Identity & access — reference

The exhaustive surface for identity, app contexts, the scope model, access profiles, roles, and credentials: every method, its parameters, validation rules, limits, the response envelope, error codes, and an honest Notes & limits section. For concepts see explanation.md; for runnable guides see how-to.md.

For the canonical, always-current request and response field shapes, use the generated API reference (rendered from the OpenAPI specification). This page is the durable map and the honest edges — not a regenerated copy of the raw endpoint schemas.

Version note. The generic identity-entity surface (/v1/entities/{namespace}, /v1/namespaces) shipped in 0.35.0, replacing the dedicated /v1/orgs and /v1/clients endpoints in a clean break — there is no compatibility shim. A client older than 0.35 cannot reach this surface; upgrade before authoring against this page.


Client construction

import { VectrosClient } from '@vectros-ai/sdk';
const client = new VectrosClient({ token, environment });
FieldMeaning
tokenThe bearer credential: sk_*, ssk_*, or st_*.
environmentThe API base URL, e.g. https://api.vectros.ai (production) or https://api.staging.vectros.ai (staging).

Identity and access methods live under two sub-clients: client.identity.* (users, identity entities, namespaces) and client.auth.* (contexts, roles, access profiles, scoped keys, token minting, the identity-binding check, and the cross-context principal lookup).

Response envelope

List, lookup, and version-history methods return the standard page envelope:

{ data: T[], nextCursor: string | null }

Drain by feeding nextCursor back as the next call's startFrom until it is null. The identity-binding check (ping) and token minting are not enveloped — they return a plain object.


Identity plane — client.identity.*

Two dimensions on this plane: the fixed user surface, and the generic identity entity surface, addressed by namespace. org and client are two reserved namespace names — they carry no dedicated endpoints or fields of their own; register them and create, read, and list entities in them exactly as you would in any namespace you register yourself.

Users

MethodPurpose
createUserCreate (idempotent by externalId).
getUserFetch one by Vectros id.
updateUserFull-replace update (PUT) of the body.
deleteUserDelete by id.
listUsersList, filterable by externalId; enveloped.
getUserVersionsVersion history; enveloped.
FieldTypeNotes
externalIdstringYour id; create is idempotent on it. Capped at 256 chars; permissive about characters.
idstringVectros-assigned UUID; returned on create/get/list.
emailstringUsers carry email, not name.
typeenumHUMAN (default) or SERVICE.
payloadobjectFree-form Record<string, unknown> attribute bag, round-tripped as-is.
statusstringACTIVE on create directly; PENDING | ACTIVE | SUSPENDED over the lifecycle. PENDING is server-managed — you cannot set it directly (below).
schemaIdstringOptional governing schema. When set, payload is validated + lookup-indexed against it, and a token carrying users:s:<schemaType> (the type the schema declares) can reveal that type's sensitive fields on this user — the same mechanism as entities:s:<namespace>.

Inviting a sub-user — client.auth.*

An alternate creation path that binds an access profile in the same call, so the invitee arrives already permissioned. Lives under client.auth.* (not client.identity.*) because it's driven by the access-profile shape.

MethodPurpose
createInviteCreate a PENDING user and bind an access profile to it, by email. Idempotent on (contextId, email) — a repeat invite rotates the token and resends. Requires users:c; if the pair already has an outstanding invitation in the SAME context, also requires users:r and users:u (the resend path), else 409. An email that already resolves to a member of a DIFFERENT context in the same tenant grants/attaches access there instead: if that member is ACTIVE, this mints no token — nothing is mutated — so it needs only users:r on top of the baseline users:c (not users:u) — except when that member's credential can't authenticate through the target context's own registered issuer, in which case this falls back to a normal independent invitation (its own token/accept link) instead, see below; if that member is still PENDING elsewhere, this rotates its token exactly like the resend path, so it needs users:r+users:u together, same as resendInvite.
resendInviteRotate the token and extend expiry on an outstanding invitation, identified by (contextId, email); re-delivers if sendEmail. Invalidates any previously issued accept link. Does not change the bound permissions. Requires users:c, users:r, and users:u together.
updateUserAlso the accept path: a PUT carrying status: 'ACTIVE', inviteToken, externalSubject, and emailVerifiedAttestation: true moves a PENDING user to ACTIVE. Calling this yourself does not grant the invitee sign-in anywhere (not the Vectros dashboards, not token exchange) — see Inviting a sub-user by email for what externalSubject actually does here and the recommended alternative when the invitee needs to sign in themselves.

