Context Tokens — Data Protocol reference
Context Tokens — the declarative placeholders that resolve against the caller's session (who am I, which org am I in) rather than the clock.
Context Tokens — the declarative placeholders that resolve against the
caller's session (who am I, which org am I in) rather than the clock,
plus one sibling that resolves against the surface: {record_id}, the
record a type: 'record' page is showing.
The two resolve against different things, so they are two lists, never one:
CONTEXT_TOKENS | RECORD_CONTEXT_TOKENS | |
|---|---|---|
| Tokens | {current_user_id}, {current_org_id} | {record_id} |
| Resolves against | the caller's session | the record the page is bound to |
| Known on the server | yes — ExecutionContext | never — only the page renderer knows which record is in view |
| Valid on | every filter surface | a component on a type: 'record' page, and nowhere else |
Why this lives in spec
These are the sibling vocabulary to {date-macros}. Filter values in
dashboards, views, reports and pages travel as JSON, so a user-scoped
slice cannot call currentUser().id inline — it writes a placeholder:
{ owner_id: '{current_user_id}' }
[{ field: 'owner', operator: 'equals', value: '{current_user_id}' }]Like date macros, the placeholders are expanded on both sides of
the wire (framework#3582): resolveContextTokens() in
@object-ui/core before the filter leaves the browser, and
resolveFilterTokens() in @objectstack/core on the ObjectQL read
AND write paths and the analytics dataset executor, for filters that
reach the database without passing through a renderer. The DRIVER
only ever sees concrete ids, never {tokens}.
The write verbs matter as much as the read ones (#3810): a filter has
to select the same rows whether find, update or delete consumes
it, or a flow that previews with one and acts with the other operates
on two different row sets.
The server resolver reads ExecutionContext — {current_user_id} is
userId, {current_org_id} is tenantId. A request that carries
neither is an ERROR, not a null comparand: resolving to null
degrades to IS NULL on most drivers and would hand back the rows
the filter was written to exclude.
{record_id} is resolved on ONE side of the wire only: by the page
renderer, which substitutes the id of the record a type: 'record' page
is showing before the filter leaves the browser. The server never knows
which record a page is showing, so a filter that reaches
resolveFilterTokens() still carrying {record_id} is refused by name
(FILTER_TOKEN_UNRESOLVED / 400) — which is also what an author sees
from a renderer that does not resolve the token yet. It never becomes
null, undefined or the literal string: a number written as "about
this record" must neither silently become "about everybody" (the
condition dropped) nor "about nobody" (the token compared as text).
Presentation scope, NOT a security boundary
This is the single most important thing to understand about these
tokens. {current_user_id} scopes what a surface shows; it does not
decide what a caller is allowed to read. {record_id} is the same: it
narrows a record-page component to the record in view, and which of those
rows the caller may read is still not its decision. Enforcement is RLS, which
uses a different and genuinely server-side vocabulary rooted at
current_user (owner_id = current_user.id, compiled by
@objectstack/plugin-security's RLS compiler).
The two look alike and are easy to confuse, so keep them straight:
{current_user_id} | current_user.id | |
|---|---|---|
| Where | filter values (JSON) | RLS using expressions |
| Resolved | client-side, before the query | server-side, during the query |
| Purpose | presentation scope | access enforcement |
| Bypassable | yes — it's just a filter | no |
Never reach for a context token to keep a user away from data. Removing
a {current_user_id} filter widens a view; it must never widen
access.
Where the tokens are honoured
{current_user_id} and {current_org_id}: filter values on every surface
that resolves placeholders — object list views, dashboard widgets,
reports, datasets, SDUI page components — and on the server's ObjectQL
read and write paths and analytics doors.
{record_id}: filter values on a component of a type: 'record' page,
and nowhere else. @objectstack/lint's validate-filter-tokens refuses it
by name ("no record in context on this surface") on list views, dashboard
widgets, reports, datasets, apps, object definitions and every page that is
not type: 'record'. The server refuses it on every path, because no
server path has a record in context.
Navigation (recordId / params) additionally resolves
AppContextSelector ids such as {active_package}; those are nav-only and
are NOT valid inside filter values, because filters are not evaluated with
the sidebar's selector state.
{record_id} is not {recordId}, and not the 'record_id' variable type
Three spellings look alike and are three different mechanisms:
{record_id}— this filter token. A whole filter VALUE, resolved to the id of the record the page shows.{recordId}— a URL / flow-template placeholder: an action'snewTabUrl(action.zod.ts) and the flow template dialect (@objectstack/lint'sflow-template-grammar.ts). It is interpolated by those surfaces' own template engines and is NOT a filter token. Written inside a filter it is refused, with{record_id}suggested.'record_id'— a page-variable TYPE (PageVariableSchema.typeinpage.zod.ts), naming what a page variable holds, e.g. the selection of anelement:record_picker. It is read in expressions aspage.<name>, never written as a placeholder.
Out of scope
current_user.*RLS expressions — see@objectstack/plugin-security.{date-macros}— the clock-based sibling; see./date-macros.zod.ts.titleFormatfield interpolation ({user_id}etc.) — that substitutes record fields, an unrelated mechanism that happens to share braces.- Tokens for a record OTHER than the one in view (a related record, a
parent of the record). Each is its own contract with its own resolver,
not an extension of
{record_id}.
Source: packages/spec/src/data/context-tokens.zod.ts
TypeScript Usage
import { ContextTokenSchema, ContextTokenPlaceholderSchema } from '@objectstack/spec/data';
import type { ContextToken, ContextTokenPlaceholder } from '@objectstack/spec/data';
// Validate data
const result = ContextTokenSchema.parse(data);ContextToken
Type: string
ContextTokenPlaceholder
Type: string