The Subject

ctx.subject — what's on it, the anonymous fallback, and how to pattern-match on the type discriminator.

Every server-side executor receives ctx.subject — the typed identity of whoever is calling. It's always present (anonymous requests get an explicit anonymous subject), so app code never has to null-check before reading basic fields.

Shape

type Subject =
  | { type: 'user';           id: string; tenantId: string;        scopes?: ReadonlyArray<string>; metadata?: Record<string, unknown> }
  | { type: 'apiKey';         id: string; tenantId: string;        scopes?: ReadonlyArray<string>; metadata?: Record<string, unknown> }
  | { type: 'serviceAccount'; id: string; tenantId: string; auditActorId?: string | null; scopes?: ReadonlyArray<string>; metadata?: Record<string, unknown> }
  | { type: 'system';         id: string; tenantId: null;          scopes?: ReadonlyArray<string>; metadata?: Record<string, unknown> }
  | { type: 'anonymous';      id: null;   tenantId: string | null }

scopes is the permission currency — the auth strategy that resolved the subject puts the computed scopes here, and @voltro/plugin-rbac compiles a caller's roles onto the same set. There is no roles field on the wire; roles are an authoring convenience that resolve to scopes.

The type discriminator narrows downstream fields:

if (ctx.subject.type === 'user') {
  // ctx.subject.id, tenantId are strings
}
if (ctx.subject.type === 'anonymous') {
  // ctx.subject.tenantId is string | null; id is null
}

Audit identity after tenant narrowing

tenantScopedSubject preserves the application audit actor separately from the machine identity as auditActorId. System work narrowed to a tenant carries explicit null; it must not stamp a fictitious system row into the app's actors foreign key. Users retain their actor ID, and API keys retain their existing acting-user attribution. subjectAuditActorId is the shared resolver used by the runtime's audit writer. The field survives SubjectIdentity serialization but grants no permissions. Authentication code establishes it; do not copy an untrusted request field into it. Ordinary service accounts without an explicit attribution use their own ID. This does not by itself fix Cadence's still-open scheduler tenant placement.

Tenant narrowing preserves only scopes already held by the caller. Missing scopes mean an empty set, not admin:full; a persisted workflow identity must pass through authority resolution before it can regain permissions. The helper does not authorize switching tenants: the calling boundary must check that too.

App metadata — subjectFromUser(user, { metadata })

metadata is the free-form slot the framework itself never reads. It is where a provider credential captured at login belongs — a plugin's credentialsResolver reads it back per request (@voltro/plugin-atlassian looks for subject.metadata.jiraToken, say), so nothing has to be re-fetched or stored server-side per call.

Pass it when you build the Subject:

import { subjectFromUser } from '@voltro/plugin-auth'

const subject = subjectFromUser(user, {
  memberships,                              // → metadata.memberships
  metadata: { jiraToken: atlassianPat },    // → metadata.jiraToken
})

The two merge — neither clobbers the other. When a memberships key appears in both, the dedicated memberships option wins: it is the typed input, and it is the one projected into the { tenantId, role } shape subjectMemberships() and the tenant switcher read. Without the option, a memberships key inside metadata passes through unchanged. A Subject built with neither option has no metadata key at all.

Keys set this way survive the login paths: the sign-in / sign-up / magic-link / passkey handlers merge sessionId onto the existing slot, and the password strategy merges provider — they add, they don't replace. The one exception is naming a key sessionId or provider yourself; those two are overwritten by design.

It survives a tenant switch too. A switch rebuilds the Subject from the user record, so the built-in /switch-tenant route passes the caller's current subject.metadata through to handleSwitchTenant — a credential parked here keeps working after the user changes tenant. Calling handleSwitchTenant yourself? Pass metadata or the credential is dropped, and the symptom is unpleasant to diagnose: the user stays signed in while every call to the provider starts failing. memberships is deliberately not carried — it is re-derived for the target tenant, and a carried copy would report a role the user does not hold there.

Two things not to put here. Anything the caller could benefit from changing — the slot rides the signed session cookie, so it is tamper-evident, but it is also stale by design: it reflects the moment of sign-in, not the current database. And anything large — it is re-serialised into every session cookie.

Resolution

