From 6d4e1a8d25328192ca2fd63a432eb6aff6161871 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:42:35 +0800 Subject: [PATCH 1/2] feat(spec,lint): freeze the {current_user_id} filter vocabulary and gate unresolvable placeholders (#3574) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 one client resolver; `{current_user_id}` had only prose in an `app.zod.ts` JSDoc and three ad-hoc client implementations, each handling one surface's filter shape. Nothing could tell an author their token was wrong. - 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. Documents what the tokens are NOT: presentation scope, never an access boundary — that is RLS, which uses the unrelated `current_user.id` expression root. - lint: new `validateFilterTokens` (rule `filter-token-unknown`, error). Walks filter / filters / runtimeFilter subtrees across dashboards, objects, views, reports, datasets, pages and apps. 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 stay out of scope: they resolve AppContextSelector ids, meaningless in a filter. - cli: the gate runs in `os validate` and `os compile`. Error rather than 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 documenting `{user_id}`, which resolves nowhere, and adds the Context Tokens reference to the objectstack-ui skill. Runtime resolution ships in objectui (@object-ui/core resolveFilterPlaceholders). Co-Authored-By: Claude --- .changeset/filter-context-tokens-gate.md | 47 ++++ .../docs/references/data/context-tokens.mdx | 111 ++++++++ content/docs/references/data/index.mdx | 1 + content/docs/references/data/meta.json | 1 + packages/cli/src/commands/compile.ts | 26 ++ packages/cli/src/commands/validate.ts | 31 +++ packages/lint/src/index.ts | 3 + .../lint/src/validate-filter-tokens.test.ts | 188 +++++++++++++ packages/lint/src/validate-filter-tokens.ts | 249 ++++++++++++++++++ packages/spec/json-schema.manifest.json | 2 + packages/spec/src/data/context-tokens.test.ts | 118 +++++++++ packages/spec/src/data/context-tokens.zod.ts | 188 +++++++++++++ packages/spec/src/data/index.ts | 4 + packages/spec/src/ui/view.zod.ts | 2 +- skills/objectstack-ui/SKILL.md | 52 ++++ 15 files changed, 1022 insertions(+), 1 deletion(-) create mode 100644 .changeset/filter-context-tokens-gate.md create mode 100644 content/docs/references/data/context-tokens.mdx create mode 100644 packages/lint/src/validate-filter-tokens.test.ts create mode 100644 packages/lint/src/validate-filter-tokens.ts create mode 100644 packages/spec/src/data/context-tokens.test.ts create mode 100644 packages/spec/src/data/context-tokens.zod.ts 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/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 From 16b532fb2bf9e2e68b63c1be4e15a6c3d05bc347 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:58:54 +0800 Subject: [PATCH 2/2] chore(spec): regenerate api-surface snapshot for the context-token exports (#3574) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The public-API ratchet flagged 10 additions, 0 breaking — the new context-token vocabulary. Snapshot regenerated with `pnpm --filter @objectstack/spec gen:api-surface`. Co-Authored-By: Claude --- packages/spec/api-surface.json | 10 ++++++++++ 1 file changed, 10 insertions(+) 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)",