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 namespaces that are always registered — they carry no dedicated endpoints or fields of their own; you 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, also requires users:r and users:u (the resend path), else 409.
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.
updateUserAlso the accept path: a PUT carrying status: 'ACTIVE', inviteToken, externalSubject, and emailVerifiedAttestation: true moves a PENDING user to ACTIVE.

CreateInviteRequest fields:

FieldTypeNotes
emailstringRequired. Rejected with 409 if it already belongs to an active/suspended member of contextId, or already has an identity elsewhere in the account — an email belongs to at most one tenant per account (a live and a test environment can't share one).
contextIdstringRequired. Must reference an existing app context in your live tenant — invitations always land in the live tenant, regardless of which environment the calling credential authenticates against; 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 falseinviteToken and acceptLink. Treat inviteToken as a credential: holding it is sufficient to accept the invitation.

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 (PENDINGACTIVE), 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. Suspended entities are retained but blocked from new operations.
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.
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 always-present, immutable built-ins.

MethodPurpose
registerNamespace(body)Register a namespace. Requires specificityRank. Root sk_* only.
getNamespace({ namespace })Fetch one. Open to any credential.
listNamespacesList; always includes org/client; enveloped. Open to any credential.
updateNamespace({ namespace, body })Update entityBacked / defaultSchemaId / specificityRank. 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. org/client, and the reserved names below, cannot be registered.
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.
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.
reservedboolean(response only) true for the org/client built-ins.
createdAtstring(response only) Absent for the built-ins (they are platform constants, not stored rows).

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 (plus org/client, the built-ins).

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 (built in) 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. Root sk_* only.
getAppContextFetch one by contextId.
updateAppContextUpdate name / description (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.

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 confirm400, 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 partner-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, or a control-plane resource: entities (below, qualified by namespace) or 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). users takes no qualifier on c/r/u/d — a qualifier there is rejected — but does on the fifth op letter, s (below).
  • ops — any combination of the letters c create, r read, u update, d delete. E.g. records:r, records:cru, documents:crud.
  • 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.

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.

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

Any other resource — the control plane keys, profiles, app-contexts, users, billing, admin, entities (creating or managing identity entities, including org/client), 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.
roleIdReference to a reusable role. XOR with scopes. 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.)
statusactive or suspended. Suspending denies access without deletion.
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).

XOR enforcement. A profile carries exactly one of inline scopes or a roleId. Updating to set one clears the other (empty-string / empty-array sentinels). On create, the unset half is absent or an empty sentinel.

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

Cross-context lookup. listProfilesForPrincipal({ principalId }) returns every profile for that principal across all contexts. A principal with no 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> | --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>

--role and --actions are mutually exclusive (exactly one). --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-clauseany clause that matches grants access.

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.

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.


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 / 24h 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 caller must also hold the identity the bound profile carries — 403 if binding to a profile whose identity the caller doesn't hold (a root key is exempt).
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 86400
});

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 86400 (24h).

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

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.
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 profile whose identity the caller doesn't hold; an invite's resolved accessProfile (its roleId's scopes, or its inline scopes) exceeding the caller's own scope; registering, updating, or deleting a namespace without a root key. 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. Cross-tenant probes collapse to 404.
409Delete 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 the context, already invited without the caller holding users:r+users:u to resend it, or already an identity elsewhere in the account (the last case alone returns a structured email_already_associated body — the first two are deliberately indistinguishable). 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.