Every users:c/users:r/users:u requirement named above has an equally sufficient alternative, letter-for-letter: the member-lifecycle capability plus the matching unqualified profiles:<verb> grant(s), for a credential scoped to a single app context. profiles:c is the baseline for BOTH methods (it's what gates entry to createInvite at all, including every case named above, and resendInvite requires it explicitly) — never omit it. On top of that baseline, grant only the additional letters the specific case actually names: profiles:r alone for the ACTIVE-attach case; profiles:r+profiles:u for a resend or a PENDING-attach. See the member-lifecycle row in the granted_capabilities table.

CreateInviteRequest fields:

FieldTypeNotes
emailstringRequired. Rejected with 409 if it already belongs to an active/suspended member of contextId, or already has a PENDING invitation under contextId without the caller holding users:r+users:u to resend it. An email already resolving to an ACTIVE or PENDING member under a DIFFERENT context in this same tenant grants/attaches access to contextId instead (or SUSPENDED stays a hard block) — see above — provided the caller holds the scope that grant/attach itself needs (users:r for ACTIVE, users:r+users:u for PENDING); without it, this also 409s. An email that already has an identity in your OTHER tenant (test vs. live) is not a collision — it gets a second, independent membership there.
contextIdstringRequired. Must reference an existing app context in the tenant your calling credential is bound to — the invitation lands in that same tenant; the invitee's access profile is bound to it.
accessProfile{ roleId } | { scopes }Required. Exactly one of the two, same XOR as a standalone access profile. A roleId naming no role in the context is rejected with 400. The resolved scope (the role's, or the inline scopes) is checked against the caller's own scope and rejected with 403 if it exceeds it — you cannot invite someone into more than you hold.
acceptUrlstringRequired when sendEmail is true (the default). Your https landing page; the token is appended as a t query parameter. IP-literal hosts are rejected.
sendEmailbooleanDefault true. false skips delivery and returns inviteToken + acceptLink in the response instead, for you to deliver through your own provider.
fromNamestringOptional display name for the invitation email's From header. Default Vectros; max 100 chars, no newlines.
ttlSecondsintegerOptional. Default 604800 (7 days); must be between 3600 (1h) and 2592000 (30 days).
firstNamestringOptional; personalizes the email greeting only — not stored or returned.
lastNamestringOptional; accepted but currently unused (not stored, returned, or applied to the greeting).

CreateInviteResponse fields: userId (stable across accept), inviteExpiresAt (ISO-8601; accept attempts after this are rejected), emailSent (true only when sendEmail was true and delivery succeeded), and — only when sendEmail was false — inviteToken and acceptLink. Treat inviteToken as a credential: holding it is sufficient to accept the invitation.

Inviting an email that already resolves to a member elsewhere in the SAME tenant — including the tenant owner's own email — grants access to contextId rather than 409ing, unless the email is already a member of contextId itself (still a collision), SUSPENDED (a deliberate lockout — the new context is never attached silently; reactivate the user explicitly first), or the caller lacks the scope the grant/attach itself needs (below — in which case it's a 409 too). An ACTIVE member with no existing access to contextId gets it immediately (201, no email — emailSent is false, inviteToken/acceptLink are absent since there's nothing to accept); this requires users:r alongside users:c (the response names their existing userId) — without users:r, 409. A member whose original invitation is still PENDING has contextId's access attached to that same outstanding invitation and its token rotated, requiring users:r+users:u, same as an ordinary same-context resend — without either, 409. One exception for an ACTIVE member: if their credential can't actually authenticate through contextId's own registered issuer (their bound externalSubject is proven exclusively from a different trusted issuer than the one contextId uses), attaching silently would be a dead end — a normal, independent invitation is created instead (its own userId and token/accept link), exactly as if the email had no existing identity in the tenant. An email that already has an identity in your OTHER tenant is never one of these cases — createInvite always succeeds there with a second, independent membership.

While an invitation is outstanding: email cannot be changed on the PENDING user by a plain update — delete and re-invite to redirect it. Deleting a PENDING user also removes its bound access profile. Once accepted (PENDING → ACTIVE), email is editable again and externalSubject is treated as immutable.

Identity entities — /v1/entities/{namespace}

One CRUD-plus-lookup surface for every entity in every entity-backed namespace, including org and client. The namespace is a path parameter, not a body field — it is immutable per entity and set once, at create.

MethodPurpose
createEntity({ namespace, body })Create (idempotent by (namespace, externalId)). ?upsert=true overwrites an existing match instead of returning it unchanged.
getEntity({ namespace, id })Fetch one by Vectros id.
updateEntity({ namespace, id, body })Full-replace update (PUT).
deleteEntity({ namespace, id })Delete by id.
listEntities({ namespace, userId?, externalId?, scope?, type?, field?, value? | from?+to? | prefix?, order?, startFrom?, limit? })List, and — via the type/field/… params — the schema-field lookup surface (non-sensitive fields); enveloped.
lookupEntities({ namespace, body })Body-based lookup for a sensitive schema field (the value travels off the URL); enveloped.
getEntityVersions({ namespace, id })Version history; enveloped.

Fields (EntityRequest, on create/update):

FieldTypeNotes
externalIdstringRequired on create. Your id, unique within the namespace; create is idempotent on it.
namestringHuman-readable name.
statusenumACTIVE (default) or SUSPENDED. A label you set and read back: SUSPENDED records your own intent to retire the entity, and the platform does not enforce it — a suspended entity is still readable, updatable and referenceable. Enforce it in your own application if you need it to have an effect. Sent case-insensitively and stored uppercase; any other value is rejected with 400, on create and update.
payloadobjectFree-form attribute bag. On update, replaces the stored payload in full (not merged); omit to leave it unchanged.
schemaIdstringOptional governing schema (validates + lookup-indexes payload). Must belong to your account.
scopesstring[]Parent ownership edges, each <namespace>:<value> — at most two, each in a namespace different from the entity's own. On update, an explicit scopes replaces the full parent set; omit it to leave ownership unchanged. The entity's own reference is accepted-and-ignored (so you can round-trip a GET's scopes value unchanged); any other value in the entity's own namespace is rejected — a parent always crosses namespaces. For a scoped credential, the update is authorized against a single scope clause that must cover every namespace the change touches — including a parent you are removing (dropping a label is authorized exactly like adding one). Because PUT is a complete declaration, re-sending an unchanged parent that sits in a namespace your granting clause doesn't cover makes the whole PUT a 403 naming that namespace; with no PATCH on the identity plane, the remedy is to send only the labels your clause covers.

Fields (EntityResponse, on read):

FieldTypeNotes
createdbooleanCreate-response only: true for a new entity, false for an idempotent-return or upsert-overwrite.
idstringVectros-assigned UUID.
namespacestringThe entity's namespace (present on responses; not a request field).
externalIdstringAs supplied at create.
namestring
statusstringACTIVE | SUSPENDED.
scopesstring[]The entity's effective scopes: its own reference (<namespace>:<id>) followed by its parent edges, e.g. ["team:<id>", "org:<id>"].
payloadobjectSensitive fields masked ([redacted]) unless the token carries entities:s:<namespace>.
schemaId / schemaVersionstring / numberThe governing schema and the version in effect when last written.
contextIdstring?The app context the entity's namespace is registered under (null for a tenant-wide namespace) — an echo of the stored row, not a request field. Mirrors NamespaceResponse's contextId above.
createdAtstringISO-8601 UTC.

Namespaces — /v1/namespaces

Declares whether a scope:<namespace> value is a free-form string (the default) or must resolve to a real identity entity. org and client are ordinary namespace registrations — reserved names, not built-ins — and, like every namespace, require an explicit registration before use. There is no special case for them on this surface.

MethodPurpose
registerNamespace(body)Register a namespace. Requires specificityRank. Root sk_*; or the CLI bootstrap's provisioning capability, confined to its own context; or the CLI bootstrap's separate tenant-wide namespace-provisioning capability, which may register a TENANT-WIDE (no ?contextId=) namespace only — never a named context, including its own.
getNamespace({ namespace })Fetch one. Open to any credential, confined to the credential's own registration (see Placement, below).
listNamespacesList; enveloped. Open to any credential, confined the same way.
updateNamespace({ namespace, body })Update entityBacked / defaultSchemaId / specificityRank / the membership fields below. Root sk_* only.
deleteNamespace({ namespace })Delete. Root sk_* only.
FieldTypeNotes
namespacestring2–32 chars, lowercase-first grammar (^[a-z][a-z0-9_-]{1,31}$). Immutable once registered. The ten hard-reserved names below can never be registered; org/client are reserved but ARE registerable — see below.
entityBackedbooleanDefault false. true ⇒ every scope:<namespace> value platform-wide must resolve to a real entity in this namespace (fail-closed existence check on create); false ⇒ free-form string, grammar-only — and note "grammar-only" is not "unconstrained": see the value row below.
(the <value> half)string1–128 chars, ^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$ — a letter or digit first, then letters, digits, _ or -. Applies wherever a scope value is written or filtered: the scopes array, ?scope=, an access profile's identityOverrides, and a scoped token's identity. Deliberately excludes : (a value becomes part of a storage key whose parser splits on colons) and $/{/} (a stored value is substituted into a scope clause and re-parsed, so a placeholder-shaped value would be read back as a matcher). Unlike the namespace, a value may be mixed-case and may start with a digit — entity ids are UUIDs.
contextIdstring?Placement, fixed once set. Omit (or don't supply ?contextId= on create) for the default — a tenant-wide registration, shared by every app context. Pass ?contextId= on POST /v1/namespaces to make the registration context-owned: it belongs entirely to that one app context, invisible from every other context — two contexts can each register their own team namespace with no collision. A namespace cannot be re-homed after registration; its entities' partition is part of their key. The response always carries contextId (null for a tenant-wide registration); there is no separate placement field.
defaultSchemaIdstringOptional default governing schema for entities created in this namespace.
specificityRankintegerRequired on create; account-unique. An integer position in your account's specificity order, used to break a tie when a caller holds two scope dimensions at once during basedOn schema resolution — the higher-ranked namespace's variant wins. Optional on update (omit to leave unchanged). Leave headroom between values (10, 20, 30, …) so a later namespace can slot in between without renumbering.
membershipRecordType / membershipTargetFieldstring / stringOptional pair, all-or-nothing: which record type and field name together hold the grant for this namespace's membership (see Membership backing, below).
membershipLevelField / membershipLevelsstring / string[]Optional; a closed set of tier labels (e.g. admin/viewer) for level-qualified membership grants.
membershipContextIdstringRequired alongside membership fields only on a tenant-wide registration — which app context holds the grant records. A context-owned registration's grants live in its own context by construction and need no separate field.
reservedboolean(response only) true when this registration was provisioned by the platform rather than authored by a caller; false for a namespace you registered yourself.
createdAtstring(response only).

?contextId= propagates to every /v1/entities/{namespace} operation, not only namespace registration — GET (by-id, every list/lookup mode), PUT, DELETE, GET .../versions, and POST .../lookup all take it. It is required for a context-owned namespace and rejected for a tenant-wide one; a context-confined credential may only name its own context. Naming a different context explicitly is refused with 403 before the lookup ever runs — it never reaches a sibling context's entities to get a 404 for them. Within its own context (named or omitted), an entity that doesn't exist there answers 404, same as anywhere else. Reading a namespace's own registration is confined the same way: a credential may read only its own context's registration, or the tenant-wide one — never a sibling context's.

Writing a namespace registration — create, update, or delete — always requires a root API key, regardless of placement, UNLESS the caller holds one of two owner-only, non-grantable CLI bootstrap capabilities, neither of which extends to update or delete:

  • The bootstrap's provisioning capability may additionally create a registration confined to its own bound app context — never a context it isn't bound to.
  • The bootstrap's separate tenant-wide namespace-provisioning capability may additionally create a tenant-wide registration (omitting ?contextId=) — and only that: naming any context explicitly, including its own, is refused exactly as for a caller without it. The two capabilities are independent — holding one confers none of the other's reach — and a bootstrap token typically carries both together.

Membership backing. A registration may declare where its membership lives, independent of where its entities live: membershipRecordType/membershipTargetField name the record type and field that, together, hold the grant. Declaring a membership backing grants nothing by itself — a role's data_scope must opt in explicitly with ${{ member.scope.<namespace> }} (any level) or ${{ member.scope.<namespace>:<level> }} (one level only); authoring a level the namespace hasn't declared is rejected at write time. Resolution happens once per request, never at mint time, so a revoked membership takes effect on the caller's very next request rather than waiting out a token's lifetime.

org and client must be registered explicitly, like any other namespace. Register both once, with a root key, before creating an org/client entity — POST /v1/entities/org/client returns 400 ("not entity-backed") until you do:

POST /v1/namespaces {"namespace": "org",    "entityBacked": true, "specificityRank": 1000}
POST /v1/namespaces {"namespace": "client", "entityBacked": true, "specificityRank": 2000}

A one-time step per account. Once registered, both behave identically to any other namespace — they appear in GET /v1/namespaces, and no request/response shape changes. The retired /v1/orgs and /v1/clients routes remain retired.

Reserved namespace names — never registerable, because they collide with a fixed schema-reference surface or an /v1/entities/{namespace}/... sub-path: record, document, entity, user, self, tenant, context, scope, versions, lookup. org/client are namespace names too, but unlike the ten above they ARE registerable — see above.

Referential integrity. A namespace cannot be deleted, or updated from entityBacked: true to false, while entities still exist in it — both reject with 409.

CLI equivalents

vectros identity create --type <user|namespace> --external-id <id>
                        [--name <n>] [--email <e>] [--service] [--scope <ns:value>...] [--metadata <json>]
vectros identity list   --type <user|namespace> [--external-id <id>] [--limit <n>]
vectros identity get    --type <user|namespace> --id <vectrosId>
vectros identity delete --type <user|namespace> --id <vectrosId>

--type takes user (the fixed principal surface) or any namespace — org/client (reserved names, registered like any other) or one you've registered. --name and --email are mutually dimension-specific (--email only for user; --name only for an entity namespace). --service only applies to user; --scope <namespace:value> (repeatable, ≤2) only applies to an entity namespace. There is no CLI command for the namespace registry itself — register a namespace via the SDK or API with a root key (see how-to.md); the CLI's identity command only creates/reads/lists/deletes entities inside a namespace that already exists.


App contexts — client.auth.*

The isolation partition. contextId must match ^[a-z][a-z0-9-]{2,30}$ (starts with a lowercase letter; lowercase letters, digits, hyphens; 3–31 chars total).

MethodPurpose
createAppContextCreate (idempotent by contextId). name is required; companyName is optional. Root sk_* only.
getAppContextFetch one by contextId.
updateAppContextUpdate name / description / companyName / identityProjectionClaims (the path supplies contextId; the body's contextId is required by the schema but ignored — it is immutable).
listAppContextsList; enveloped.
deleteAppContextConfirm-gated async cascade — see below. Root sk_* only.

companyName is distinct from name: name is the app's own fixed identity, companyName is the deploying organization's own display name, used to personalize platform-sent correspondence (e.g. sub-user invitation emails) with the deployer's own branding instead of a generic app name. Optional on every request; absent on any context that predates its introduction.

identityProjectionClaims (0.43.0). Declares which of your tenant's captured identity-provider claim names (see capturedClaims under Trusted issuers, below) get projected, read-only, onto access profiles in this context. Filled in once per profile, the first time a sign-in for that principal can supply a value — usually at profile-creation, but for an invited member not until they actually accept the invite and sign in for the first time (there's nothing to project before that). Once filled, a profile's projection does not update again even if this declaration or the underlying identity data changes later — changing this declaration only affects profiles that haven't been filled yet. Requires a root API key or your platform provisioning credential — an ordinary app-contexts:u-scoped token can update name/description/companyName but cannot set this field; attempting to returns 403. Projected values surface as a read-only identityProjection object on AccessProfileResponse (see Access profiles, below) — absent when your context declares no projection, when none of the declared names have a captured value for that principal yet, or before that principal's first successful sign-in. Omit to leave unchanged; send an empty list to disable future projection.

⚠️ Declaring a name here is itself the access-control decision — identityProjection on the read side carries no additional gate. Unlike email on the same AccessProfileResponse (gated behind your token also holding users:r), every projected field is returned to any caller who can read the profile at all — an ordinary profiles:r grant, no users:r needed. If you declare a PII-shaped claim name (e.g. "email", "phone_number"), every profiles:r holder in this context sees it. Declare only names you intend everyone with roster-read access to see.

Root-only lifecycle (API). On the API, creating and deleting an app context require a root sk_* key. A scoped key or token (ssk_* / st_*) cannot create or tear down a context — not even one carrying the wildcard * scope. (Get / update / list are reachable with appropriate scope.) Account owners additionally have a self-service path that does not involve the root key: the dashboard's Contexts page and vectros context destroy both perform the same confirm-gated teardown through an owner-gated server-side route, so no teardown-capable credential is ever held by the browser or stored by the CLI.

Delete contract. deleteAppContext({ contextId, confirm }) requires confirm to equal contextId:

  • Without a matching confirm → 400, and nothing is touched (the rejected delete is a no-op; child roles/profiles still exist).
  • With a matching confirm → accepted (202); the context flips active → purging immediately and drains all of its records, documents, folders, schemas, roles, and profiles in the background, reaching deleted when the drain completes.

Reserved context. Certain context ids are reserved by the platform and cannot be created by a tenant — vectros-admin (backs the hosted admin surfaces) and default (the base context auto-provisioned for every tenant).

Errors. Malformed contextId → 400 with a developer-friendly message. Get on a well-formed but never-created contextId → 404 (not 500). Cross-tenant probes collapse to 404.

CLI equivalents

vectros context create <contextId> [--name <n>]
vectros context list
vectros context get <contextId>
vectros context destroy <contextId> [--force] [--tenant test|live]

(destroy is the same confirm-gated async cascade as the API delete: interactively it asks you to re-type the context id, and --force skips that prompt — required when stdin is not a terminal. It authenticates as the account owner, so it works without the root key.)


The scope model

Scope grammar

An allowed action is resource:ops[:qualifier]:

  • resource — one of the data-plane resources records, schemas, search, documents, folders, inference, entities (below, qualified by namespace), or a control-plane resource: users (your tenant's own user identities — users:c, users:r, users:u, users:d; the developer portal's scope editor also offers users:crud as a one-click shorthand for all four), profiles (access profiles — profiles:c, profiles:r, profiles:u, profiles:d), scripts (scripts:c, scripts:r, scripts:d, and the execute letter scripts:x — there is no scripts:u, since a stored version is immutable) or triggers (triggers:c, triggers:r, triggers:u, triggers:d). users takes no qualifier on c/r/u/d — a qualifier there is rejected — but does on the fifth op letter, s (below). profiles:c/u/d accept an optional qualifier confining WHICH principal the grant may act on — a literal usr_<id>, or the bare self sentinel. self matches only when the target of the request is the credential's own bound principal; it never widens a grant, and it is a plain literal in the qualifier position, not a ${{ }} template. A bare, unqualified entry (e.g. profiles:cru) is unchanged and stays broad — it remains the context-admin grant. profiles:r does not accept a qualifier: GET/list routes are unaffected by this grammar. Example: {"allowed_actions": ["profiles:u:self"], "data_scope": {}} lets a credential update only its own access profile in a context, never another principal's.
  • ops — any combination of the letters c create, r read, u update, d delete. E.g. records:r, records:cru, documents:crud. Two more letters are accepted on any resource but have an effect only where a resource defines one: s reveals sensitive fields (records:rs:patient) and x executes a stored script (scripts:x, or scripts:x:<name> for one script name; see POST /v1/scripts/execute in the data-model reference).
  • qualifier — optional tail that narrows (e.g. records:r:intake_form = read only the intake_form record type; entities:cru:org = create/read/update entities in the org namespace only). It never widens.

scripts:c is one trust tier, and it cannot be narrowed. Unlike profiles, it accepts no qualifier on c/r/d — scripts:c:<name> is rejected at authoring, because pushing a script has no per-name routing that would make a name-scoped grant do anything but sit inert (the x execute letter is the one op that DOES take a script-name qualifier — see above — and is a separate, narrower axis from create). A credential holding bare scripts:c may push a new version under any script name in the context, including the name behind an existing version:"latest" trigger rule — whose code then runs under that rule's own stored grant the next time it fires, not the pusher's. There is no way to grant a narrower form of scripts:c today. Treat it the way you would a deploy credential, and grant it no more widely than you would one.

entities follows the same resource:ops:<qualifier> grammar as records, with the qualifier naming a namespace rather than a record type — entities:c:team grants create on team entities only. A fifth op letter, s, reveals sensitive payload fields — entities:s:<namespace> for entities, and users:s:<schemaType> for users that carry a governing schema (schemaId) — see data-model reference for the general s op.

What does NOT grant access (author the letter form instead):

FormEffect at runtime
records:r, search:r, entities:r:org, …✓ Grants the named operations.
* (the single literal wildcard)✓ Grants everything — the shape root keys carry. Reserve it.
A coarse verb (read, write, delete)✗ Grants nothing.
resource:* (operations-wildcard)✗ Grants nothing at runtime. Author explicit c/r/u/d letters.

The operations-wildcard resource:* does not grant access at the runtime enforcement layer. Always author the explicit letter form. The only wildcard that grants anything is the bare literal *.

Managing the namespace registry itself (POST/PUT/DELETE /v1/namespaces) is not a grantable scope at all — it is root-key-only regardless of any scope a token carries; namespaces:* is not a recognized resource.

granted_capabilities

A scope clause — in a role, an access profile, or an invitation — accepts an optional granted_capabilities array alongside allowed_actions and data_scope. A capability names a bounded effect that reaches across a partition boundary, which the resource:ops verb grammar above cannot express. Six names are recognized in this release, and the list is closed and versioned with the API: a later release may accept further names, and a name this release does not accept is rejected when you author the clause, not stored inert.

CapabilityEffect
member-lifecycleCreate and remove identities in your tenant — and, for a still-pending invitation, resend it (rotate its token, extend its expiry) or attach an existing member to an additional app context. Bounded by the app contexts the credential can reach — an ordinary grant where app contexts separate your apps, but not where you have modelled your own customers as separate app contexts. Adding someone who already has an identity resolves them by email/externalId; it does not let the credential look an identity up on its own.
forensic-readRead the access log across every context in your tenant, by the credential that performed each read. Bounded by the tenant, not the credential's app context — crossing contexts is the point. Confers nothing on its own: the endpoint still requires the access-log:r action, so the effective grant is access-log:r plus this name. Grant it to an audit or support role.
context-directory-readRead your tenant's app context directory (which contexts exist, their roles and access profiles, and which contexts a given principal is in) across every context, not just the credential's own — see GET /v1/principals/{id}/profiles below for its concrete effect. Bounded by the tenant. Confers no data-plane reach at all (records, documents, folders, schemas, entities) and no write. Grant it only to an admin or support role.
delegate-mintMint a scoped API key (ssk_*) bound to a different principal than your own via POST /v1/admin/keys/scoped — see Scoped key lifecycle below. Bounded by the tenant: the credential it lets you mint literally is another principal, wherever that identity is later used. The target must still already be a member of your own app context, and the minted key's effective scope can never exceed your own — this capability lifts only who the minted key may be, not what it may do. Grant it to an operator or automation role that provisions credentials on other members' behalf.
delegate-principal-stampWhen creating a record, stamp a userId belonging to someone else in your tenant instead of only your own. Without it, a userId on create must match the credential's own identity or the request is rejected. Confers nothing by itself: the same data_scope that already authorizes the write must also explicitly name userId (a clause silent on userId still denies), so you keep exact, per-value control over which principals may be named — but the capability itself doesn't have to live on that same clause; declaring it anywhere on the role (including a separately-composed roleIds entry) is enough, only the write's own verb+userId admission must be co-located. Confers no ability to act as that person — only to label a record as theirs, which becomes usable once they authenticate with their own credential. Bounded by the same app context and record type your data_scope already confines the write to — unlike four of the other five, it is not tenant-wide. Applies to record creation only; an update can never reassign userId, with or without this capability. Grant it to an admin role that provisions membership-shaped records on behalf of known users.
trigger-control-plane-grantLet a trigger rule's own grant (its scopes/composed role) name a control-plane resource — keys, profiles, users, app-contexts, access-log, or scripts, any op — or the bare wildcard *, which otherwise names every resource. Without it, POST/PUT /v1/triggers refuses such a grant outright. Bounded by the tenant: a trigger's grant executes unattended on every matching record write for as long as the rule exists, which is a materially different exposure from a human exercising the same scope once. Grant it only to a role that deliberately author control-plane-reaching triggers.

Three rules govern the field, all fail-closed:

  • An absent or empty list grants no capability. Absence is silence, not a wildcard.
  • An unrecognized name denies that whole clause rather than being ignored — a typo fails loudly at the first request instead of quietly downgrading the grant to its surviving verbs.
  • A "*" in allowed_actions confers zero capabilities. "*" is not a wildcard in this list, and is rejected as a name; name each capability explicitly. Where a capability acts on individual records, the clause's data_scope narrows it too — delegate-principal-stamp is the one name in this release where that narrowing genuinely applies (per the exact userId values the clause names); the other five are unaffected by data_scope.

On self-signup — and this cuts one way, not all six: a role carrying forensic-read, context-directory-read, delegate-mint, or trigger-control-plane-grant is refused when a self-signup policy creates a profile, so no new self-signup can be created against such a role. member-lifecycle and delegate-principal-stamp are the deliberate exceptions — both are bounded by app context rather than the tenant, so a self-signup role may carry either; granting member-lifecycle to your default self-signup role is the intended way to let a founder sign up and then invite their own team, and delegate-principal-stamp lets that same founder provision membership-shaped records for the people they invite. For the four refused names, the check runs at profile-creation time rather than continuously — adding one of them to a role your users can already sign up to grants it to those existing members at their next token. Treat re-scoping such a role as the authoring decision it is.

assignable_roles

(0.43.0+) A scope clause — in a role, an access profile, or a scoped-key scope — accepts an optional assignable_roles array: a roleId allow-list restricting which named roles that clause may compose into a delegated access profile (roleIds composition). It is orthogonal to allowed_actions/data_scope — it narrows which roles the clause's authority may hand out, not how much data it reaches. Capped at 20 entries. Absent or null means no roleId restriction, which is the default and matches how every clause authored before this release behaves.

You opt in by adding the field to a clause that grants role- or profile-creating authority — which is profiles:c: roles live under an app context (/v1/app-contexts/{contextId}/roles) and are governed by the same scope, so there is no separate roles resource to grant.

⚠️ Adoption is one-way for that clause, and the order matters. Once a clause carries assignable_roles, it can compose only the roles the list names — so name every roleId the restricted clause still needs before you add the field, not after. "Unchanged behavior" holds for non-adopters only.

One further condition catches a common shape: a composed role that itself grants role-composing authority — profiles, users, keys or triggers with c, u or d, or a wildcard * — must also carry an assignable_roles no broader than yours. users:c is "can invite team members", so this is not exotic. Without it, composing such a role would hand the delegate unrestricted composing power one hop later, which is exactly the escalation the field exists to close. An ordinary role that grants no composing authority of its own needs no change, and composing one keeps working exactly as it does today.

dataScope

A map from an ownership dimension to the list of allowed values. userId is its own fixed dimension; every other dimension is a namespace, keyed scope:<namespace>:

{ "dataScope": { "scope:client": ["client_abc", null] } }
KeyMeaning
userIdConfine to rows owned by the listed users.
scope:<namespace>Confine to rows whose scopes include one of the listed <namespace>:<value> entries — scope:org, scope:client, or any namespace you registered.
  • Enforced as a server-side filter below any caller-supplied filter; cannot be widened by the caller.
  • Multiple values in one dimension's list → union (OR). Multiple dimensions → intersection (AND).
  • Strict by default. A scoped credential must include the matching filter on every list and search call (?userId=, or ?scope=<namespace>:<value> for a namespace dimension), or the request is rejected (e.g. "scope:org is required by token scope").
  • null sentinel. Include JSON null in a dimension's value list to additively grant access to tenant-level rows (no value for that dimension). Opt-in only — never implicit.
  • ?scope= takes one entry per query — filtering on two namespace dimensions at once in a single call isn't expressible; ?userId= plus one ?scope= is. Repeated ?scope= may land later.
  • Reads vs. writes on an unnamed dimension. A dimension dataScope says nothing about doesn't narrow reads — but it also grants no way to write into it. To place data in a given dimension, that dimension must be named in dataScope, either explicitly or via the "*" default-dimension form (a role clause's {"*": [...]} entry — see Role fields above).

The bootstrap scope gate (data-plane allowlist)

The CLI / blueprint bootstrap flow mints scoped keys only for the data plane. The allowlist is exactly:

records, schemas, search, documents, folders, inference, entities

entities grants ordinary per-namespace identity-entity CRUD — creating/reading/updating/ deleting org/client/or any namespace you registered — gated by the same dataScope/ownership rules as records. What it does not grant is any authority over the namespace registry itself: registering a new namespace (POST/PUT/ DELETE /v1/namespaces) is root-key-only regardless of any scope a token carries, and namespaces is not a resource the bootstrap loader will ever mint (see above).

Any other resource — the control plane keys, profiles, app-contexts, users, billing, admin, namespaces, or any unrecognized resource — and the literal * are hard-rejected: the bootstrap mints nothing and exits non-zero. There is no override flag; control-plane scoped keys are created deliberately in the developer portal.


Access profiles — client.auth.*

The per-principal, per-context permission binding.

MethodPurpose
createAccessProfileCreate/bind (idempotent by (context, principalId)).
getAccessProfileFetch one by (contextId, principalId).
updateAccessProfileUpdate scopes/role/status/overrides.
deleteAccessProfileRemove the binding.
listAccessProfilesList a context's profiles; enveloped.
listProfilesForPrincipalCross-context: every context a principal is bound to; enveloped.

Profile fields

FieldMeaning
principalIdThe bound principal: usr_<userId> or key_<keyId>. Must start with usr_ or key_; structural characters like : are rejected with 400.
scopesInline clauses, each { allowed_actions: string[] } (snake_case on the wire). XOR with roleId/roleIds.
roleIdReference to a single reusable role. XOR with scopes/roleIds. Must name a role that exists in the context — a roleId with no matching role is rejected with 400 naming it, on create, update/upsert, and when a scoped-key mint resolves the bound profile's role. (Previously accepted and stored, leaving a profile that could never authenticate — that gap is closed.) Deprecated as of 0.41.0 in favor of roleIds — still accepted, but sending both in the same request is 400.
roleIds(0.41.0+) Reference to one or more reusable roles, composed additively: the effective grant is each named role's own clauses, concatenated in the order listed — never merged, so each clause keeps meaning exactly what its own author wrote. Every id must name a role in the same app context, and no id may repeat. XOR with scopes; deprecated-but-compatible with roleId (sending both is 400). Reads always return roleIds, and return roleId too only when exactly one role composes.
statusactive or suspended. Suspending denies access without deletion — and (0.43.0+) it stops NEW credentials being issued against the profile immediately, on both issuance paths: createScopedKey is refused 409, and the token exchange is refused with its usual uniform 403 invalid_grant. Both read this field live. Credentials issued BEFORE the suspension are a separate matter and unchanged: they may keep working for up to five minutes while the access-profile cache expires, and an st_* keeps its own one-hour lifetime regardless. So suspension is immediate containment against new credentials and eventually-consistent against existing ones; to stop a specific credential now, revoke that credential.
assumable(0.41.0+) Only valid alongside inline scopes — the entitlement grant POST /v1/auth/token/assume reads, same grammar and 403 authoring-subset rule as Role.assumable (below). Setting it on a roleId/roleIds-composed profile is rejected outright with 400 (it would never be read); author the grant on the referenced role(s) instead.
identityOverridesOwnership values stamped onto what the principal touches — each a bare entity-id string keyed by scope:<namespace> (e.g. {"scope:org":"<entity-id>"}), reserved (scope:org, scope:client) or one you registered, up to two total. The value is authorized like scopes/roleId — and this applies even to an update whose body carries only identityOverrides: a scoped credential may override only to a scope:<namespace> value it itself holds (403 otherwise); a root key's override value must reference an entity that exists (400 naming the value otherwise). userId and the tenant id are sacred and rejected with 400. The same authorization also runs on the value being replaced: changing or clearing an entry — including wiping identityOverrides via an empty map — is 403 unless the caller holds the value it would overwrite, and deleteAccessProfile is refused the same way if the profile's identityOverrides holds a value the caller doesn't hold. Unaffected: giving your own identity to a profile with none, editing or deleting a profile whose identity is already yours, omitting identityOverrides entirely, and root API keys (exempt throughout).
identityProjection(0.43.0, READ-ONLY.) IdP-asserted identity fields projected onto this profile, per the app context's identityProjectionClaims declaration (see App contexts, above). Filled in once, the first time a sign-in for this principal can supply a value — usually at profile-creation, but for an invited member not until they actually accept and sign in for the first time. Once filled, it does not update again even if the declaration or the underlying identity data changes later. Absent when the context declares no projection, when none of the declared names have a captured value for this principal yet, or before this principal's first successful sign-in. Cannot be set or changed via createAccessProfile/updateAccessProfile — sending it in either request body is silently ignored; the stored value is untouched.

XOR enforcement. A profile carries exactly one of inline scopes or a role reference (roleId/roleIds). Updating to set one clears the other (empty-string / empty-array sentinels). On create, the unset half is absent or an empty sentinel. roleId is a deprecated-but-compatible derived mirror of roleIds as of 0.41.0, not an independent third option: send either, never both (400), and a read always returns roleIds, returning roleId too only when exactly one role composes.

Idempotency. A repeat create for an existing (context, principalId) returns the existing profile unchanged.

Cross-context lookup — narrowed for another principal. listProfilesForPrincipal({ principalId }) (GET /v1/principals/{id}/profiles) returns every profile for that principal across all contexts when you're looking up your own principal, or when the calling credential holds the context-directory-read capability (see Granted capabilities, above) — same as before. Holding profiles:r alone is not enough to enumerate a different principal's access across contexts: a context-confined credential looking up another principal instead sees only that principal's profile in the credential's own context, at most one result. If you rely on profiles:r alone to enumerate another principal's access across contexts, grant context-directory-read to the calling role as well. A principal with no visible profiles returns an empty array (200), not 404. A malformed principalId (e.g. containing :) → 400.

CLI equivalents (vectros access)

vectros access grant  --principal <usr_|key_> --context <c> (--role <r> [--role <r2> ...] | --actions <csv>)
vectros access revoke --principal <usr_|key_> --context <c>
vectros access list   (--context <c> | --principal <usr_|key_>)
vectros access get    --principal <usr_|key_> --context <c>
vectros access explain --principal <usr_|key_> --context <c>

--role and --actions are mutually exclusive (exactly one option, but --role is repeatable — 2+ compose additively, sending roleIds to the platform, same semantics as the wire field above). --actions mints a single-clause inline profile. access list requires exactly one of --context (a context's members) or --principal (a principal's contexts). Bind identityOverrides with --identity-overrides '{"scope:org":"...","scope:group":"..."}'.


Roles — client.auth.*

Reusable, context-scoped, identity-agnostic permission shapes.

MethodPurpose
createRoleCreate (idempotent by roleId).
getRoleFetch by (contextId, roleId).
updateRoleUpdate name / scopes.
deleteRoleDelete — blocked with 409 while a profile still references the role.
listRolesList a context's roles; enveloped.

Role fields

FieldMeaning
roleIdThe stable role handle within the context.
nameHuman-readable name.
descriptionOptional.
scopesOne or more clauses, each { allowed_actions: string[] }. Roles may be multi-clause — any clause that matches grants access.
assignableRoles(0.43.0+) Optional roleId allow-list on a clause, restricting which named roles that clause may compose into a delegated access profile. See assignable_roles above for the adoption order and the composing-authority condition.
assumable(0.41.0+) Optional map naming which values, per scope:<namespace>, a holder of this role may assume via POST /v1/auth/token/assume (below): {"scope:org": ["org_engineering", "org_sales"]}. The principal (userId) can never be named. Omitting it grants no assumption of anything. A non-root credential authoring an assumable grant broader than its own live composed reach gets 403. Also directly authorable on an inline-scopes AccessProfile (same 403 rule); setting it alongside roleId/roleIds is rejected outright with 400 (it would never be read — the role-reference path sources the grant from each referenced role's own assumable, not the profile's) — author the grant on the referenced Role(s) instead.

Role clauses accept a placeholder grammar in data_scope, in addition to literal values:

PlaceholderResolves to
${{ self.userId }}The acting principal's own userId.
${{ self.scope.<namespace> }} (e.g. ${{ self.scope.org }})The acting principal's own value in that namespace.
${{ any }}Any value present in that dimension — deliberately not a row with no value there. Combine with null in the same list to also match owner-less rows.
${{ under.self.userId }}A value whose immediate parent is the principal's own userId. One level only, not a full ancestor walk.
${{ under.self.scope.<namespace> }}A value whose immediate parent is the principal's own value in that namespace — e.g. lets a credential confined to an org act on the clients under it without naming each client at mint time.

A dimension key of "*" (e.g. {"*": ["${{ any }}", null]}) states a default rule for every dimension the clause doesn't name explicitly; a dimension named directly in the same clause always takes precedence over the "*" default. A null data-scope sentinel additively grants tenant-level (owner-less) records. Any ${{ ... }} spelling other than the forms above is rejected at authoring time with a 400 — it is never silently stored as a literal that matches nothing. These placeholder forms are authored through blueprints.

Referential integrity. Deleting a role referenced by a profile is rejected with 409 — remove or re-point the profile first. Deleting an unreferenced role succeeds; the platform does not cascade.

assumable's own value grammar (0.41.0+) is deliberately narrower than data_scope's above: a plain literal, ${{ under.self.userId }}, or ${{ member.scope.<namespace>[:level] }} — never ${{ under.self.scope.<namespace> }} (it resolves against the caller's current value for a namespace /assume can itself change, so what it admitted would depend on what was last assumed — that form stays valid in data_scope, where it's re-derived per write), a bare ${{ self.<dim> }}, or ${{ any }}, all rejected at authoring time.

CLI equivalents (vectros role)

vectros role create --context <c> --role-id <id> --name <n> --actions <csv> [--description <d>]
vectros role list   --context <c>
vectros role get    --context <c> --role-id <id>
vectros role delete --context <c> --role-id <id>

The CLI role create authors single-clause roles from --actions; assumable has no role create flag today — author it via a blueprint's roleAssumable: block (see clients/blueprints.md) or a direct API call. role get shows a role's assumable grant when one is set.


Assuming an identity — POST /v1/auth/token/assume

Re-mints the presented st_* scoped token with exactly one identity.<namespace> value changed — for a caller whose ROLE explicitly grants assuming more than one value in that namespace (an invited hr-admin, or a multi-org case-handler) and needs to change which value new writes are placed under.

Request{"scope:<namespace>": "<value>", ...} — one or more namespaces, canonical scope:<namespace> form, e.g. {"scope:org": "orgB"}. Each value must be a plain literal, never a ${{ ... }} placeholder. Naming more than one namespace requires a SINGLE one of your roles to grant all of them together — the combination is never assembled across two roles, because no role author would have vouched for it.
Credentialst_* only — a root API key or scoped API key (ssk_*) gets 403; neither needs this.
Entitlement checkEach requested value must be explicitly granted by the caller's role(s) assumable field for that namespace — a point check against the value requested, deliberately separate from what data_scope permits reading or writing. Holding broad data_scope reach in a namespace does not by itself grant assuming any value in it. Checked live, against the caller's roles as they are right now, not a copy captured when the presented token was minted.
403The requested value isn't granted by any role backing the presented token.
409The credential's basis changed since it was minted (the access profile or a role it composes from has since been edited, suspended, or deleted). Re-authenticate for a current token, then retry.
ChainingA token produced by /assume can never assume again (403) — every assume starts from the token you exchanged for. Keep your original token if you need to switch more than once.
expIdentical to the presented token's — this call can never extend a session's life.
Clause survivalEvery clause whose reach does not depend on the namespace you're changing is preserved verbatim. A clause depends on a namespace when it filters on it, or when one of its values resolves against your own value for it (${{ self.scope.<ns> }}, ${{ under.self.scope.<ns> }} — which can sit under a different key than the one changing). A role that doesn't authorize the new value loses its clauses touching that namespace, even ones scoped to the value you already held.
ErrorsOrdinary {"message": ...} shape — not the OAuth envelope POST /v1/auth/token/exchange uses.

A fresh, independently-revocable jti is stamped on every call, and the token also carries a root_jti revocation-lineage claim, so revoking the presented token closes the one token assumed from it directly — consistent with chaining being capped at depth one (above): there is no deeper chain for a revocation to need to reach.

CLI equivalent

vectros keyring/vectros join don't wrap this endpoint directly today — it's reachable via the SDK's client.auth.assumeToken(...) (or POST /v1/auth/token/assume directly), and packages/react's token cache wraps it as setPartnerApiTokenAssumer + a third argument to getVectrosApiToken.


Trusted issuers — v1/auth/issuers

RFC 8693 OAuth token exchange: register a third-party IdP once, then let its users trade its JWTs for Vectros st_* tokens without a Vectros credential of their own. See explanation.md for the concept and clients/blueprints.md for the blueprint-authored path.

Version note. Shipped in 0.39.0. 0.40.0 confined every operation on this surface to the caller's own app context and added the token-exchange context_id disambiguator. 0.41.0 added updateIssuer (see Updating a registration, below) and issuer suspension — and, elsewhere on this page, added access-profile roleIds composition and the POST /v1/auth/token/assume + Role.assumable surface (see Assuming an identity, above). See the SDK's own CHANGELOG.md (shipped in the @vectros-ai/sdk package) for the exact before/after. 0.45.0 made a registration without restrictedToDomain start as pending_verification and added verifyIssuer (see Proving control of an issuer, below); this page describes current behavior.

Issuer registration — client.auth.*

MethodPurpose
registerIssuerRegister (idempotent by issuerId). Root sk_* or the CLI bootstrap's provisioning capability only — never an ordinary grantable scope.
getIssuerFetch one by issuerId. Same gate as register.
listIssuersList the tenant's issuers (or, for a context-confined caller, only its own context's); enveloped.
updateIssuerUpdate the mutable (non-trust-anchor) field set (subClaim, emailClaim, userinfoUri, status, selfSignupPolicies, capturedClaims, restrictedToDomain) — subClaim is identity-determining and is refused once the issuer has a bound user, and restrictedToDomain has rules of its own; see Updating a registration, below. Same gate as register.
verifyIssuerProve control of a pending_verification registration, activating it (0.45.0+). Same gate as register. See Proving control of an issuer, below.
deleteIssuerDeregister. Refused with 409 if the issuer has ever bound a user.

Fields (IssuerRequest, on register):

FieldTypeNotes
issuerIdstringRequired. Your slug for this registration within your tenant: 3-31 chars, lowercase letter first, then lowercase letters/digits/hyphens. Immutable once registered; the idempotency key.
issuerstringRequired. The IdP's iss claim value, exactly as its tokens carry it.
jwksUristringRequired. The IdP's remote JWKS endpoint — where Vectros fetches the public keys it verifies signatures against. Must use the https:// scheme — an http:// endpoint is refused with 400, since a plaintext fetch lets an on-path attacker substitute the signing keys this platform trusts for the issuer.
audiencestringRequired. The aud claim a presented subject_token must carry. Must be globally unique in combination with issuer, across every tenant — use a distinct audience per environment/context sharing one IdP account.
contextIdstringRequired. Which app context an exchanged token targets; must already exist. A provisioning-capability credential may name only the context it's bound to (403 otherwise); a root key may name any of its contexts.
subClaimstringOptional. The claim carrying the subject identifier. Defaults to sub.
emailClaimstringOptional. The claim carrying the subject's email, used for first-login invite matching. Defaults to email.
userinfoUristringOptional. The IdP's OIDC userinfo endpoint. Presented tokens are access tokens, which don't carry email under OIDC unless the IdP was specifically configured to add it — if emailClaim misses on the presented token and userinfoUri is configured, Vectros falls back to calling this endpoint (presented token as bearer credential) and reads emailClaim from its JSON response. Omit to leave the fallback disabled. Must use the https:// scheme (0.44.0) — an http:// endpoint is refused with 400, since the request carries your presented token as a bearer credential and a plaintext fetch lets an on-path attacker both harvest it and control the response this platform trusts back. Unlike the trust-anchor fields, this is a plain safe field: fix a legacy http:// value with a single PUT {"userinfoUri": "https://…"} — that works even on a registration with bound users, no context migration or operator step needed.
selfSignupPoliciesarrayOptional. {signup_type, role_id} pairs — see Self-signup policies, below. Omit to leave self-signup disabled (the default).
capturedClaimsarray of stringOptional (0.43.0). Additional OIDC claim names — beyond emailClaim, which keeps its own dedicated field — to capture from this issuer's tokens on every successful token exchange and store as your tenant's golden identity-provider-asserted copy for the signed-in user. Not a fixed set: name whatever claims this IdP actually asserts (standard, e.g. name/phone_number/address, or your IdP's own custom claims). Each claim is read from the verified token first, falling back to userinfoUri (if configured) only for names still missing after that. Omit entirely to capture nothing beyond email (the default).
restrictedToDomainstringOptional. A domain your account has already verified. Scopes this registration's (issuer, audience) uniqueness to that domain, so the registration is active at once instead of pending_verification. It routes only tokens whose hd claim equals the domain, so it can only serve users of a domain you own — it proves control of the domain, not of the issuer itself. A PUT may change it or clear it (""); clearing it — making the registration unrestricted — is refused unless the registration has itself proven control of the unrestricted pair (it was registered without a domain and verified).

Fields (IssuerResponse, on read): every field above, plus:

FieldTypeNotes
statusstringactive | suspended | pending_verification. A registration without restrictedToDomain is pending_verification on create (0.45.0+) and accepts no tokens until verifyIssuer succeeds; it can't be changed by PUT (a request that names any other status is refused with 400). A suspended issuer stays registered but its tokens are no longer accepted for exchange, identically to an unregistered issuer — a token-exchange caller can't distinguish the two. A domain-scoped registration reads active on create; set an active one to suspended (and back) via updateIssuer. Suspending doesn't revoke any already-bound user's existing access directly — it stops future exchanges through this issuer from succeeding.
verificationClaimstringPresent only while pending_verification. The token claim your IdP must stamp verificationNonce into: https://vectros.ai/claims/issuer_challenge. Fixed by the platform, never chosen by the registrant.
verificationNoncestringPresent only while pending_verification. The one-time value that claim must carry. Not a credential — it proves nothing unless it arrives inside a token signed by the issuer's own keys.
verificationExpiresAtstringPresent only while pending_verification. ISO-8601 UTC; 7 days after registration.
createdAtstringISO-8601 UTC.
createdbooleanCreate-response only: true for a new registration, false for an idempotent echo. The HTTP status mirrors it — 201 vs 200. Absent on reads.

Proving control of an issuer — verifyIssuer (0.45.0+)

POST /v1/auth/issuers/{issuerId}/verify with { "token": "<jwt>" } activates a pending_verification registration. The call succeeds only if all of these hold:

  1. The registration is pending_verification and its challenge has not expired (7 days).
  2. The issuer's OpenID Connect discovery document (<issuer>/.well-known/openid-configuration) is reachable over https://, names exactly the registered issuer, and publishes a jwks_uri that is exactly the registered jwksUri. The comparison is exact — no case, slash or query normalization.
  3. The token verifies against that key set with the registration's issuer and audience (signature, expiry, iss, aud).
  4. The token carries the claim https://vectros.ai/claims/issuer_challenge as a string equal to the registration's verificationNonce.
  5. No other registration already holds the (issuer, audience) pair (including a registration that predates the pair claim and is active without one), the registration's app context is not being torn down, and no different registration is already active in that context.

The token is discarded after the check — never stored, logged, or used to sign anyone in. Any failure leaves the registration pending_verification and gives back any pair claim it took, and returns 400 with a message about your own registration or token; a registration that isn't yours, or doesn't exist, returns 404; a registration changed or removed while it was being verified returns 409 (this request activated nothing; read the registration back — another verification may already have activated it — otherwise retry). The request body is limited to 8 KB. Same gate as register: a root sk_* or the CLI bootstrap's provisioning capability.

Uniqueness and limits:

  • (issuer, audience) is globally unique, across every tenant, and is claimed when a registration becomes active. A registration without restrictedToDomain claims nothing while it is pending_verification — registering never reveals whether another tenant holds the pair. The claim is taken by verifyIssuer, so a pair another registration already holds is refused there with 400, regardless of issuerId. A registration scoped with restrictedToDomain claims its (issuer, audience, domain) triple when it is created.
  • A tenant may hold at most 50 issuer registrations at once (0.44.0). Registering past that cap is rejected with 400; deregister an unused issuer to free a slot. Bounds how many globally-unique (issuer, audience) pairs one tenant account can hold at a time — does not, by itself, prove anyone controls an issuer (see the note on jwksUri above and the tracked follow-up on issuer-control verification).
  • An issuerId can be permanently retired (0.44.0). If a Vectros operator ever force-releases a registration that had bound users (the platform's response to a squatted (issuer, audience) pair — see Delete, below, for the ordinary "has anyone bound" guard this bypasses), that exact issuerId can never be registered again under the same tenant — 400, naming the reason. This exists so a registration's bound users can never be silently re-pointed at a new trust anchor by whoever registers next under the same slug; register under a different issuerId instead.
  • The idempotent echo doesn't cross a context boundary. Re-registering with the same issuerId normally echoes the existing registration back (created: false). If a context-confined caller's issuerId happens to collide with a registration it doesn't own (a sibling context's), that collision now fails with 400 instead of echoing the other context's jwksUri/audience back to a caller that shouldn't see them.
  • One active issuer per app context. Registering a second issuer against a context that already has an active one is rejected with 400 — deregister the existing one first to replace it (only possible while it has no bound users — see Delete, below, for what to do once it does). The reverse — one issuer serving several contexts, each via its own registration row and a distinct audience — is supported and unaffected.
  • Delete is refused if the issuer has ever bound a user (self-signup or invite-then-exchange, through this issuer) — 409, unconditionally of the affected users' status (suspending them first does not lift the refusal). There is no self-service, same-context replacement for a bound registration: the context's one-active-issuer claim above is released only when THIS registration is deleted, so a second issuerId targeting the same contextId is refused for the identical reason, no matter what you name it. Real options: suspend this issuer (stops new exchanges immediately) and register a replacement under a different app context with a new (issuer, audience) pair (a distinct context — this context's existing users/roles/access profiles are not reachable from it). A Vectros operator can force-release a bound registration instead — but this permanently retires its issuerId and leaves its bound users unable to ever exchange through it again; their existing accounts are lost either way, so this buys nothing over the different-context option above beyond staying in the same app context. (The (issuer, audience) pair itself does become claimable again — just never under this issuerId, for this tenant, again.) An issuer that has never completed a successful exchange can always be deregistered.
  • Cross-context confinement (0.40.0). A credential confined to one app context (the bootstrap token) sees, registers into, and deregisters only issuers in its own context; naming or listing a sibling context's issuer behaves exactly as if it doesn't exist (404 on get/delete/update, silently absent from list). A root API key is unaffected. @vectros-ai/cli 0.16.0+ re-mints the bootstrap credential, pinned to a blueprint's own context, when its issuers: block targets a non-default context — see clients/blueprints.md.
  • An ordinary bootstrap token with no explicit context binding resolves to default specifically, not "every context". getIssuer/listIssuers (and updateIssuer/deleteIssuer) called with such a token only ever see issuers registered under the default app context — a 404 (get/update/delete) or an empty list result for any issuer registered under a different context is expected, not a bug, and re-minting the bootstrap credential pinned to that other context (as above) is what's needed to reach it.

Self-signup policies. Each selfSignupPolicies entry is {signup_type, role_id} — a caller-nameable slug (same grammar as a role id) paired with the role a brand-new, no-invite-required first-time exchange binds to. No entry may ever resolve to a role carrying elevated (provisioning, wildcard, or a tenant-management resource — keys, profiles, users, app-contexts, access-log) scope: checked, best-effort, when you register the policy (skipped if the role doesn't exist yet), and unconditionally re-checked, unskippable, at the moment a caller actually signs up. See explanation.md for why a caller-supplied signup_type carries no escalation risk on its own.

Claim capture (0.43.0). capturedClaims names any additional OIDC claims — beyond email — you want Vectros to capture from this issuer's tokens, re-captured on every successful exchange for that subject so it stays current with what the IdP asserts. There's deliberately no fixed vocabulary: name whatever your IdP actually provides. Captured values are never returned by any read API (there's no users:r-equivalent surface for them, by design) — the sanctioned way an app reads them is a future, separately-declared, read-only projection onto its own membership roster, not a direct read of the golden copy itself.

Scoping a registration to a verified domain — restrictedToDomain (0.44.0)

restrictedToDomain is an optional field on registerIssuer that ties a registration to a domain your account has already verified. It is the alternative to proving control of the issuer (above): a domain-scoped registration is active as soon as it is created, but it only ever serves that domain's users.

  • The domain must be verified for your account first. Verification is done by your account owner in the developer portal under Domains — the same place webhook domains are verified — not with the API credential that makes the register call. Naming a domain that isn't verified is refused with 400. The value is trimmed and compared in lowercase.
  • It claims its own triple at register. The registration claims (issuer, audience, domain), not the bare (issuer, audience) pair, so an unrelated registration on the same pair never blocks you: the same (issuer, audience) can be registered once per verified domain, and beside a registration that has no domain. Registering a triple another registration already holds is refused with 400 — use a distinct audience per environment or context sharing one IdP account. Deleting the registration frees its triple.
  • It routes only matching tokens. At token exchange, a domain-scoped registration is considered only when the presented token carries an hd claim equal to the domain (compared case-insensitively) — the OpenID Connect hosted-domain convention, which Google emits natively for Workspace accounts; other IdPs need a claims-mapping rule that emits a claim literally named hd. A token with no hd claim never matches it, and when both a domain-scoped and a domain-less registration match a token's audience, the domain-scoped one wins.
  • It proves control of the domain, not of the issuer. That is why it can be active at once, and why it cannot serve anyone outside the domain.

Changing it later is an updateIssuer — see Updating a registration, below.

Updating a registration — updateIssuer (0.41.0)

updateIssuer(issuerId, body) is a partial update over a deliberately narrow field set: a field omitted from body is left unchanged.

Updatable ("safe") fields — emailClaim, userinfoUri, status, selfSignupPolicies, capturedClaims. None of these carries retroactive blast radius on an already-bound user in the sense of a trust-anchor change; they only take effect at the next first-login/exchange (claim mapping, the userinfo fallback endpoint, self-signup eligibility, which additional claims get captured) or change whether the issuer's tokens are currently honored at all (status). capturedClaims is the exception worth calling out explicitly: each successful exchange re-captures against the list as CURRENTLY configured, so narrowing it — not just clearing it to [] — drops a no-longer-listed claim from the stored copy on that user's next exchange, not just stops adding to it. Pass an empty list to stop capturing anything beyond email; pass a narrower non-empty list to intentionally retire specific claims from future captures (and, on each affected user's next login, from what's already stored).

restrictedToDomain is updatable, with rules of its own (see Scoping a registration to a verified domain, above). It changes only which tokens are routed to the registration from then on — never an already-bound user's trust anchor or identity. A new non-blank value must be a domain your account has verified and whose (issuer, audience, domain) triple no other registration holds — 400 otherwise. An empty string ("") clears it, making the registration domain-less; that is refused with 400 unless the registration holds the unrestricted (issuer, audience) pair — that is, unless it was registered without a domain (and, from 0.45.0, verified). A registration created scoped to a domain never held that pair, so to move it to an unrestricted registration, register a new issuer without restrictedToDomain and verify it. A registration still pending_verification can't be re-scoped at all — verify it, or delete it and register again. Supplying the current value back is a no-op.

subClaim is updatable, but is NOT safe in the sense above — it IS identity-determining. It names which verified JWT claim POST /v1/auth/token/exchange reads as the federated subject before composing the internal identity key (issuerId + that claim's value). Changing it on an issuer that already has bound users would silently re-identify — or, under self-signup, orphan — every one of them. A subClaim change is therefore refused with 400 once any user has ever bound through the issuer, the same "has anyone bound" check deleteIssuer already applies (see NOT updatable, below, for a related but different immutability shape) — and remains freely tunable before an issuer's first real login. Supplying the current value back is a no-op, not a "change," even on a bound issuer.

NOT updatable — issuer, jwksUri, audience (the cryptographic trust anchor: together they decide which signing keys, and therefore which tokens, this platform accepts) and contextId (the routing pin an exchanged token targets). Supplying one of these four in the request body with a value that differs from the current registration is rejected with 400, naming the field — it is never silently ignored, so a client can't believe a trust-anchor change took effect when it didn't. Supplying the current value back (e.g. a client that reads the full object with getIssuer and PUTs it back unchanged) is a no-op, not an error. To actually rotate a trust anchor, delete and re-register the issuer under the same issuerId — which is itself refused with 409 while any user is bound through it, so a live issuer's trust can't be silently swapped out from under its users.

Confinement, gating, and the uniform-404 behavior on a sibling-context issuer are identical to getIssuer/deleteIssuer (see Cross-context confinement, above). A selfSignupPolicies update runs the same best-effort elevated-role check as registration — an entry may never target a role carrying provisioning or wildcard scope.

Token exchange — POST /v1/auth/token/exchange

RFC 8693 OAuth 2.0 token exchange. Unauthenticated — no Vectros credential of any kind; the subject_token itself is the credential. Uses the OAuth-standard error envelope ({"error": ..., "error_description": ...}, RFC 6749 §5.2), not this API's usual {"message": ...} shape — its client is generic OAuth tooling, not the Vectros SDK.

TokenExchangeRequest fields:

FieldTypeNotes
grant_typestringRequired. Must be exactly urn:ietf:params:oauth:grant-type:token-exchange.
subject_tokenstringRequired. The IdP-issued JWT to exchange.
subject_token_typestringRequired. urn:ietf:params:oauth:token-type:jwt or urn:ietf:params:oauth:token-type:id_token.
requested_token_typestringOptional. Accepted and ignored — this contract mints exactly one token shape.
invite_tokenstringOptional. The inv_* token from a sub-user invite email, for a first-time subject completing that invite. If present, it's the only binding path tried — a failed invite never falls through to self-signup.
signup_typestringOptional. Selects a selfSignupPolicies entry for a first-time subject with no invite token. Omit when the issuer declares exactly one policy (the unambiguous default); required to pick among several. Ignored entirely for a caller with an existing identity or a presented invite_token.
context_idstringOptional (0.40.0). Selects which app context to target when the matched issuer is registered against more than one, each via its own audience. Omit when your token's aud claim matches only one registered context — the common case, unaffected by this field's existence. A mismatch (naming a context this issuer isn't registered against) is refused identically to an unrecognized issuer.

TokenExchangeResponse fields: access_token (an st_* token), issued_token_type (always urn:ietf:params:oauth:token-type:access_token), token_type (always Bearer), expires_in (seconds — the same 3600s default/cap as every other st_*).

Errors specific to this endpoint:

CodeWhen
400Malformed body; missing subject_token; unparseable JWT; unsupported subject_token_type or grant_type; missing iss/aud claims; more than 8 candidate aud values on the token.
401subject_token failed verification — bad signature, expired, iss/aud mismatch, or the registered JWKS couldn't be fetched. Deliberately generic; these causes are not distinguished in the response.
403The token verified, but its subject resolves to no existing Vectros user, matches no pending invite, and either no self-signup policy is configured or signup_type matched none of the configured ones.
404The iss/aud pair names no registered issuer (or, with context_id set, no registration in that context) — indistinguishable from a genuinely unregistered issuer.

CLI / blueprint path

There is no standalone vectros issuer CLI command. Register an issuer through the SDK/API directly with a root key, or declare it in a blueprint's top-level issuers: block (see clients/blueprints.md) and let vectros bootstrap apply it — the loader calls registerIssuer under the bootstrap credential's provisioning capability, re-minting it pinned to the blueprint's own context when needed (0.40.0 confinement, @vectros-ai/cli 0.16.0+).


Credentials

Types

PrefixLifetimeScopeUse
sk_live_* / sk_test_*Permanent (revoke to retire)Wildcard within its tenantServer-to-server from your own backend.
ssk_live_* / ssk_test_*Permanent (revoke to retire)Bound to a profile; identity-bearingAgents, bots, long-running workers; audit attribution.
st_*1h default / 1h maxEmbedded in the tokenFront-end-safe per-session credentials.

The raw secret of an sk_*/ssk_* is shown once at creation and never re-readable; the platform stores only a hash.

Scoped key lifecycle — client.auth.*

MethodPurpose
createScopedKeyMint an ssk_* for a principal that already has a profile in the context; raw secret returned once. The profile must be active and the user must not be SUSPENDED — either is refused 409 (0.43.0+), for every credential including a root key. A PENDING user is fine. Minting bound to your own principal needs no special authorization. Minting bound to a different principal additionally requires the calling credential's role to carry delegate-mint (see Granted capabilities, above) — 403 without it. A root key is unaffected by the delegate-mint requirement and may mint against any principal.
getScopedKeyMetadata for one key (no secret).
revokeScopedKeySoft-delete; stops working within ~5 minutes (authorizer cache).
listScopedKeysThe tenant's keys; enveloped (single page — no cursor input on this endpoint).

createScopedKey fields: keyName, tenantId, contextId, userId (the bare principal user id), optional label. A re-issue of an existing (tenant, context, principal, keyName) tuple returns the key without the secret (the platform never re-discloses) — rotate to get a fresh secret.

CLI equivalents (vectros key)

vectros key issue  --principal <p> --context <c> [--name <n>] [--label <l>] [--format human|raw|env|json]
vectros key list   [--principal <p>] [--context <c>]
vectros key get    <keyId>
vectros key revoke <keyId>
vectros key rotate --principal <p> --context <c> [--name <n>] [--format …]

key rotate has no dedicated endpoint — it revokes the matching active key and mints a fresh one. There is no in-place rotation.

Token minting — client.auth.mintToken. Root sk_* only.

const { token, expiresAt } = await client.auth.mintToken({
  scope: { allowedActions: string[], dataScope?: {...}, identity?: {...} },
  userId?: string,                 // mint on behalf of a real user in the tenant
  contextId?: string,              // target an existing app context (defaults to `default`)
  expiresInSeconds?: number,       // default 3600, max 3600
});

Minting a scoped token is a root-key operation end to end — a scoped key or token (ssk_*/st_*) cannot call this endpoint at all, so there is no confined credential for contextId to let escape its own context.

FieldMeaning
scope.allowedActionsRequired; array of resource:ops[:qualifier] strings. Malformed entries → 400 at mint.
scope.dataScopeOptional; { userId: [...] } and/or { "scope:<namespace>": [...] } entries — the data the token may read/write.
scope.identityOptional; { userId: "..." } and/or { "scope:<namespace>": "..." } — ownership values stamped onto resources the token creates.
userIdOptional; must reference a real user in the caller's tenant (unknown id → 400 naming the field).
contextIdOptional; the app context the minted token operates in. Omit to inherit default. Must reference an existing context — an unrecognized value returns a uniform 404. Combine with an omitted userId/scope.identity to mint a token that creates ownerless (unattributed) resources in that context — e.g. the shared lineage base for a new record type (see basedOn in the data model reference), which every other owner's schema of that type then points back to.
expiresInSecondsOptional; default 3600 (1h), capped at 3600 (1h) — a larger value is rejected with a 400.

Returns { token: "st_…", expiresAt: <unix-seconds> }. Tokens cannot be revoked in flight — expiry is the only lever; mint short.

resolvedScope (0.43.0+). POST /v1/auth/token, POST /v1/auth/token/exchange and POST /v1/auth/token/assume each return an additional resolvedScope object alongside the minted / exchanged / re-minted token:

resolvedScope: { allowedActions: string[], identity: Record<string, string> }

It is the same plaintext data baked into the token's own (compressed) scope claim, resolved server-side — so you no longer decode the token to read what it actually carries. allowedActions is the union of allowed_actions across every clause the token holds (["*"] for a wildcard-scoped credential); identity is the token's own identity claim, keyed by the same public dimension names you send on mint (userId, scope:<namespace>). Purely additive — every existing response field is unchanged.

Identity-binding check — client.auth.ping

Returns the authenticated principal's identity: status, tenantId, environment, principalType (root_key | scoped_key | token), and principalKeyId. For a scoped_key, allowedActions is present, and dataScope.scopes reports its resolved scope:<namespace> bindings if any. For a token, tokenExpiresAt is present. An invalid credential is denied at the edge with 403.


Error codes

CodeWhen
400Malformed contextId / principalId / scope token / namespace name; an unrecognized ${{ ... }} placeholder spelling in a role's data_scope; a scope value with punctuation outside letters/digits/_/-, or not starting with a letter or digit; userId/ownership id not a real row in the tenant; a roleId that doesn't reference an existing role, on profile create/update/upsert, an invite's accessProfile, or a scoped-key mint bound to it; a scopes entry naming an entity's own namespace (a parent must cross namespaces); a scope:<namespace> value that doesn't resolve to a real entity in an entity-backed namespace — validated on update as well as create, naming the missing parent; identityOverrides.userId (sacred field); context delete without a matching confirm; more than two scopes/parent dimensions; a namespace register/update omitting specificityRank, or reusing one already taken in the account; a malformed invite email, a non-https or IP-literal acceptUrl, or a ttlSeconds outside 1h–30d; a plain update attempting to change email on a PENDING user; a status outside ACTIVE/SUSPENDED on user or entity update as well as create (0.43.0+ — the update paths previously stored the value verbatim), including the empty string; status: PENDING on PUT /v1/users/{id} (0.43.0+), which is server-managed and rejected even when it is the value the user already holds — omit status when updating a pending user's other fields.
403Invalid or unauthorized credential; a scoped action the credential's scope does not permit; a create or update whose resulting ownership placement no single scope clause of the credential permits — including attributing an item to another user without a clause constraining the userId dimension, and setting a scopes/identityOverrides value the scoped credential does not itself hold; changing, clearing, or deleting an identityOverrides value the caller doesn't itself hold; minting a scoped key bound to a different principal without the calling credential holding delegate-mint (minting bound to your own principal is unaffected); a granted_capabilities entry naming an unrecognized capability (denies the whole clause) or naming "*" (never a wildcard in this list); an invite's resolved accessProfile (its roleId's scopes, or its inline scopes) exceeding the caller's own scope; updating or deleting a namespace without a root key, or registering one without a root key or one of the CLI bootstrap's two independent namespace-provisioning capabilities; reading OR writing a namespace registration, or reading, creating, updating, or deleting an entity, with ?contextId= naming a context other than a context-confined caller's own — refused before the lookup ever runs, so a confined caller can never reach a sibling context's rows to get a 404 for them instead. Messages are uniform on purpose — they do not reveal which check failed.
404Get on a non-existent (or cross-tenant) context/identity/namespace; list/create under a non-existent parent context; resendInvite on an (contextId, email) pair with no outstanding invitation; an entity or namespace registration that genuinely doesn't exist under the context actually used for the lookup (naming a different context than a confined caller's own is a 403, not this — see above). createScopedKey — naming a userId that doesn't exist anywhere in the tenant and naming one that exists but has no access profile in the named context now return the same 404 (the access-profile message, which stays actionable: "create one via POST /v1/app-contexts/{contextId}/profiles first") — a context-confined credential can no longer tell the two apart. Cross-tenant probes collapse to 404.
409createScopedKey against an access profile that exists but is suspended, or for a user whose own status is SUSPENDED (0.43.0+, two distinct messages naming which). The remedy is to reactivate whichever the message names — the profile via PUT /v1/app-contexts/{contextId}/profiles/{principalId} with status: active (needs profiles:u), or the user via PUT /v1/users/{id} with status: ACTIVE — rather than to change your credential. Both refusals prevent ISSUANCE only: suspending the profile also stops credentials already bound to it within ~5 minutes, while suspending the user stops nothing that already exists (a user's status is never consulted when a credential authenticates), so revoke those keys explicitly if that is the containment you need. this is deliberately not a 403, since every 403 on that endpoint means your own credential is insufficient. Delete a role still referenced by a profile; delete a namespace, or flip it from entityBacked: true to false, while entities still exist in it; createInvite on an email already active/suspended in contextId, already invited under contextId without the caller holding users:r+users:u to resend it, or already resolving to a SUSPENDED member of a DIFFERENT app context in this SAME tenant (a deliberate lockout — never silently attached) — to a scoped credential (ssk_*/st_*) all these causes now return the same, undifferentiated 409. A root API key still receives the structured email_already_associated body for the cross-context-SUSPENDED cause (it can already list its own users directly, so the distinction discloses nothing new to it); if you branch on that error code, do so on a root key, or treat any 409 from this endpoint as "address unavailable within this tenant". An email resolving to an ACTIVE or PENDING member of a different app context in this SAME tenant grants/attaches access to contextId instead of 409ing — see Inviting a sub-user above — unless the caller lacks the scope that grant/attach itself needs (users:r for ACTIVE, users:r+users:u for PENDING), in which case it's a 409 too, same undifferentiated shape to a scoped credential. An email that already has an identity in your OTHER tenant is not one of these causes — createInvite succeeds and creates a second, independent membership. resendInvite has no 409 of its own — a non-matching (contextId, email) is a 404, not a collision.

A scoped credential lacking permission for a list/search filter required by its dataScope is rejected (strict scope) with a message naming the required dimension.


Notes & limits

What this surface does not do, stated plainly:

  • No identity PATCH. Users and identity entities update via full-replace PUT only — there is no partial-update on the identity plane.
  • No reparenting beyond scopes. An entity's parents are set through its scopes array (full replace on update); there is no separate move/reparent operation, and a parent edge always names another entity's <namespace>:<id> — orgs and clients (and any namespace) are referenced by id, never nested through a path.
  • At most two scopes entries per entity, and per record/document/folder. Same cap as the record ownership model always had; a value in an entity's own namespace is rejected as a "parent."
  • ?scope= is single-valued. One <namespace>:<value> filter per list/search call; pair it with ?userId= for a second dimension, but two namespace dimensions can't be filtered simultaneously in one call.
  • Single scope clause on mintToken. The token-mint endpoint serializes one (allowedActions, dataScope) clause per request. Multi-clause shapes are expressed through a role (referenced by a profile) or a blueprint, not minted directly as a single multi-clause token.
  • Single-clause access-profile create. The profile-create API accepts one inline clause; reach for a multi-clause role for compound shapes.
  • identityOverrides is scope:<namespace> only, capped at two. userId and the tenant id are sacred and rejected — a profile cannot forge another user's identity.
  • resource:* grants nothing at runtime. Author explicit c/r/u/d letters. Only the bare literal * grants everything (and that is the root-key shape).
  • s is an advanced op letter, beyond c/r/u/d. A fifth op, s, grants reveal of a type's sensitive fields (e.g. entities:s:org reveals sensitive org payload fields; customer:rs similarly for a record type). It is a per-resource capability, not part of the standard CRUD set — the blueprint/bootstrap scope gate accepts only c/r/u/d, so packs are authored with those; s is minted deliberately where sensitive-field reveal is intended.
  • The namespace registry is never scope-mintable. Registering, updating, or deleting a namespace always requires a root key — there is no namespaces:c (or similar) scope to grant a lesser credential that ability.
  • No in-place key rotation. Rotation is revoke-then-reissue; a key's raw secret is shown once and never re-readable.
  • Revocation is not instant. A revoked sk_*/ssk_* 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 offboarding with this window in mind.
  • listScopedKeys is a single page — the endpoint takes no cursor input; filter client-side by context/principal.
  • Cross-tenant existence is unobservable. Probing for another tenant's id returns the same uniform 404 as a non-existent id; scope-mismatch failures return the same generic shape.
  • This was a clean break, not an additive migration (0.35.0). The old /v1/orgs, /v1/clients, orgId/clientId fields, orgs:<verb>/clients:<verb> scopes, and the ${{ self.orgId }}/${{ self.clientId }} placeholders are gone outright — there is no compatibility shim and no deprecation window. Every removed form fails loudly (a compile error against a generated SDK, or a 400 naming its replacement), never silently.

Where to go next

  • explanation.md — the concepts: contexts as the isolation moat, the identity plane, namespaces, the scope model, and the three credential types.
  • how-to.md — runnable guides for every method above.
  • The generated API reference (rendered from the OpenAPI specification) — canonical, always-current request/response field shapes.
  • The blueprint walkthroughs — end-to-end builds that wire contexts, profiles, roles, and scoped keys together.