diff --git a/.changeset/filter-context-tokens-gate.md b/.changeset/filter-context-tokens-gate.md
new file mode 100644
index 0000000000..9faa166f5d
--- /dev/null
+++ b/.changeset/filter-context-tokens-gate.md
@@ -0,0 +1,47 @@
+---
+"@objectstack/spec": patch
+"@objectstack/lint": patch
+"@objectstack/cli": patch
+---
+
+feat(spec,lint): freeze the `{current_user_id}` filter vocabulary and fail the build on unresolvable placeholders (#3574)
+
+A dashboard widget filtered on `{current_user}` rendered `0`. Not an error — a
+zero, indistinguishable from a metric that is legitimately empty, with nothing
+in the console or the server log. `service_dashboard.my_open_cases_by_priority`
+in the HotCRM template had shipped broken this way since the day it was
+written.
+
+The token had never been part of the contract. Date macros were frozen in
+`date-macros.zod.ts` with a spec vocabulary, a lint-usable predicate, and a
+single client resolver; `{current_user_id}` had only prose in an `app.zod.ts`
+JSDoc and three ad-hoc client implementations that each handled one surface's
+filter shape. Nothing could tell an author their token was wrong.
+
+- **`@objectstack/spec`** — new `data/context-tokens.zod.ts` freezing
+ `CONTEXT_TOKENS` (`current_user_id`, `current_org_id`) as the sibling of
+ `DATE_MACRO_TOKENS`, with `isContextToken` / `isKnownFilterToken` /
+ `classifyFilterToken` and a `CONTEXT_TOKEN_SUGGESTIONS` near-miss table. The
+ module documents what the tokens are *not*: presentation scope, never an
+ access boundary — that is RLS, which uses the unrelated `current_user.id`
+ expression root.
+- **`@objectstack/lint`** — new `validateFilterTokens` (rule
+ `filter-token-unknown`, severity `error`). It walks `filter` / `filters` /
+ `runtimeFilter` subtrees across dashboards, objects, views, reports,
+ datasets, pages and apps, and reports any placeholder that resolves in
+ neither vocabulary. It scans for filter *keys* rather than enumerating known
+ surfaces, so a new surface following the convention is covered the day it
+ ships — enumerating surfaces is how the dashboard was missed in the first
+ place. Navigation `recordId` / `params` are deliberately out of scope: they
+ resolve `AppContextSelector` ids, which are meaningless in a filter.
+- **`@objectstack/cli`** — the gate runs in `os validate` and `os compile`.
+
+It is an error rather than a warning because of who authors this metadata. An
+AI reads a query returning `0` as a correct answer and builds on it; its
+correction loop is author → validate → fix, so a diagnostic only reaches it if
+it can fail the build. The three spellings the suggestion table covers —
+`{current_user}`, `{user_id}`, `{organization_id}` — are each correct
+*somewhere else* in the platform, which is exactly why authors reach for them.
+
+Also fixes a `ViewSchema` JSDoc example that documented `{user_id}`, a token
+that resolves nowhere.
diff --git a/content/docs/references/data/context-tokens.mdx b/content/docs/references/data/context-tokens.mdx
new file mode 100644
index 0000000000..7351264066
--- /dev/null
+++ b/content/docs/references/data/context-tokens.mdx
@@ -0,0 +1,111 @@
+---
+title: Context Tokens
+description: Context Tokens protocol schemas
+---
+
+{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}
+
+Context Tokens — the declarative placeholders that resolve against the
+
+**caller's session** (who am I, which org am I in) rather than the clock.
+
+# 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 **client-side** by
+
+`resolveContextTokens()` in `@object-ui/core` immediately before the
+
+filter is handed to the data source. The data engine only ever sees
+
+concrete ids, never `\{tokens\}`.
+
+# 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. 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
+
+Filter values on every surface that resolves placeholders — object list
+
+views, dashboard widgets, reports, SDUI page components. 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.
+
+# Out of scope
+
+- `current_user.*` RLS expressions — see `@objectstack/plugin-security`.
+
+- `\{date-macros\}` — the clock-based sibling; see `./date-macros.zod.ts`.
+
+- `titleFormat` field interpolation (`\{user_id\}` etc.) — that substitutes
+
+*record fields*, an unrelated mechanism that happens to share braces.
+
+
+**Source:** `packages/spec/src/data/context-tokens.zod.ts`
+
+
+## TypeScript Usage
+
+```typescript
+import { ContextToken, ContextTokenPlaceholder } from '@objectstack/spec/data';
+import type { ContextToken, ContextTokenPlaceholder } from '@objectstack/spec/data';
+
+// Validate data
+const result = ContextToken.parse(data);
+```
+
+---
+
+
+---
+
+
+---
+
diff --git a/content/docs/references/data/index.mdx b/content/docs/references/data/index.mdx
index 9d9f6e28db..207d74e17e 100644
--- a/content/docs/references/data/index.mdx
+++ b/content/docs/references/data/index.mdx
@@ -7,6 +7,7 @@ This section contains all protocol schemas for the data layer of ObjectStack.
+
diff --git a/content/docs/references/data/meta.json b/content/docs/references/data/meta.json
index 9f551ea1b0..e8e922b97e 100644
--- a/content/docs/references/data/meta.json
+++ b/content/docs/references/data/meta.json
@@ -27,6 +27,7 @@
"seed",
"seed-loader",
"---More---",
+ "context-tokens",
"field-value"
]
}
\ No newline at end of file
diff --git a/packages/cli/src/commands/compile.ts b/packages/cli/src/commands/compile.ts
index 17cca927fd..46d1eb207b 100644
--- a/packages/cli/src/commands/compile.ts
+++ b/packages/cli/src/commands/compile.ts
@@ -12,6 +12,7 @@ import { validateStackExpressions } from '@objectstack/lint';
import { validateVisibilityPredicates } from '@objectstack/lint';
import { validateWidgetBindings } from '@objectstack/lint';
import { validateDashboardActionRefs } from '@objectstack/lint';
+import { validateFilterTokens } from '@objectstack/lint';
import { validateResponsiveStyles } from '@objectstack/lint';
import { validateSecurityPosture, validateOrgAxisRedLines, buildAccessMatrix, diffAccessMatrix } from '@objectstack/lint';
import { validateReadonlyFlowWrites } from '@objectstack/lint';
@@ -293,6 +294,31 @@ export default class Compile extends Command {
}
}
+ // 3a-ter. Filter placeholder resolvability (#3574). A filter value that
+ // resolves in neither vocabulary — `{current_user}` instead of
+ // `{current_user_id}` — reaches the data engine as a literal and
+ // matches nothing, so the surface renders empty with no error. That
+ // silent zero is indistinguishable from a genuine zero at review
+ // time, and an AI author reads it as a successful query. Fails the
+ // build because authoring time is the last point the author sees it.
+ if (!flags.json) printStep('Checking filter placeholders (#3574)...');
+ const filterTokenFindings = validateFilterTokens(result.data as Record);
+ const filterTokenErrors = filterTokenFindings.filter((f) => f.severity === 'error');
+ if (filterTokenErrors.length > 0) {
+ if (flags.json) {
+ console.log(JSON.stringify({ success: false, error: 'filter placeholder validation failed', issues: filterTokenErrors }));
+ this.exit(1);
+ }
+ console.log('');
+ printError(`Filter placeholder check failed (${filterTokenErrors.length} issue${filterTokenErrors.length > 1 ? 's' : ''})`);
+ for (const f of filterTokenErrors.slice(0, 50)) {
+ console.log(` • ${f.where}: ${f.message}`);
+ console.log(chalk.dim(` ${f.hint}`));
+ console.log(chalk.dim(` rule: ${f.rule} at ${f.path}`));
+ }
+ this.exit(1);
+ }
+
// 3c. SDUI scoped-styling correctness (ADR-0065) — a styled node without
// an `id` drops its CSS silently; Tailwind-in-className does nothing
// from metadata. Same bar for hand-authored and AI-generated pages
diff --git a/packages/cli/src/commands/validate.ts b/packages/cli/src/commands/validate.ts
index b3de1f5efb..7df47faa25 100644
--- a/packages/cli/src/commands/validate.ts
+++ b/packages/cli/src/commands/validate.ts
@@ -13,6 +13,7 @@ import { validateListViewMode } from '@objectstack/lint';
import { validateViewContainers } from '@objectstack/lint';
import { validateWidgetBindings } from '@objectstack/lint';
import { validateDashboardActionRefs } from '@objectstack/lint';
+import { validateFilterTokens } from '@objectstack/lint';
import { validateResponsiveStyles } from '@objectstack/lint';
import { validateJsxPages, validateReactPages, validateReactPageProps, validatePageSourceStyling } from '@objectstack/lint';
import { validateCapabilityReferences } from '@objectstack/lint';
@@ -250,6 +251,36 @@ export default class Validate extends Command {
}
}
+ // 3a-ter. Filter placeholder resolvability (#3574) — a filter value like
+ // `{current_user}` resolves in no vocabulary, reaches the data engine
+ // as a literal, matches nothing, and the surface renders empty with
+ // no error anywhere. Silent-zero is indistinguishable from a genuine
+ // zero, so it survives human review; and an AI author reads the 0 as
+ // a successful query. Caught here because authoring time is the only
+ // place the diagnostic can still reach the author.
+ if (!flags.json) printStep('Checking filter placeholders (#3574)...');
+ const filterTokenFindings = validateFilterTokens(result.data as Record);
+ const filterTokenErrors = filterTokenFindings.filter((f) => f.severity === 'error');
+ if (filterTokenErrors.length > 0) {
+ if (flags.json) {
+ console.log(JSON.stringify({
+ valid: false,
+ errors: filterTokenErrors,
+ warnings: [...widgetWarnings, ...actionRefWarnings],
+ duration: timer.elapsed(),
+ }, null, 2));
+ this.exit(1);
+ }
+ console.log('');
+ printError(`Filter placeholder check failed (${filterTokenErrors.length} issue${filterTokenErrors.length > 1 ? 's' : ''})`);
+ for (const f of filterTokenErrors.slice(0, 50)) {
+ console.log(` • ${f.where}: ${f.message}`);
+ console.log(chalk.dim(` ${f.hint}`));
+ console.log(chalk.dim(` rule: ${f.rule} at ${f.path}`));
+ }
+ this.exit(1);
+ }
+
// 3b. SDUI scoped-styling correctness (ADR-0065) — a styled node's
// responsiveStyles must be scopable (needs an `id`), reference real
// CSS properties + design tokens, and carry a `large` base;
diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts
index 9f874d2427..db3b7a45d5 100644
--- a/packages/lint/src/index.ts
+++ b/packages/lint/src/index.ts
@@ -178,4 +178,7 @@ export type {
DashboardActionRefSeverity,
} from './validate-dashboard-action-refs.js';
+export { validateFilterTokens, FILTER_TOKEN_UNKNOWN } from './validate-filter-tokens.js';
+export type { FilterTokenFinding, FilterTokenSeverity } from './validate-filter-tokens.js';
+
export { buildAccessMatrix, diffAccessMatrix } from './build-access-matrix.js';
diff --git a/packages/lint/src/validate-filter-tokens.test.ts b/packages/lint/src/validate-filter-tokens.test.ts
new file mode 100644
index 0000000000..068004f41d
--- /dev/null
+++ b/packages/lint/src/validate-filter-tokens.test.ts
@@ -0,0 +1,188 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+import { describe, it, expect } from 'vitest';
+import { validateFilterTokens, FILTER_TOKEN_UNKNOWN } from './validate-filter-tokens';
+
+describe('validateFilterTokens', () => {
+ it('returns nothing for an empty / absent stack', () => {
+ expect(validateFilterTokens(undefined)).toEqual([]);
+ expect(validateFilterTokens(null)).toEqual([]);
+ expect(validateFilterTokens({})).toEqual([]);
+ });
+
+ // The exact shape from issue #3574: a metric widget whose owner clause never
+ // resolved, so the widget rendered 0 and nobody noticed.
+ it('catches {current_user} in a dashboard widget filter and names the widget', () => {
+ const findings = validateFilterTokens({
+ dashboards: [
+ {
+ name: 'service_dashboard',
+ widgets: [
+ {
+ id: 'my_open_cases',
+ type: 'metric',
+ dataset: 'case_metrics',
+ filter: { owner: '{current_user}', status: 'open' },
+ },
+ ],
+ },
+ ],
+ });
+
+ expect(findings).toHaveLength(1);
+ expect(findings[0].severity).toBe('error');
+ expect(findings[0].rule).toBe(FILTER_TOKEN_UNKNOWN);
+ expect(findings[0].where).toBe('dashboard "service_dashboard" · widget "my_open_cases"');
+ expect(findings[0].path).toBe('dashboards[0].widgets[0].filter.owner');
+ expect(findings[0].hint).toContain('{current_user_id}');
+ });
+
+ it('accepts the correct spelling alongside date macros', () => {
+ expect(
+ validateFilterTokens({
+ dashboards: [
+ {
+ name: 'd',
+ widgets: [
+ {
+ id: 'w',
+ filter: {
+ owner_id: '{current_user_id}',
+ org: '{current_org_id}',
+ created_at: { $gte: '{week_start}' },
+ closed_at: { $lte: '${30_days_ago}' },
+ status: 'open',
+ },
+ },
+ ],
+ },
+ ],
+ }),
+ ).toEqual([]);
+ });
+
+ // Both platform filter shapes must be walked. A resolver that handled only
+ // the array shape is what caused #3574 in the first place.
+ it('walks the array/triple list-view shape as well as the object shape', () => {
+ const findings = validateFilterTokens({
+ objects: [
+ {
+ name: 'crm_lead',
+ listViews: {
+ my_leads: {
+ filter: [{ field: 'owner', operator: 'equals', value: '{current_user}' }],
+ },
+ my_other: { filter: [['owner', '=', '{user_id}']] },
+ },
+ },
+ ],
+ });
+
+ expect(findings).toHaveLength(2);
+ expect(findings.map((f) => f.path).sort()).toEqual([
+ 'objects[0].listViews.my_leads.filter[0].value',
+ 'objects[0].listViews.my_other.filter[0][2]',
+ ]);
+ for (const f of findings) expect(f.hint).toContain('{current_user_id}');
+ });
+
+ it('reaches nested $and / $or branches', () => {
+ const findings = validateFilterTokens({
+ dashboards: [
+ {
+ name: 'd',
+ widgets: [
+ {
+ id: 'w',
+ filter: {
+ $and: [{ status: 'open' }, { $or: [{ owner: '{me}' }, { owner: '{current_user_id}' }] }],
+ },
+ },
+ ],
+ },
+ ],
+ });
+ expect(findings).toHaveLength(1);
+ expect(findings[0].path).toBe('dashboards[0].widgets[0].filter.$and[1].$or[0].owner');
+ });
+
+ it('covers reports, datasets and SDUI page components', () => {
+ const findings = validateFilterTokens({
+ reports: [{ name: 'r', runtimeFilter: { owner: '{current_user}' } }],
+ datasets: [{ name: 'ds', filter: { org: '{org_id}' } }],
+ pages: [
+ {
+ name: 'p',
+ regions: [
+ {
+ components: [
+ { type: 'object-grid', properties: { filters: [['owner_id', '=', '{current_user}']] } },
+ ],
+ },
+ ],
+ },
+ ],
+ });
+ expect(findings.map((f) => f.where).sort()).toEqual([
+ 'dataset "ds"',
+ 'page "p"',
+ 'report "r"',
+ ]);
+ });
+
+ // Navigation resolves an extra vocabulary (AppContextSelector ids). Filters
+ // do not, so the rule must not wander into `params` / `recordId`.
+ it('ignores nav params and recordId, which resolve context-selector ids', () => {
+ expect(
+ validateFilterTokens({
+ apps: [
+ {
+ name: 'app',
+ navigation: [
+ { id: 'n1', type: 'object', recordId: '{current_user_id}' },
+ { id: 'n2', type: 'component', params: { package: '{active_package}' } },
+ ],
+ },
+ ],
+ }),
+ ).toEqual([]);
+ });
+
+ it('flags an unresolvable token in a nav item filters map', () => {
+ const findings = validateFilterTokens({
+ apps: [
+ {
+ name: 'app',
+ navigation: [{ id: 'n', type: 'object', filters: { owner_id: '{current_user}' } }],
+ },
+ ],
+ });
+ expect(findings).toHaveLength(1);
+ expect(findings[0].path).toBe('apps[0].navigation[0].filters.owner_id');
+ });
+
+ it('leaves ordinary values and partial braces alone', () => {
+ expect(
+ validateFilterTokens({
+ dashboards: [
+ {
+ name: 'd',
+ widgets: [
+ {
+ id: 'w',
+ title: '{not_a_filter}',
+ filter: { name: 'Acme {Corp}', count: 3, active: true, tags: ['a', 'b'] },
+ },
+ ],
+ },
+ ],
+ }),
+ ).toEqual([]);
+ });
+
+ it('survives a cyclic metadata graph', () => {
+ const dash: Record = { name: 'd', widgets: [] };
+ dash.self = dash;
+ expect(() => validateFilterTokens({ dashboards: [dash] })).not.toThrow();
+ });
+});
diff --git a/packages/lint/src/validate-filter-tokens.ts b/packages/lint/src/validate-filter-tokens.ts
new file mode 100644
index 0000000000..75bcb5eefa
--- /dev/null
+++ b/packages/lint/src/validate-filter-tokens.ts
@@ -0,0 +1,249 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+import { classifyFilterToken, CONTEXT_TOKENS } from '@objectstack/spec/data';
+
+/**
+ * Build-time filter-placeholder diagnostics (issue #3574).
+ *
+ * Filter values travel as JSON, so a user-scoped or time-scoped slice cannot
+ * call code inline — it writes a placeholder that the client resolves just
+ * before querying:
+ *
+ * { owner_id: '{current_user_id}', created_at: { $gte: '{week_start}' } }
+ *
+ * Exactly two vocabularies resolve inside a filter value: context tokens
+ * (`{current_user_id}`, `{current_org_id}` — see `context-tokens.zod.ts`) and
+ * date macros (`{today}`, `{30_days_ago}` — see `date-macros.zod.ts`).
+ * Anything else is passed to the data engine **verbatim**, matches no row, and
+ * the surface renders an empty result.
+ *
+ * ## Why this is an error, not a warning
+ *
+ * The runtime failure mode is silent and indistinguishable from success. A
+ * metric widget filtered on an unresolved `{current_user}` renders `0`, which
+ * looks exactly like a metric that is legitimately zero — no console error, no
+ * server log, nothing for a human reviewer to notice. Issue #3574 found a
+ * dashboard that had been broken this way since the day it was written.
+ *
+ * That failure mode is worse for AI authors than for humans. An AI reads a
+ * successful query returning `0` as a correct answer and builds on it — it has
+ * no instinct that the number looks wrong. Its correction loop is
+ * "author → validate → fix", so a diagnostic only reaches it if the diagnostic
+ * can fail the build. A runtime warning in a server log is invisible to it.
+ * Hence: authoring-time error.
+ *
+ * The near-miss spellings this catches are not hypothetical. Each is a correct
+ * spelling *somewhere else* in the platform, which is precisely why authors
+ * reach for them:
+ *
+ * - `{current_user}` — `current_user.id` is the RLS expression root
+ * - `{user_id}` — `{user_id}` is valid `titleFormat` field interpolation
+ * - `{current_organization_id}` — `organization_id` is the real column name
+ *
+ * `CONTEXT_TOKEN_SUGGESTIONS` maps each to what the author meant, so the
+ * diagnostic names the fix instead of only reporting the symptom.
+ *
+ * ## Scope — filter subtrees only
+ *
+ * The walk descends into `filter` / `filters` / `runtimeFilter` subtrees and
+ * classifies string values inside them. It deliberately does NOT check
+ * navigation `recordId` / `params`, which resolve an additional vocabulary —
+ * `AppContextSelector` ids such as `{active_package}` — that is meaningless in
+ * a filter because filters are not evaluated with the sidebar's selector
+ * state. Restricting the walk keeps that legitimate usage out of the rule and
+ * holds false positives at zero.
+ *
+ * Only whole-string placeholders are considered (`'{token}'` / `'${token}'`,
+ * anchored). A value that merely contains braces is left alone.
+ */
+
+export const FILTER_TOKEN_UNKNOWN = 'filter-token-unknown';
+
+export type FilterTokenSeverity = 'error' | 'warning';
+
+export interface FilterTokenFinding {
+ /** Always `error` today — an unresolved placeholder silently matches nothing. */
+ severity: FilterTokenSeverity;
+ /** Diagnostic rule id. */
+ rule: string;
+ /** Human-readable location, e.g. `dashboard "sales" · widget "my_deals"`. */
+ where: string;
+ /** Config path, e.g. `dashboards[0].widgets[2].filter.owner_id`. */
+ path: string;
+ /** What is wrong. */
+ message: string;
+ /** How to fix it. */
+ hint: string;
+}
+
+type AnyRec = Record;
+
+/** Keys whose subtree is a filter — the only place placeholders resolve. */
+const FILTER_KEYS = new Set(['filter', 'filters', 'runtimeFilter']);
+
+/**
+ * Coerce a collection (array or name-keyed map) to an array of records,
+ * injecting `name` from the map key — mirrors the helper in the sibling
+ * authoring lints so the rule works on both the parsed (array) and normalized
+ * (map) stack shapes.
+ */
+function asArray(v: unknown): AnyRec[] {
+ if (Array.isArray(v)) return v as AnyRec[];
+ if (v && typeof v === 'object') {
+ return Object.entries(v as AnyRec).map(([name, def]) => ({ name, ...(def as AnyRec) }));
+ }
+ return [];
+}
+
+function label(v: unknown, fallback: string): string {
+ return typeof v === 'string' && v.length > 0 ? v : fallback;
+}
+
+const KNOWN_LIST = CONTEXT_TOKENS.join('}, {');
+
+/**
+ * Classify every string inside an already-identified filter subtree.
+ *
+ * Walks arrays and plain objects uniformly, which is what makes this work
+ * across the platform's two filter shapes: the MongoDB-style object a
+ * dashboard widget carries (`{ owner_id: '{current_user_id}' }`) and the
+ * condition/triple arrays a list view carries
+ * (`[{ field, operator, value }]` / `[['owner','=','…']]`). The bug this rule
+ * exists for was caused by a resolver that handled only one of those shapes.
+ */
+function walkFilterValues(
+ node: unknown,
+ path: string,
+ where: string,
+ out: FilterTokenFinding[],
+ seen: Set,
+): void {
+ if (node === null || node === undefined) return;
+
+ if (typeof node === 'string') {
+ const cls = classifyFilterToken(node);
+ if (cls?.kind === 'unknown') {
+ const suggestion = cls.suggestion;
+ out.push({
+ severity: 'error',
+ rule: FILTER_TOKEN_UNKNOWN,
+ where,
+ path,
+ message:
+ `Filter value "${node}" is not a resolvable placeholder. It is sent to the ` +
+ `data engine as a literal string, matches no record, and the surface renders empty.`,
+ hint: suggestion
+ ? `Did you mean "{${suggestion}}"? Context tokens are {${KNOWN_LIST}}; ` +
+ `time-based values use date macros such as {today} or {30_days_ago}.`
+ : `Resolvable placeholders are the context tokens {${KNOWN_LIST}} and the ` +
+ `date macros (e.g. {today}, {week_start}, {30_days_ago}). To filter on a ` +
+ `literal value that happens to look like a placeholder, this is not supported — ` +
+ `rename the value.`,
+ });
+ }
+ return;
+ }
+
+ if (typeof node !== 'object') return;
+ // Metadata graphs can be cyclic once normalized; guard the walk.
+ if (seen.has(node)) return;
+ seen.add(node);
+
+ if (Array.isArray(node)) {
+ node.forEach((v, i) => walkFilterValues(v, `${path}[${i}]`, where, out, seen));
+ return;
+ }
+
+ for (const [k, v] of Object.entries(node as AnyRec)) {
+ walkFilterValues(v, `${path}.${k}`, where, out, seen);
+ }
+}
+
+/**
+ * Find `filter` / `filters` / `runtimeFilter` subtrees anywhere beneath
+ * `node`, then classify the values inside them.
+ *
+ * Scanning for filter KEYS rather than enumerating known surfaces is
+ * deliberate: widget filters, list-view filters, dataset and measure filters,
+ * report runtime filters, and SDUI component filters all spell the key the
+ * same way, and a new surface that follows the convention is covered the day
+ * it ships. Enumerating surfaces is how #3574 happened — the dashboard was
+ * simply never added to the list.
+ */
+function scanForFilters(
+ node: unknown,
+ path: string,
+ where: string,
+ out: FilterTokenFinding[],
+ seen: Set,
+): void {
+ if (!node || typeof node !== 'object') return;
+ if (seen.has(node)) return;
+ seen.add(node);
+
+ if (Array.isArray(node)) {
+ node.forEach((v, i) => scanForFilters(v, `${path}[${i}]`, where, out, seen));
+ return;
+ }
+
+ for (const [k, v] of Object.entries(node as AnyRec)) {
+ const childPath = `${path}.${k}`;
+ if (FILTER_KEYS.has(k)) {
+ walkFilterValues(v, childPath, where, out, new Set());
+ continue;
+ }
+ scanForFilters(v, childPath, where, out, seen);
+ }
+}
+
+/**
+ * Validate filter placeholders across a schema-parsed stack.
+ *
+ * Pure `(stack) => Finding[]`; no I/O. Covers dashboards (widget + global
+ * filters), objects (list views), top-level view containers, reports,
+ * datasets, and pages.
+ */
+export function validateFilterTokens(stack: Record | undefined | null): FilterTokenFinding[] {
+ if (!stack || typeof stack !== 'object') return [];
+ const out: FilterTokenFinding[] = [];
+
+ const surfaces: Array<[key: string, kind: string]> = [
+ ['dashboards', 'dashboard'],
+ ['objects', 'object'],
+ ['views', 'view'],
+ ['reports', 'report'],
+ ['datasets', 'dataset'],
+ ['pages', 'page'],
+ ['apps', 'app'],
+ ];
+
+ for (const [key, kind] of surfaces) {
+ const items = asArray((stack as AnyRec)[key]);
+ items.forEach((item, i) => {
+ const name = label(item.name ?? item.id, `#${i}`);
+ // Dashboards are the surface #3574 was filed against; name the widget in
+ // `where` so the author can jump straight to it.
+ if (kind === 'dashboard') {
+ const widgets = Array.isArray(item.widgets) ? (item.widgets as AnyRec[]) : [];
+ widgets.forEach((w, wi) => {
+ const wName = label(w.id ?? w.title, `#${wi}`);
+ scanForFilters(
+ w,
+ `${key}[${i}].widgets[${wi}]`,
+ `dashboard "${name}" · widget "${wName}"`,
+ out,
+ new Set(),
+ );
+ });
+ // …and everything else on the dashboard (globalFilters, header, etc.)
+ // minus the widgets already covered above.
+ const { widgets: _skip, ...rest } = item;
+ scanForFilters(rest, `${key}[${i}]`, `dashboard "${name}"`, out, new Set());
+ return;
+ }
+ scanForFilters(item, `${key}[${i}]`, `${kind} "${name}"`, out, new Set());
+ });
+ }
+
+ return out;
+}
diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json
index 41d5eb04fb..ad81c70927 100644
--- a/packages/spec/api-surface.json
+++ b/packages/spec/api-surface.json
@@ -190,6 +190,10 @@
"CALENDAR_DATE_TYPES (const)",
"CLOCK_TIME_TYPES (const)",
"COMPUTED_VALUE_TYPES (const)",
+ "CONTEXT_TOKENS (const)",
+ "CONTEXT_TOKEN_DESCRIPTIONS (const)",
+ "CONTEXT_TOKEN_SUGGESTIONS (const)",
+ "CONTEXT_TOKEN_WRAPPED_RE (const)",
"CalendarDateValueSchema (const)",
"ClockTimeValueSchema (const)",
"ComparisonOperatorSchema (const)",
@@ -200,6 +204,9 @@
"ConditionalValidationSchema (const)",
"ConsistencyLevel (type)",
"ConsistencyLevelSchema (const)",
+ "ContextToken (type)",
+ "ContextTokenPlaceholderSchema (const)",
+ "ContextTokenSchema (const)",
"CrossFieldValidation (type)",
"CrossFieldValidationSchema (const)",
"CrudAffordances (interface)",
@@ -539,6 +546,7 @@
"WindowSpec (type)",
"WindowSpecSchema (const)",
"canonicalizeSqlType (function)",
+ "classifyFilterToken (function)",
"countAuthorableFields (function)",
"defaultAggregateFor (function)",
"defineCube (function)",
@@ -556,10 +564,12 @@
"isApiOperationAllowed (function)",
"isApiPrimitive (function)",
"isCompatible (function)",
+ "isContextToken (function)",
"isDateMacroToken (function)",
"isFileIdToken (function)",
"isFilterAST (function)",
"isIncoherentAggregate (function)",
+ "isKnownFilterToken (function)",
"isLegacyApiMethod (function)",
"isMultiValueField (function)",
"isTenancyDisabled (function)",
diff --git a/packages/spec/json-schema.manifest.json b/packages/spec/json-schema.manifest.json
index f23530a31b..18d753fd89 100644
--- a/packages/spec/json-schema.manifest.json
+++ b/packages/spec/json-schema.manifest.json
@@ -673,6 +673,8 @@
"data/ComputedFieldCache",
"data/ConditionalValidation",
"data/ConsistencyLevel",
+ "data/ContextToken",
+ "data/ContextTokenPlaceholder",
"data/CrossFieldValidation",
"data/Cube",
"data/CubeJoin",
diff --git a/packages/spec/src/data/context-tokens.test.ts b/packages/spec/src/data/context-tokens.test.ts
new file mode 100644
index 0000000000..55987d87c8
--- /dev/null
+++ b/packages/spec/src/data/context-tokens.test.ts
@@ -0,0 +1,118 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+import { describe, it, expect } from 'vitest';
+import {
+ CONTEXT_TOKENS,
+ CONTEXT_TOKEN_DESCRIPTIONS,
+ CONTEXT_TOKEN_SUGGESTIONS,
+ ContextTokenPlaceholderSchema,
+ ContextTokenSchema,
+ classifyFilterToken,
+ isContextToken,
+ isKnownFilterToken,
+} from './context-tokens.zod';
+
+describe('context token contract', () => {
+ it('every token has a description', () => {
+ for (const t of CONTEXT_TOKENS) {
+ expect(CONTEXT_TOKEN_DESCRIPTIONS[t]).toBeTypeOf('string');
+ }
+ });
+
+ it('tokens pass the token schema', () => {
+ for (const t of CONTEXT_TOKENS) {
+ expect(() => ContextTokenSchema.parse(t)).not.toThrow();
+ }
+ });
+
+ it('placeholder schema accepts {token} and ${token}', () => {
+ expect(() => ContextTokenPlaceholderSchema.parse('{current_user_id}')).not.toThrow();
+ expect(() => ContextTokenPlaceholderSchema.parse('${current_org_id}')).not.toThrow();
+ expect(() => ContextTokenPlaceholderSchema.parse('current_user_id')).toThrow();
+ });
+
+ // The spelling in the #3574 report. It has never resolved anywhere in the
+ // platform — `current_user` is the RLS expression root, not a filter token —
+ // so the contract must keep rejecting it rather than quietly aliasing it.
+ it('rejects {current_user}, the RLS root mistaken for a filter token', () => {
+ expect(isContextToken('current_user')).toBe(false);
+ expect(isKnownFilterToken('current_user')).toBe(false);
+ expect(() => ContextTokenPlaceholderSchema.parse('{current_user}')).toThrow();
+ });
+
+ it('rejects near-miss spellings that resolve nowhere', () => {
+ for (const bad of ['user_id', 'me', 'current_organization_id', 'foo', 'currentUserId']) {
+ expect(isContextToken(bad), bad).toBe(false);
+ }
+ });
+});
+
+describe('isKnownFilterToken — the union of both filter vocabularies', () => {
+ it('accepts context tokens and date macros alike', () => {
+ expect(isKnownFilterToken('current_user_id')).toBe(true);
+ expect(isKnownFilterToken('current_org_id')).toBe(true);
+ expect(isKnownFilterToken('today')).toBe(true);
+ expect(isKnownFilterToken('30_days_ago')).toBe(true);
+ expect(isKnownFilterToken('week_start')).toBe(true);
+ });
+
+ it('rejects AppContextSelector ids — nav-only, meaningless in a filter', () => {
+ expect(isKnownFilterToken('active_package')).toBe(false);
+ });
+});
+
+describe('classifyFilterToken', () => {
+ it('returns null for values that are not placeholders at all', () => {
+ for (const v of ['open', '', 42, null, undefined, { $gte: 1 }, ['a'], 'a{b}c']) {
+ expect(classifyFilterToken(v)).toBeNull();
+ }
+ });
+
+ it('classifies each vocabulary', () => {
+ expect(classifyFilterToken('{current_user_id}')).toEqual({
+ kind: 'context',
+ token: 'current_user_id',
+ });
+ expect(classifyFilterToken('{today}')).toEqual({ kind: 'date-macro', token: 'today' });
+ expect(classifyFilterToken('${30_days_ago}')).toEqual({
+ kind: 'date-macro',
+ token: '30_days_ago',
+ });
+ });
+
+ // Each suggestion below is a real authoring mistake: the token is a correct
+ // spelling elsewhere in the platform, which is why authors reach for it.
+ it('suggests the intended token for known near-misses', () => {
+ expect(classifyFilterToken('{current_user}')).toEqual({
+ kind: 'unknown',
+ token: 'current_user',
+ suggestion: 'current_user_id',
+ });
+ expect(classifyFilterToken('{user_id}')).toEqual({
+ kind: 'unknown',
+ token: 'user_id',
+ suggestion: 'current_user_id',
+ });
+ expect(classifyFilterToken('{org_id}')).toEqual({
+ kind: 'unknown',
+ token: 'org_id',
+ suggestion: 'current_org_id',
+ });
+ });
+
+ it('reports unknown tokens with no suggestion when there is no near-miss', () => {
+ expect(classifyFilterToken('{totally_made_up}')).toEqual({
+ kind: 'unknown',
+ token: 'totally_made_up',
+ suggestion: undefined,
+ });
+ });
+
+ it('every suggestion resolves to a real token', () => {
+ for (const [from, to] of Object.entries(CONTEXT_TOKEN_SUGGESTIONS)) {
+ expect(isContextToken(to), `${from} → ${to}`).toBe(true);
+ // A suggestion key must itself be invalid, or the mapping is incoherent.
+ expect(isKnownFilterToken(from), from).toBe(false);
+ }
+ });
+});
diff --git a/packages/spec/src/data/context-tokens.zod.ts b/packages/spec/src/data/context-tokens.zod.ts
new file mode 100644
index 0000000000..1873d03787
--- /dev/null
+++ b/packages/spec/src/data/context-tokens.zod.ts
@@ -0,0 +1,188 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+import { z } from 'zod';
+import { DATE_MACRO_WRAPPED_RE, isDateMacroToken } from './date-macros.zod.js';
+
+/**
+ * Context Tokens — the declarative placeholders that resolve against the
+ * **caller's session** (who am I, which org am I in) rather than the clock.
+ *
+ * # 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 **client-side** by
+ * `resolveContextTokens()` in `@object-ui/core` immediately before the
+ * filter is handed to the data source. The data engine only ever sees
+ * concrete ids, never `{tokens}`.
+ *
+ * # 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. 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
+ *
+ * Filter values on every surface that resolves placeholders — object list
+ * views, dashboard widgets, reports, SDUI page components. 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.
+ *
+ * # Out of scope
+ *
+ * - `current_user.*` RLS expressions — see `@objectstack/plugin-security`.
+ * - `{date-macros}` — the clock-based sibling; see `./date-macros.zod.ts`.
+ * - `titleFormat` field interpolation (`{user_id}` etc.) — that substitutes
+ * *record fields*, an unrelated mechanism that happens to share braces.
+ */
+
+/**
+ * The complete set of session-scoped filter tokens.
+ *
+ * Deliberately tiny. Every addition is a platform contract that three
+ * surfaces and one lint pass must honour, so a token earns its place only
+ * when it cannot be expressed as a plain field filter.
+ */
+export const CONTEXT_TOKENS = [
+ 'current_user_id',
+ 'current_org_id',
+] as const;
+
+export type ContextToken = (typeof CONTEXT_TOKENS)[number];
+
+/**
+ * Test helper: is `token` (without braces) a recognised context token?
+ * Use in lint passes and AI-side validators.
+ */
+export function isContextToken(token: string): boolean {
+ return (CONTEXT_TOKENS as readonly string[]).includes(token);
+}
+
+/**
+ * Match the **wrapped** form a filter author actually writes —
+ * `{current_user_id}` or `${current_user_id}`. Shares the date-macro
+ * grammar so a single walk over a filter tree can classify both.
+ */
+export const CONTEXT_TOKEN_WRAPPED_RE = DATE_MACRO_WRAPPED_RE;
+
+/** Strict zod schema for the **token name** (the bit inside `{}`). */
+export const ContextTokenSchema = z
+ .string()
+ .refine(isContextToken, {
+ message: `Unknown context token. Must be one of: ${CONTEXT_TOKENS.join(', ')}`,
+ });
+
+/**
+ * Zod schema for a complete placeholder string (`'{current_user_id}'`).
+ * Use as an opt-in refinement on filter values to fail loudly on unknown
+ * tokens instead of silently sending them to SQL.
+ */
+export const ContextTokenPlaceholderSchema = z
+ .string()
+ .refine(
+ (s) => {
+ const m = s.match(CONTEXT_TOKEN_WRAPPED_RE);
+ return !!m && isContextToken(m[1]);
+ },
+ { message: 'Not a recognised {context-token} placeholder' },
+ );
+
+/**
+ * Description table — feeds skill / docs generation. Pure data,
+ * intentionally not exported through any zod schema.
+ */
+export const CONTEXT_TOKEN_DESCRIPTIONS: Record = {
+ current_user_id: "The signed-in user's id (`sys_user.id`).",
+ current_org_id: 'The active organization id.',
+};
+
+/**
+ * Near-miss spellings → the token the author meant.
+ *
+ * Every entry here is a real mistake observed in authored metadata, and
+ * each one used to fail the same way: the literal string reached SQL,
+ * matched nothing, and the surface rendered an empty result with no error
+ * anywhere. Silent-zero is especially costly for AI authors, which read a
+ * `0` as a successful query and build on it.
+ *
+ * `current_user` and `user_id` dominate because both are correct spellings
+ * *somewhere else* in the platform — `current_user.id` is the RLS root, and
+ * `{user_id}` is valid `titleFormat` field interpolation. Lint quotes the
+ * suggestion so the author is corrected at authoring time, where an AI can
+ * still see and act on it.
+ */
+export const CONTEXT_TOKEN_SUGGESTIONS: Readonly> = {
+ current_user: 'current_user_id',
+ current_user_email: 'current_user_id',
+ user_id: 'current_user_id',
+ userid: 'current_user_id',
+ me: 'current_user_id',
+ current_organization_id: 'current_org_id',
+ org_id: 'current_org_id',
+ organization_id: 'current_org_id',
+ current_tenant_id: 'current_org_id',
+};
+
+/**
+ * Is `token` (without braces) resolvable inside a **filter value**?
+ *
+ * The union of the two filter vocabularies: date macros and context
+ * tokens. This is the predicate a lint pass wants — anything else in a
+ * filter value is passed through to the data engine verbatim and will
+ * match nothing.
+ *
+ * Note this is deliberately narrower than what navigation accepts:
+ * `recordId` / `params` also resolve `AppContextSelector` ids, which are
+ * meaningless in a filter.
+ */
+export function isKnownFilterToken(token: string): boolean {
+ return isContextToken(token) || isDateMacroToken(token);
+}
+
+/**
+ * Classify a filter *value*. Returns `null` when the value is not a
+ * placeholder at all (a plain literal), so callers can walk a whole filter
+ * tree and only act on the strings that look like placeholders.
+ *
+ * Distinguishing `unknown` from "not a placeholder" is what lets lint stay
+ * quiet about ordinary values while still catching `{current_user}`.
+ */
+export function classifyFilterToken(
+ value: unknown,
+):
+ | { kind: 'context'; token: ContextToken }
+ | { kind: 'date-macro'; token: string }
+ | { kind: 'unknown'; token: string; suggestion?: ContextToken }
+ | null {
+ if (typeof value !== 'string') return null;
+ const m = value.match(CONTEXT_TOKEN_WRAPPED_RE);
+ if (!m) return null;
+ const token = m[1];
+ if (isContextToken(token)) return { kind: 'context', token: token as ContextToken };
+ if (isDateMacroToken(token)) return { kind: 'date-macro', token };
+ return { kind: 'unknown', token, suggestion: CONTEXT_TOKEN_SUGGESTIONS[token.toLowerCase()] };
+}
diff --git a/packages/spec/src/data/index.ts b/packages/spec/src/data/index.ts
index d97a763f13..a3e49cdebd 100644
--- a/packages/spec/src/data/index.ts
+++ b/packages/spec/src/data/index.ts
@@ -3,6 +3,10 @@
export * from './query.zod';
export * from './filter.zod';
export * from './date-macros.zod';
+// Session-scoped filter placeholders ({current_user_id} / {current_org_id}) —
+// the sibling vocabulary to date macros. Presentation scope only; RLS is the
+// enforcement boundary. See context-tokens.zod.ts.
+export * from './context-tokens.zod';
export * from './object.zod';
// API-method derivation — the single source of truth turning an object's
// `enable.apiMethods` whitelist into its effective operation set (#3391).
diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts
index e12a2ccedd..6e391bfc97 100644
--- a/packages/spec/src/ui/view.zod.ts
+++ b/packages/spec/src/ui/view.zod.ts
@@ -1096,7 +1096,7 @@ export const ObjectListViewSchema = lazySchema(() =>
* form: { type: "simple", fields: ["name"] },
* listViews: {
* "all": { label: "All", filter: [] },
- * "my": { label: "Mine", filter: [["owner", "=", "{user_id}"]] }
+ * "my": { label: "Mine", filter: [["owner", "=", "{current_user_id}"]] }
* }
* }
*/
diff --git a/skills/objectstack-ui/SKILL.md b/skills/objectstack-ui/SKILL.md
index e01c40472e..7c459f7f5b 100644
--- a/skills/objectstack-ui/SKILL.md
+++ b/skills/objectstack-ui/SKILL.md
@@ -1500,6 +1500,58 @@ Both `{token}` and `${token}` forms are accepted.
semantics (`'{today}-{tomorrow}'` is fine; `{today_or_tomorrow}` is
not — there is no such token).
+## Context Tokens — `{current_user_id}` / `{current_org_id}`
+
+The session-scoped sibling of date macros, resolved by the same client pass
+(`resolveFilterPlaceholders` in `@object-ui/core`). Contract: `CONTEXT_TOKENS`
+in `@objectstack/spec/data`. These are the **only two** tokens that resolve
+inside a filter value:
+
+| Token | Resolves to |
+|:--|:--|
+| `{current_user_id}` | the signed-in user's id (`sys_user.id`) |
+| `{current_org_id}` | the active organization id |
+
+They work in **every** filter value — list-view filters, dashboard widget
+filters, report `runtimeFilter`, dataset filters, SDUI component filters, and
+nav-item `filters` — in both authoring shapes:
+
+```typescript
+// Widget filter (object shape)
+filter: { owner_id: '{current_user_id}', created_at: { $gte: '{week_start}' } }
+
+// List view (array shape)
+filter: [{ field: 'owner', operator: 'equals', value: '{current_user_id}' }]
+```
+
+### DO / DON'T
+
+* **DO** use exactly these spellings. Three near-misses are common because
+ each is a correct spelling *somewhere else* in the platform — all three
+ resolve to nothing in a filter:
+
+ | You might write | Because | Correct token |
+ |:--|:--|:--|
+ | `{current_user}` | `current_user.id` is the RLS expression root | `{current_user_id}` |
+ | `{user_id}` | `{user_id}` is valid `titleFormat` field interpolation | `{current_user_id}` |
+ | `{organization_id}` | that is the real column name | `{current_org_id}` |
+
+* **DO** remember these are **presentation scope, not security**. They decide
+ what a surface *shows*; RLS decides what a caller may *read*. Removing a
+ `{current_user_id}` filter widens a view — it must never be the thing
+ standing between a user and someone else's data.
+* **DON'T** embed a token in a larger string (`'user-{current_user_id}'`).
+ Ids are opaque; only whole-value placeholders are substituted.
+* **DON'T** use `AppContextSelector` ids (e.g. `{active_package}`) in a
+ filter. Those resolve in navigation `recordId` / `params` only — filters are
+ not evaluated with the sidebar's selector state.
+
+`os validate` fails the build on an unresolvable placeholder in any filter
+(rule `filter-token-unknown`) and names the token it thinks you meant. That
+gate exists because the runtime failure is silent: an unresolved token reaches
+SQL as a literal, matches nothing, and the widget renders `0` —
+indistinguishable from a genuine zero.
+
---
## Analytics Cubes — Semantic Layer