Actor envelope contract
The
actor.*fields are the single source of authorization input to the DSL. Every policy, spec, query filter, or mutation body that gates on the authenticated actor MUST read through the envelope; nothing in the engine should reach around it.
What the envelope is
When a request hits the gRPC stream, the auth interceptor
(component/grpc/auth_session_middleware.go) validates the JWT (or
the API-key bearer / Worker token, per the identity
shape) and builds an auth.Identity struct. The engine binds that
identity onto the request context; resolver code reads it back via
auth.UserIdentityFromContext.
The DSL surface is read-only access to that same identity
through dotted-path fields on the actor namespace. Author-side:
@actorshape actorEnvelope { actor.userId actor.role actor.identityId actor.isClusterOwner actor.now}Engine-side, dotted paths route through
component/memql/executor.go:resolveActorReferences (line 377) →
resolveActorPath (line 427), matching each *ActorReference node
(line 383) against the resolved envelope. #221
renamed both the DSL surface and these internal Go names, so actor.
is the vocabulary top to bottom -- there is no leftover caller. /
CallerReference naming behind it.
Field reference
| Field | Type | Meaning | Per-identity-shape behavior |
|---|---|---|---|
actor.userId | string | Canonical v1:identity:user.id for the user behind the request | user (magic-link / OAuth): the user's id. PAT: the user who owns the PAT. worker token: the user who issued the worker token. system: empty -- system actor; actor.role == "system". |
actor.role | string | Cluster-wide role: owner / admin / developer / writer / reader (plus system for the seed-materializer / automation actor) | Re-fetched from v1:identity:user.role at each request -- a role demotion takes effect at the next call, not on the next token refresh. |
actor.identityId | string | v1:identity:identity.id of the credential the request was authenticated with (distinct from userId: one user can own many credentials) | user (magic-link): the magic_link identity row's id. PAT: the api_key identity row's id. worker token: the worker_token identity row's id. |
actor.isClusterOwner | bool | True iff actor.userId is the registered cluster owner | Re-resolved per request via the cluster-settings lookup. |
actor.primaryEmail | string | The user's primary email | Empty for system / worker shapes. |
actor.now | string | RFC3339 timestamp captured at request start | Same for every field reference within the request -- the clock is captured once. |
actor.config.<key> | string | Allow-listed config value (whitelist in component/config/policy_exposable.go) | Reading an unlisted key is a parse-time error. |
(The pre-#56 envelope also exposed actor.partitions + actor.partition. Those are retired and the parser rejects them with a migration hint -- partitioning came out in #56 phase 5.)
Identity shapes
The envelope exposes the same field names regardless of how the caller authenticated, but the underlying behavior differs:
| Shape | How it authenticates | actor.userId | actor.role | actor.identityId | Surface restrictions |
|---|---|---|---|---|---|
| user (magic-link) | JWT verified against JWKS | user's id | user's role | the magic_link identity | full stream |
| user (PAT) | API key bearer | owner's id | owner's role | the api_key identity | full stream |
| worker token | Authorization: Worker mql_wkr_<token> | registering user's id | system | the worker_token identity | only WorkerService.Stream; everything else 401 |
| badge operator grant | short-lived class="badge" JWT minted by POST /auth/badge/grant (memql#2513), rotated onto the stream via RotateAuth | the OPERATOR's id | operator's row role clamped to the terminal's role at grant time (role_ceiling) | empty | stream EXCEPT credential/session/cluster management (rejected badge_grant_restricted while live); after the grant's exp every envelope but the recovery control frames is rejected badge_grant_expired |
| system | systemActorContext(ctx) -- internal-only | empty | system | empty | seed materializer, automations -- never reachable from a network request |
The auth interceptor (component/grpc/auth_session_middleware.go)
- the worker interceptor (
worker_stream_interceptor.go) builds the right envelope based on which token shape arrived. The engine never re-classifies; it just reads through the envelope.
What the envelope is NOT
- Not a permission set. The envelope says who the actor is,
not what they can do. The DSL composes permissions on top of
the envelope: policies + specs gate behavior on
actor.role,actor.isClusterOwner,actor.userId == payload.ownerUserId, etc. - Not mutable inside a request. Every field is captured once at request entry. Engine code MUST NOT update the envelope mid-call.
- Not a substitute for storage-level enforcement today. After #56 lands and partitioning went away, the envelope is the only authorization layer.
Author rules
- Always read through
actor.*-- never inspectauth.Identitydirectly from Go code that's making an authz decision. (Engine plumbing that constructs the envelope is the sole exception.) @actorshapes carry the binding. Specs that want to gate on actor state bind the shape in their signature (spec actorEnvelope <name> { return <key> }, or a more specific actor shape) and read the projected key bare. The post-load validator catches a binding that resolves to no registered shape.actor.nowis the only clock. Never use Go'stime.Now()in a DSL evaluator path; that's a source of test-flake + replay-skew.
Anti-patterns
- Reaching around the envelope for "fast path" reads. Don't
read
tokenInfo.Subjectdirectly to compose an authz decision -- readactor.userIdthrough the envelope. Today the auth context's Subject and the envelope's userId match; reaching around it lets them silently drift. - Composing partial envelopes. Don't build a partial actor
envelope for a sub-call (e.g. "this query runs as the user but
with admin role"). If a privilege escalation is intended, use the
delegationconcept; otherwise the envelope reflects the authenticated actor exactly as-is. - Caching the envelope across requests. Per-request only.
caller.X/@caller. Retired in #221. The parser rejects both spellings; the canonical form isactor.X/@actor.
Related issues
- #54 — per-row authorization audit (uses the envelope to gate every user-scoped query / mutation)
- #56 — removed partitioning (deleted the partition fields)
- #221 — consolidated
caller./actor.->actor.for one canonical auth-context vocabulary across the DSL