Identity & access — concepts
Identity in Vectros answers two questions on every request: who is acting, and what are they allowed to touch. The model spans the whole range from the company that builds on the platform down to a single end-user inside one of that company's apps — and it turns "who is this" into "what operations, on what data" through one small set of primitives. Every API call carries a credential; every credential resolves, at the edge, into a principal, a tenant, an app context, and a scope before the request reaches any application code.
Vectros began as the back-end for a HIPAA-grade clinical product — battle-tested in production against regulated health data before it was offered as an API. The data-isolation, audit, and scope-enforcement primitives described here were designed for that bar, and they apply unchanged to every workload built on the platform.
This page is the mental model. For runnable steps, see how-to.md; for the exhaustive surface — every method, field, limit, and error — see reference.md.
The isolation moat: app contexts
Start here, because everything else rests on it. An app context is a hard partition.
Every piece of data you store — records, documents, folders, and the schemas that shape
them — lives inside exactly one context, identified by a contextId. The context is not
a label you attach and could forget: it is a mandatory, fail-closed partition key
that is derived from the calling credential, never accepted from request input. A caller
cannot ask to read another context's data, because there is no request shape that lets
them name a context they aren't authenticated into. Lookups are same-context-only by
construction.
This is the multi-tenant isolation boundary. If you are building a platform that serves your own customers — a SaaS product, a per-clinic clinical tool, a per-team knowledge base — you give each customer their own context and their data can never bleed across. The guarantee is structural, not a per-handler runtime check that a later change could quietly regress.
A contextId is a stable, human-meaningful string matching ^[a-z][a-z0-9-]{2,30}$ —
for example customer-portal, clinic-intake, or internal-admin. You create contexts
through the context lifecycle endpoints (create / get / update / list / delete). On the
API, creating and deleting a context is a root-key (sk_*) operation — a scoped key or
token cannot provision or tear down a context, even with the wildcard * scope. Account
owners can also create and delete contexts from the dashboard or the CLI: those paths hold
the authority server-side behind the owner's sign-in, so no provisioning- or
teardown-capable credential ever reaches a browser or a keychain. Certain
context ids are reserved by the platform and cannot be created by a tenant — for
example vectros-admin, which backs the hosted admin surfaces, and default, the base
context auto-provisioned for every tenant.
A single company can run many contexts. A business that operates both a customer-facing portal and an internal admin tool can model them as two contexts and grant the same person different permissions in each, without one app's roles polluting the other's.
Deleting a context is deliberate and irreversible: it is a confirm-gated, asynchronous
cascade. The delete call must carry a confirm token equal to the contextId, or it
is rejected before anything is touched. With the token, the context flips to a purging
state immediately and drains all of its records, documents, folders, schemas, roles, and
profiles in the background, reaching deleted when the drain completes. This per-context
hard-delete is implemented and live. (It is a different mechanism from end-subject data
deletion — do not conflate the two.)
The identity plane: users and identity entities
Alongside the data, Vectros models the people and groups a request can be attributed
to and scoped against. These live on the identity plane, addressed through
client.identity.*, and are tenant-wide — they exist independently of any one context
and are referenced by data and by credentials rather than owned by a context.
There are two identity dimensions:
- Users represent individual people or machines acting in your tenant. A user is
either a
HUMANidentity — a real person who authenticates through a login system (your own auth, or a hosted one) — or aSERVICEidentity — a machine principal that does not log in interactively and instead acts under a credential. The two share one model and one code path; only thetypediffers. - Identity entities are the groupings you define — a clinic, a department, a team, a
customer relationship. Rather than a fixed pair of types, Vectros models every such
grouping as one generic entity type, addressed by namespace:
organdclientare two reserved namespace names, registered the same way as any other (see below), and anything else —team,project,department— is a namespace you register yourself before it can hold entities. Records, documents, and users can be tagged with one or more entity references — up to two, each in a different namespace — through thescopesfield. The reason identity entities are first-class platform objects — rather than free-form metadata — is that they participate directly in scope enforcement: a credential can be narrowed to a specific entity, and the narrowing is checked against the row's own ownership fields on every read, write, and search, not against a per-document access list.
Namespaces: anchoring your own ownership axes
A namespace is a name you can reference in a scopes entry (<namespace>:<value>).
org and client are reserved names, but not built-ins: your account must register both
once, the same one-time, root-key step as any other namespace (see
reference.md).
Anything else you use — region:us, team:eng — is a free-form string by default, validated
only for grammar. To turn a namespace into a real, enumerable entity type — one you can
create, list, look up by schema field, and that the platform existence-checks on every
reference — you register it: POST /v1/namespaces with
{ namespace: "team", entityBacked: true, specificityRank: 10 }. Once a namespace is
entityBacked, every scope:team value anywhere on the platform (record ownership,
dataScope, identityOverrides) must resolve to a real entity created via
/v1/entities/team, or the write is rejected — the same anchoring org and client get
once registered, available for any axis you define. Registering or changing a namespace is a
root-key-only operation; reading the registry (GET /v1/namespaces, which lists
whatever you've registered) is open to any credential. A namespace cannot be deleted, or
flipped back to free-form, while entities still exist in it.
A namespace registration has a placement, fixed at creation. By default it's
tenant-wide: its entities are shared across every app context, the way org/client
have always behaved. Passing ?contextId= at registration makes it context-owned
instead — the namespace, and every entity in it, belongs entirely to that one app context
and is invisible from any other; two different contexts can each register their own team
namespace with no collision. A registration can't be re-homed after the fact. A namespace
can also declare where its membership lives (which record type and field carry the
grant), letting a role's data_scope reference ${{ member.scope.<namespace> }} without
you hand-rolling the lookup yourself. Full field-level detail, including the migration step
for a pre-existing account, is in
reference.md.
specificityRank is a required, account-unique integer position in your account's
specificity order (registering or updating a namespace without one is rejected with a
400). It exists to break ties: when a basedOn schema lookup finds a caller holding two
scope dimensions at once (e.g. both org and team), the higher-ranked namespace's variant
wins. Pick an order once and leave headroom between values (10, 20, 30, …) so a later
namespace can slot in between without renumbering everything else.
An identity entity's own ownership is expressed the same way records' is: its scopes
field carries up to two parent edges, each <namespace>:<value>, and each in a
namespace different from the entity's own (a team entity's parents can be org:...
and one more, but never another team:... — a value in an entity's own namespace names a
peer, not a parent). Read back, an entity's scopes are its effective scopes: its own
reference (<namespace>:<its id>) followed by its parent edges — so you can quote a
GET's scopes value directly as an ownership reference elsewhere.
External IDs make identities idempotent
You arrive with users, customers, and accounts that already have IDs in your own systems
— emails, UUIDs, your billing provider's customer IDs, your auth provider's subject IDs.
Every identity carries an externalId that you supply, so you don't have to maintain
a separate mapping table. Create is idempotent by externalId (per user, and per
(namespace, externalId) for an entity): a second create with the same externalId
returns the existing identity (it does not duplicate, and it does not overwrite — the
second call's other fields are ignored on the idempotent return, unless you pass
?upsert=true on an entity create, which overwrites instead). The encoding that carries
an external ID into storage is permissive about characters — the variety of legitimate
formats is too wide to allow-list — so values containing separators like : or #
round-trip cleanly, and you never see the encoded form.
Each identity dimension supports the full lifecycle: create, get, update (a full-replace
PUT), delete, list (filterable by externalId, and entities additionally by
?scope=<namespace>:<value> — one parent per query — and by schema field), and a version
history read. There is no partial-update (PATCH) on the identity plane today — updates
replace the identity body.
The access model: scopes, profiles, and roles
A credential is only as powerful as its scope. Scope has two halves: which operations it permits, and which data it may touch.
Scope grammar
An allowed action is a string of the form resource:ops[:qualifier]. The operations
are the single letters c (create), r (read), u (update), d
(delete), freely combinable — records:r is read-only on records, records:cru is
create/read/update, records:r:intake_form narrows read to one record type. The data-plane
resources are records, schemas, search, documents, folders, inference, and
identity entities, granted as entities:ops:<namespace> (mirroring the
records:ops:<type> qualifier form) — ordinary namespaced tenant data, ownership-gated by
dataScope exactly like records; an automated bootstrap can mint this scope (below). What
it grants no authority over is the namespace registry itself (registering a new
namespace type), which stays control-plane regardless. Your tenant's own users are a
separate control-plane resource, granted as plain
users:ops for the four CRUD letters — a qualifier there is rejected, since users aren't
typed the way records or entities are. The fifth op letter, s, is the exception: if a
user carries a governing schema (schemaId), users:s:<schemaType> reveals that type's
sensitive fields, the same qualifier form as documents:s:<type>. A sixth, x, is the
permission to execute a stored script synchronously (scripts:x, narrowable to one script name
as scripts:x:<name>) — separate from scripts:c, the permission to push one. The developer
portal's scope editor also offers users:crud as a one-click shorthand for the four CRUD
operations; scripts:x is entered as an advanced (free-form) action.
Two grammar facts matter enough to lead with, because getting them wrong fails silently:
- Author explicit
resource:opforms. A coarse verb (a barereadorwrite) and the operations-wildcard formresource:*grant nothing at runtime. Always write the letter form:records:r,search:r,documents:cru. - The single literal
*is the only true wildcard — it grants everything, and it is the shape carried by root keys (below). Reserve it for that.
Data scope
The second half is dataScope — a map from an ownership dimension to the list of values
the credential may touch. userId is its own fixed dimension; every other dimension is a
namespace, keyed as scope:<namespace>:
{ "dataScope": { "scope:client": ["client_abc"] } }
A credential scoped to { "scope:client": ["client_abc"] } cannot touch rows whose
client scope entry is anything else. The same filter applies to list and search as a
server-side narrowing that sits below any caller-supplied filter and can never be
widened by it. Multiple values in one dimension's list match the union; multiple
dimensions intersect.
dataScope is strict by default: a scoped credential must include the matching filter
on each list and search call (?userId= for the userId dimension, ?scope=<namespace>:<value>
for a namespace dimension — one scope filter per query), or the request is rejected with
a message naming the required dimension. Strict-scope forces the caller's intent to be
explicit at the request boundary. To also reach tenant-level data — rows with no
value for the scoped dimension, i.e. shared data not assigned to any owner in that
dimension — the caller adds a JSON null to the value list:
{ "scope:client": ["client_abc", null] }. The null is an explicit, opt-in widening to
owner-less data; it is never implicit.
The same values also gate what a credential may place on a write, not just read. A
credential that carries a dataScope for a dimension but no fixed identity of its own must
state ownership when it creates an item: a create that omits scopes for that dimension is
refused rather than silently producing an account-wide (owner-less) item. To create an
account-wide item on purpose, include a JSON null in that dimension's dataScope value list
(the same explicit opt-in that widens reads also authorizes an owner-less write). Ownership is
never assigned implicitly.
A dimension a credential's dataScope says nothing about is not narrowed on reads — but
it also cannot be written into. If a credential needs to place data in a given
dimension, that dimension must be named in dataScope (explicitly, or via the "*"
default-dimension form described under roles below); a clause that never mentions a
dimension grants no way to stamp a value into it on create or update.
Access profiles and roles
An access profile is the per-principal, per-context permission row that the scope
model is built from. It binds a principal (a usr_<userId> or a key_<keyId>) to a
permission shape inside one context. A profile carries exactly one of: a set of inline
scopes, or a reference to a reusable role by roleId — the two are mutually
exclusive (switching from one to the other clears the unused half). Profiles can be
active or suspended, and suspending one denies access without deleting it.
A role is a context-scoped, identity-agnostic permission shape — define
engineering-member or support-readonly once, then bind many principals to it through
their profiles. This is the reusable-permission primitive: roles are multi-clause (each
clause is an (allowed_actions, dataScope) pair, and any clause that matches grants
access), which lets one role express a compound shape like "full control over the records I
own, plus read access to the rest of the team's." A role's data_scope clauses accept a
small placeholder grammar, not just literal values:
${{ self.userId }}and${{ self.scope.<namespace> }}(e.g.${{ self.scope.org }}) resolve to the acting principal's own value at runtime.${{ any }}claims a whole ownership dimension without enumerating its values — it matches any value present in that dimension (pair it with anullentry to also cover owner-less rows).${{ under.self.userId }}and${{ under.self.scope.<namespace> }}match values whose immediate parent is the credential's own — one level only, not a full ancestor walk — so a credential confined to an org can work with the clients under it without naming each one at mint time.- A dimension key of
"*"states a default rule for every dimension the clause doesn't name explicitly; a dimension named directly always takes precedence.
A null data-scope sentinel additively grants tenant-level (owner-less) records. Any other
${{ ... }} spelling is rejected at authoring time with a 400, never silently stored as
an inert literal.
A profile can be cleared safely: deleting a role that a profile still references is blocked
(the platform refuses to orphan a profile's binding). And a profile's identityOverrides
— the ownership values stamped onto what the principal touches — accept any
scope:<namespace> key (reserved or one you registered, up to two total); the tenant
identifier and userId are sacred and rejected, so a profile can never forge a different
user's identity. The override value is authorized like scopes — even when the request
body carries only identityOverrides: a scoped credential may override only to a value it
itself holds (a 403 otherwise), and a root key's override must reference an entity that
exists (a 400 naming the value otherwise). The check runs on the value being replaced,
not only the value being set: changing or clearing an identityOverrides entry — including
wiping it via an empty map — is refused with a 403 unless the caller also holds the value it
would overwrite, and deleting a profile outright is refused the same way when its
identityOverrides holds a value the caller doesn't hold. This is unaffected when you give
your own identity to a profile that previously had none, when you edit or delete a profile
whose identity is already yours, or when you omit identityOverrides entirely — and root
API keys are exempt from this check throughout.
Profiles are addressable across contexts when you're looking up your own principal, or
when the calling credential holds the context-directory-read capability (see
Granted capabilities): a single lookup can then answer
"every context this principal has access to," useful for an admin view of one person's reach
across all the apps you've provisioned. Looking up a different principal without that
capability sees only that principal's profile in the credential's own context, at most one
result — see reference.md for the exact rule.
Note on minting profiles via the API. The profile-create endpoint accepts one scope clause per request today. Multi-clause shapes are expressed through roles (which a profile then references) or through blueprints. See reference.md for the precise limit.
Inviting a sub-user by email
A user doesn't have to be created directly — you can invite one into a specific app context
instead, and let them complete their own onboarding. Inviting creates a PENDING user and
binds an access profile to it in the same call: the invite carries exactly one of roleId or
inline scopes, the same mutually-exclusive choice a standalone profile makes. Vectros emails
the invitee an accept link, or — with sendEmail: false — hands you the raw token and a
ready-made link so you can deliver it through your own provider instead. The invitee finishes
the flow themselves, typically after authenticating with your own login system: an update to
their user record carrying the invite token, their externalSubject in your auth system, and an
attestation that you verified their email flips them from PENDING to ACTIVE.
externalSubjecthere doesn't grant sign-in — to anywhere — and it doesn't prevent a future duplicate either. CallingPUTyourself (server-to-server, with your own auth system's identifier) activates the user and records that identifier exactly as sent, with no normalization, but it does not let that identity sign in to any Vectros surface — not the Vectros dashboards, and not via token exchange either — and it does NOT automatically match a later token-exchange sign-in for the same real-world identity (token exchange computes its own value from the verified credential; it never reads this field, so a caller would have to independently reproduce that internal computation for the two to line up, which nothing documents as a stable contract). If the invitee needs to sign in through token exchange themselves, have them redeem the invite token directly at that endpoint with their own credential — Vectros verifies it there and completes the same activation, safely. Call thePUTyourself only if you're recording informational bookkeeping for your own backend — there's no other reason to setexternalSubject.
Inviting is idempotent on the pair (context, email) — inviting the same address into the
same context again rotates the token and resends the invitation rather than creating a
duplicate, and a dedicated resend call does the same without touching the bound permissions. A
409 means the email already belongs to an active or suspended member of THAT SAME context, or
already has a pending invitation there you don't hold the resend scope for.
Inviting an email that already belongs to a member of a different context in your same tenant usually grants access to the new context instead of 409ing, attaching it to the existing identity rather than minting a duplicate — provided your credential holds the scope that grant/attach itself needs (see reference.md for the exact dispatch and scopes; without it, this 409s too, same as any other collision here). One exception mints a genuinely independent second row instead of attaching: an ACTIVE member whose credential can't actually authenticate through the new context's own registered issuer gets a normal, independent invitation there — the identity ends up with two separate rows in the same tenant rather than one, each usable only where it was created. A suspended identity is a hard exception too, regardless of scope: that's a deliberate lockout, never silently undone by an unrelated invite. An email that already has an identity in an entirely different tenant on your account is never a collision either — a live and a test environment (separate tenants) can each independently invite the same address.
Five behaviors worth knowing:
- You cannot invite someone into more than you yourself hold. Whether the invite's
accessProfileis an inlinescopeslist or aroleId, the resolved scope is checked against your own credential's scope before the invite is created — inviting into a role or scope set broader than what you hold is rejected rather than minting a member who out-scopes their inviter. - Invitations always land in your live tenant, regardless of which environment your credential authenticates against — there's no such thing as a test-tenant invitation.
emailis frozen while an invitation is outstanding. You cannot redirect a pending invite to a different address by editing the user directly — delete the pending user and re-invite instead. The address is editable again once the invitation is accepted or the user is removed.- An invite's
roleIdmust name a role that already exists in the context, exactly like a standalone access profile — inviting someone into a role you haven't created, or mistyped, is rejected outright rather than creating a member who could never authenticate. - Resending needs more than creating. Creating a new invitation takes the create action on users; resending it — through the dedicated resend call, or by re-inviting the same address — additionally requires the read and update actions on users. Without both, the attempt is refused rather than silently rotating someone else's pending invitation.
Credentials: root keys, scoped keys, and short-lived tokens
Three credential types cover three lifecycles. All three are presented the same way on the
wire — an Authorization: Bearer … header — and all three resolve through the same edge
authorizer, which classifies the credential by prefix, validates it, and injects the
resolved tenant, principal, context, and scope into the request before any application code
runs.
sk_live_*/sk_test_*— root keys. One live and one test key, each with wildcard scope and full authority within its tenant. Intended for server-to-server calls from your own backend. The raw secret is shown once at creation and never again — the platform stores only a hash. The two prefixes track the two environments (a live tenant for production, a test tenant for development), with fully isolated data and indexes between them.ssk_live_*/ssk_test_*— scoped keys. Permanent, least-privilege keys that are identity-bearing on the data plane: the key is bound to a principal that already has an access profile in a context, the bound principal is the data-ownership identity, and it can never exceed that profile. Minting one also requires the caller to hold the identity the bound profile carries — a scoped credential minting on another principal's behalf is refused with a 403 if it doesn't hold that identity itself; a root key is exempt. Scoped keys are the right shape for a local agent, a per-team-member bot, or any long-running worker that has no way to refresh a token and where audit attribution ("Alice's agent did this") matters. The raw secret is shown once; there is no in-place rotation — rotate by revoking and re-issuing.st_*— short-lived tokens. Minted on demand, scope embedded in the token itself, 1-hour lifetime — which is both the default and the maximum. These power the front-end-safe pattern (below). They cannot be revoked in flight — expiry is the lever, so mint with a short lifetime to bound blast radius.
The front-end-safe minting pattern
Browser code cannot safely hold a root key. The front-end-safe pattern keeps your backend in the loop only for minting:
- Your backend holds the long-lived
sk_*(or anssk_*). - On login, it mints an
st_*narrowed to that one user's scope — typicallydataScope: { userId: ["<that user's id>"] }plus the session's allowed actions — with a short lifetime. - It hands the
st_*to the browser. The browser calls Vectros directly.
The root key never crosses the network boundary, the per-session token cannot widen its own scope, and a compromised browser exposes one user for at most the token's lifetime. Every minted token also records which key minted it, so audit attribution is never ambiguous — even for tokens that carry no user (a public search page, a service-to-service call).
The blueprint scope gate
The CLI and blueprint bootstrap flow — which provisions a context, principal, profile,
and a narrow ssk_* from a declarative app definition — runs behind a hard scope gate.
The gate mints keys for the data plane only: records, schemas, search,
documents, folders, inference, and entities (ordinary namespaced identity-entity
CRUD, ownership-gated like records — it grants no authority over the namespace registry
itself). Any control-plane scope (keys, profiles, app-contexts, users, billing, admin,
namespaces) and the literal wildcard are hard-rejected — the bootstrap mints nothing
and exits non-zero, with no override flag. The trust boundary is the tool, not the app
definition it reads: a control-plane scoped key is something a human creates deliberately
in the developer portal, never something an automated bootstrap can be talked into minting.
A blueprint's identities: block (see
clients/blueprints.md) is a separate mechanism for a different
job — it declares fixed seed principals for the loader to resolve, once, via the ordinary
identity API under the bridge token's own authority at apply time (so ${{ identities.* }}
tokens can be substituted into seed records) — distinct from a minted key holding runtime
entities scope, which is what an end user's own app-driven entity creation (e.g. a user
creating their own org) needs instead.
Trusted issuer federation: BYO-IdP token exchange
A fourth way to get an st_* token onto a caller exists alongside the three credential types
above: RFC 8693 OAuth token exchange. If you already run your own identity
provider — Auth0, Okta, Cognito, anything that issues a JWT and publishes a JWKS — you can register
that IdP as a trusted issuer, and let your own users trade the IdP's token for a
Vectros-scoped one directly, with no Vectros credential and no backend of yours in the request
path.
This is a materially different shape from the front-end-safe pattern above: there, your
backend holds an sk_*/ssk_* and mints on your users' behalf, so your backend is a party to
every mint. With token exchange, Vectros verifies the IdP's signature itself (against the JWKS
you registered) and resolves the caller's scope from an access profile already bound to that
identity — your backend is never called at request time. This is the shape the
@vectros-ai/react package's Auth0 provider builds on (exchangeToken/mintPartnerApiToken
wire an SPA directly to the exchange endpoint).
The flow
- Register the issuer:
POST /v1/auth/issuersrecords the IdP'sissuer(itsissclaim value),jwksUri(where to fetch its signing keys), theaudience(aud) your tokens must carry, and which appcontextIdan exchanged token targets. This is a provisioning-time act — a root key or the CLI bootstrap'sprovisioning:ccapability, never an ordinary grantable scope — because it attaches a whole external trust relationship to a context, unlike an ordinary CRUD write. - Prove you control the issuer. A registration accepts no tokens until this step: naming an
issuer URL and audience alone proves nothing, so a fresh registration starts
pending_verificationand stays that way until you complete a one-time verification call against a claim only an admin of that IdP can set. The one exception is a registration scoped to a domain you've already verified for your account (restrictedToDomain) — that proves control a different way and is active immediately, but only ever serves that domain's users. See identity-access/how-to.md for the walkthrough. - Your IdP issues a JWT to one of your end users, however your IdP normally does that (its own login UI, embedded auth, whatever you've already built).
- The caller exchanges it:
POST /v1/auth/token/exchange, unauthenticated (no Vectros credential at all), trades that JWT for a Vectrosst_*token. Vectros looks up the registration by the token'siss/aud, verifies the signature against the registered JWKS, and — once the subject resolves to an existing Vectros identity — mints a token scoped to that identity's own access profile in the target context. An issuer that is stillpending_verificationhas nothing to resolve against, so exchange fails the same way it would for an issuer that was never registered. - The resulting token drives ordinary API calls, exactly like any other
st_*— same 1-hour cap, same non-revocability, same "expiry is the lever" posture as the front-end-safe pattern.
Binding a first-time subject
The first time a given subject presents a token, there's no Vectros identity to resolve yet, so the exchange fails unless a binding path applies:
invite_token— the sameinv_*token an ordinary invite produces. A user with a pending invite completes it through the exchange call instead of throughupdateUserdirectly — the same underlying accept mechanism, reached from a different door.signup_type— opt-in self-signup. A registration can declareselfSignupPolicies, a list of{signup_type, role_id}pairs; a first-time caller naming one (or omitting it, when exactly one exists) gets a brand-new active user, bound to that role — no invite and no admin step at all.
If invite_token is present, that's the only path tried — a bad or expired invite never falls
through to self-signup. Neither field is required for a subject with an existing identity; the
exchange just resolves them.
Self-signup's real trust boundary is your IdP's audience, not signup_type
signup_type is a plain, caller-supplied string — it doesn't need to come from the IdP's own
claims. That's safe only because of what a selfSignupPolicies entry means: every entry is
something you already decided any caller who can present a token from this issuer, for this
audience, may have. There's no privilege differential between entries for a caller to escalate
into by naming a different one, and the platform enforces that structurally — no entry may ever
resolve to a role carrying elevated (provisioning, wildcard, or tenant-management) scope,
re-checked at the moment of every signup, not just when you configure the policy. What
self-signup is not is a way to vet who reaches it: "any caller" means literally anyone who can
authenticate against your IdP for that audience. Restrict that at your IdP, not here, if
self-signup needs to be narrower than "anyone who can log into this identity provider."
Cross-context confinement (0.40.0)
An issuer registration targets exactly one app context, and — as of 0.40.0 — every operation on
this surface is confined to match: a credential authorized only via the bootstrap's provisioning
capability may register, read, or delete an issuer only in the context it's bound to, and a
context has exactly one active issuer at a time. See
reference.md for the full confinement rules, and
the SDK's own CHANGELOG.md (shipped in the @vectros-ai/sdk package) for the exact before/after.
A root API key is unaffected throughout.
Revocation and propagation
Revoking a key (sk_* or ssk_*) is not instantaneous — caches at the edge mean a revoked
key keeps working until the edge authorizer cache expires, up to about five minutes.
st_* tokens cannot be revoked at all; they expire on
their lifetime. Plan rotations and offboarding with that propagation window in mind. See
reference.md for exact figures.
Where to go next
- how-to.md — runnable guides: create identity entities and a user, register a custom namespace, mint a least-privilege scoped key, operate inside a context, wire the front-end-safe pattern.
- reference.md — exhaustive identity/access surface: every method, field, scope-grammar rule, limit, and error.
- ../search-rag/explanation.md — how scope and
dataScopenarrow search results at the data layer. - ../data-model/explanation.md — how records and documents pick up ownership and live inside a context.
- ../operations-trust/explanation.md — how isolation, least-privilege, and audit history compose into the platform's compliance posture.
- The generated API reference (rendered from the OpenAPI specification) — the canonical, always-current request and response shapes.