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;        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
}

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 chaincomposeAuthStrategies evaluates each strategy in order, first matched wins, first failed short-circuits to anonymous. A typical chain resolves, in order:

  1. Built-in password cookievoltroPasswordStrategy reads voltro:session and HMAC-verifies it. → type: 'user'.
  2. API keyapiKeyStrategy 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 fallback — nothing matched. The composer's fallback reads the x-tenant header and produces anonymousSubject(tenant). → type: 'anonymous'.

An anonymous request with x-tenant: <id> therefore carries that tenant on the subject, scoping reads on public tables. The header is unauthenticated — never trust it for writes.

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 tenant resolution

For public APIs where you want anonymous callers scoped to a tenant (multi-tenant marketing site, public listings), nothing extra to configure: when no strategy matches, composeAuthStrategies' default fallback reads the x-tenant header and yields anonymousSubject(tenant):

import { anonymousSubject } from '@voltro/protocol'
// fallback output for `x-tenant: foo`:
//   { type: 'anonymous', id: null, tenantId: 'foo' }

Tables with tenant() then scope anonymous reads to foo. To require a tenant on anonymous callers — so a tenant-less request can't read across the whole DB on tables that aren't tenant()-scoped — pass anonymousTenantRequired: true to composeAuthStrategies:

const resolveSubject = composeAuthStrategies(strategies, {
  anonymousTenantRequired: true, // no x-tenant + no matched strategy → throws Unauthenticated
})

When set, an unmatched call with no x-tenant header throws Unauthenticated instead of yielding a null-tenant anonymous Subject. (Ignored when you supply a custom fallback — that function owns the decision.)

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.