AuthMiddleware resolves a Subject on every request by running the strategy chain — composeAuthStrategies evaluates each strategy in order, first matched wins, first failed rejects with Unauthenticated (unless an app-owned fallback explicitly decides otherwise). A typical chain resolves, in order:

  1. Built-in password cookie — voltroPasswordStrategy reads voltro:session and HMAC-verifies it. → type: 'user'.
  2. API key — apiKeyStrategy from @voltro/protocol/apikey maps Authorization: Bearer <prefix>_<token> → a scoped apiKey subject. This ships today; add it to the auth.strategies chain in app.config.ts. → type: 'apiKey'.
  3. In-process system calls — code running under runAsSystem (cron, workflows, backfills) carries a system subject; it's never produced from an inbound request. → type: 'system'.
  4. Anonymous — nothing matched. What the caller is now is the app's decision, auth.anonymous in app.config.ts: refused, tenant-less, the x-tenant header's tenant, or one fixed tenant (below). → type: 'anonymous'.

Only under 'header' does an anonymous request with x-tenant: <id> carry that tenant — and the header is unauthenticated: never trust it for writes, and never make a member-only procedure openAccess on such an app.

Common patterns

Require sign-in

import { Schema } from 'effect'

class Unauthorised extends Schema.TaggedError<Unauthorised>()('Unauthorised', {}) {}

export default async (input, ctx) => {
  if (ctx.subject.type !== 'user') throw new Unauthorised()
  // ctx.subject.id is narrowed to string
}

Require sign-in + specific tenant

import { assertOwnTenant } from '@voltro/plugin-multitenancy'

export default async (input, ctx) => {
  if (ctx.subject.type !== 'user') throw new Unauthorised()
  assertOwnTenant(input.tenantId, ctx.subject)
  // …
}

Allow system OR user

const canTrigger = ctx.subject.type === 'user' || ctx.subject.type === 'system'
if (!canTrigger) throw new Unauthorised({})

RBAC

@voltro/plugin-rbac builds on the subject's scopes. Roles compile to scopes — the plugin resolves the caller's roles, flattens them onto the resolved scope set, and the permission() handler guard checks it in one line:

import { permission } from '@voltro/plugin-rbac'

yield* permission(ctx, 'admin:full')   // Effect<void, ScopeError>

See the RBAC plugin for the full model.

Anonymous callers — the app's decision

A request that matches no strategy still resolves to a Subject, and that Subject's tenantId decides what every tenant()-scoped table and every openAccess procedure answers. Left to the request, the x-tenant header would choose it: any client could read any tenant's public-per-tenant rows, and a procedure that is openAccess for a tenant's members would answer for whichever tenant the header named. So app.config.ts decides once, and voltro serve refuses to boot until it has:

// app.config.ts
export default {
  type: 'api' as const, name: 'api',
  auth: { anonymous: 'no-tenant' },
}
auth.anonymous An unauthenticated caller is… Use it for
'refuse' refused with Unauthenticated an api with no public surface at all
'no-tenant' anonymous with the empty tenant '': every tenant-scoped read is empty (fails closed); tables without tenant() and procedures that need no tenant answer token-shaped public surfaces — an invite preview, a signed link, a sign-out
'header' anonymous in the tenant the request's x-tenant header names; a request without the header is refused public-per-tenant data — a marketing site per tenant, public listings by slug. Every openAccess procedure and every table without tenant() is then public for a client-chosen tenant, so nothing member-only may be openAccess on such an app
{ tenantId: 'public' } anonymous in that one tenant, chosen by the server; the header is not consulted a public surface that belongs to one tenant

Never a null tenant: to the store a null tenant is a system subject and reads unscoped, across every tenant. 'no-tenant' carries '' for that reason.

voltro dev runs an app that has not decided on the development convenience — the x-tenant header's tenant, else the dev tenant (TENANT, else acme) — and says so at boot. voltro serve refuses instead, naming the four choices. Once decided, both boot paths run the same policy.

The same field goes to composeAuthStrategies(strategies, { anonymous }) when you compose the chain yourself (its default is 'no-tenant') and to makeTestApp({ anonymous }) (its default is the dev convenience). An app-owned fallback replaces the policy entirely: that function owns the anonymous subject and the rejected-credential decision.

A rejected credential is never anonymous. "No credential" and "a bad credential" are different callers: the first may read what the public may read, the second is told to sign in again. When a strategy rejects the credential it was handed (an expired session, a malformed JWT, a revoked key), the resolver throws Unauthenticated with credential rejected: <reason> — under every auth.anonymous policy alike. It used to fall into the anonymous branch marked credentialRejected, which every surface then had to remember to check. Only an app-owned fallback still receives that mark, because that function owns the decision.

Tenant selection — switching tenants for any credential

A subject that belongs to several tenants acts in ONE at a time. The auth plugin's POST /auth/switch-tenant re-issues its session cookie with the new active tenant — which does nothing for a subject whose credential is minted elsewhere: an external IdP's JWT names the tenant it names, an API key names the tenant it was issued for. Tenant selection is the same switch as a primitive on the Subject, independent of the strategy that produced it: the selection is stored beside the subject (_voltro_tenant_selections, a framework table), verified against the app's memberships on every request it applies to, and applied after the strategy matched and before auth.resolveScopes — so a role resolver sees the subject in the tenant it will act in.

Configure the ONE source of memberships in app.config.ts; the auth plugin's is authMemberships(userStore), an IdP project supplies its own:

// app.config.ts
import { authMemberships } from '@voltro/plugin-auth'

export default {
  type: 'api' as const, name: 'api',
  auth: {
    // Read only when a stored selection names a tenant other than the credential's.
    memberships: authMemberships(userStore),
    // or, from your own directory:
    // memberships: async (subject, { store }) => listTenantsOf(store, subject.id),
  },
}

Switching is three lines in a mutation — the same rule the resolver applies, then one row:

// tenants.switch.mutation.server.ts
import { selectTenant } from '@voltro/runtime'

export default async (input: { tenantId: string }, ctx) => {
  const memberships = await listTenantsOf(ctx.store, ctx.subject.id)
  // Throws `TenantSelectionRefused` (`not-a-member` | `subject-has-no-tenant`) — nothing is written then.
  return selectTenant(ctx.store, ctx.subject, input.tenantId, memberships)
}

What holds, on both boot paths:

  • The next request is in the selected tenant — whatever credential it presents. There is no staleness window: the selection is one point read per request, and the memberships are consulted only when a selection exists that differs from the credential's tenant.
  • Revoking the membership revokes the selection. A stored tenant the source no longer lists is ignored and deleted; the subject proceeds in the tenant its credential names.
  • A selection that cannot be read fails closed with Unauthenticated (tenant selection unavailable: …) — serving the credential's tenant instead would be the wrong-tenant answer with no symptom.
  • The auth plugin's /auth/switch-tenant writes the same row beside its cookie, so a password user and an IdP user switch through one rule (selectSubjectTenant in @voltro/protocol) and one table.
  • anonymous and system subjects have nothing to select and are never looked up; memberships and the selection store come as a pair, and the resolver refuses to compose with only one of them.

Custom resolvers

For exotic auth setups (mTLS, custom JWTs from an upstream gateway) write a custom AuthStrategy and add it to the auth.strategies chain in app.config.ts — there is no runtime.resolveSubject field:

// app.config.ts
import { anonymousSubject } from '@voltro/protocol'

export default {
  type: 'api' as const,
  name: 'myApi',
  auth: {
    strategies: [
      {
        id: 'mtls',
        resolve: (input) => {
          const cn = input.headers['x-client-cert-cn']
          return cn
            ? { kind: 'matched', subject: { type: 'system', id: `mtls:${cn}`, tenantId: null } }
            : { kind: 'skip' }
        },
      },
    ],
  },
}

The built-in password strategy still runs first; your strategy adds to the chain. Most apps never need this.

ctx.subject in subscriptions

ctx.subject is captured at subscribe-time. If the cookie expires mid-subscription:

  • Subsequent mutations from the now-expired client fail with Unauthorised.
  • The subscription itself continues to stream until the client reconnects or the server drops it.
  • On reconnect, the new connection re-resolves the subject — anonymous if the cookie is gone.

The open stream isn't force-killed on expiry; the next write fails with Unauthenticated and the client can re-auth or reconnect. That gentler UX is the framework's behaviour.

Anti-patterns

  • Reading ctx.subject.id without narrowing. It's string on user / apiKey / serviceAccount / system, but null on anonymous. Narrow on type first.
  • Trusting subject.tenantId for writes. It's read-only context. Always assertOwnTenant(input.tenantId, ctx.subject) for any write that takes a tenant.
  • Caching subjects across requests. The subject is request-scoped; sessions expire, role membership changes. Resolve fresh each time.