Skip to content

Commit 6d4e1a8

Browse files
os-zhuangclaude
andcommitted
feat(spec,lint): freeze the {current_user_id} filter vocabulary and gate 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 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 <noreply@anthropic.com>
1 parent 8891f93 commit 6d4e1a8

15 files changed

Lines changed: 1022 additions & 1 deletion

File tree

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
---
2+
"@objectstack/spec": patch
3+
"@objectstack/lint": patch
4+
"@objectstack/cli": patch
5+
---
6+
7+
feat(spec,lint): freeze the `{current_user_id}` filter vocabulary and fail the build on unresolvable placeholders (#3574)
8+
9+
A dashboard widget filtered on `{current_user}` rendered `0`. Not an error — a
10+
zero, indistinguishable from a metric that is legitimately empty, with nothing
11+
in the console or the server log. `service_dashboard.my_open_cases_by_priority`
12+
in the HotCRM template had shipped broken this way since the day it was
13+
written.
14+
15+
The token had never been part of the contract. Date macros were frozen in
16+
`date-macros.zod.ts` with a spec vocabulary, a lint-usable predicate, and a
17+
single client resolver; `{current_user_id}` had only prose in an `app.zod.ts`
18+
JSDoc and three ad-hoc client implementations that each handled one surface's
19+
filter shape. Nothing could tell an author their token was wrong.
20+
21+
- **`@objectstack/spec`** — new `data/context-tokens.zod.ts` freezing
22+
`CONTEXT_TOKENS` (`current_user_id`, `current_org_id`) as the sibling of
23+
`DATE_MACRO_TOKENS`, with `isContextToken` / `isKnownFilterToken` /
24+
`classifyFilterToken` and a `CONTEXT_TOKEN_SUGGESTIONS` near-miss table. The
25+
module documents what the tokens are *not*: presentation scope, never an
26+
access boundary — that is RLS, which uses the unrelated `current_user.id`
27+
expression root.
28+
- **`@objectstack/lint`** — new `validateFilterTokens` (rule
29+
`filter-token-unknown`, severity `error`). It walks `filter` / `filters` /
30+
`runtimeFilter` subtrees across dashboards, objects, views, reports,
31+
datasets, pages and apps, and reports any placeholder that resolves in
32+
neither vocabulary. It scans for filter *keys* rather than enumerating known
33+
surfaces, so a new surface following the convention is covered the day it
34+
ships — enumerating surfaces is how the dashboard was missed in the first
35+
place. Navigation `recordId` / `params` are deliberately out of scope: they
36+
resolve `AppContextSelector` ids, which are meaningless in a filter.
37+
- **`@objectstack/cli`** — the gate runs in `os validate` and `os compile`.
38+
39+
It is an error rather than a warning because of who authors this metadata. An
40+
AI reads a query returning `0` as a correct answer and builds on it; its
41+
correction loop is author → validate → fix, so a diagnostic only reaches it if
42+
it can fail the build. The three spellings the suggestion table covers —
43+
`{current_user}`, `{user_id}`, `{organization_id}` — are each correct
44+
*somewhere else* in the platform, which is exactly why authors reach for them.
45+
46+
Also fixes a `ViewSchema` JSDoc example that documented `{user_id}`, a token
47+
that resolves nowhere.
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
---
2+
title: Context Tokens
3+
description: Context Tokens protocol schemas
4+
---
5+
6+
{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}
7+
8+
Context Tokens — the declarative placeholders that resolve against the
9+
10+
**caller's session** (who am I, which org am I in) rather than the clock.
11+
12+
# Why this lives in `spec`
13+
14+
These are the sibling vocabulary to `\{date-macros\}`. Filter values in
15+
16+
dashboards, views, reports and pages travel as JSON, so a user-scoped
17+
18+
slice cannot call `currentUser().id` inline — it writes a placeholder:
19+
20+
\{ owner_id: '\{current_user_id\}' \}
21+
22+
[\{ field: 'owner', operator: 'equals', value: '\{current_user_id\}' \}]
23+
24+
Like date macros, the placeholders are expanded **client-side** by
25+
26+
`resolveContextTokens()` in `@object-ui/core` immediately before the
27+
28+
filter is handed to the data source. The data engine only ever sees
29+
30+
concrete ids, never `\{tokens\}`.
31+
32+
# Presentation scope, NOT a security boundary
33+
34+
This is the single most important thing to understand about these
35+
36+
tokens. `\{current_user_id\}` scopes what a surface *shows*; it does not
37+
38+
decide what a caller is *allowed* to read. Enforcement is RLS, which
39+
40+
uses a different and genuinely server-side vocabulary rooted at
41+
42+
`current_user` (`owner_id = current_user.id`, compiled by
43+
44+
`@objectstack/plugin-security`'s RLS compiler).
45+
46+
The two look alike and are easy to confuse, so keep them straight:
47+
48+
| | `\{current_user_id\}` | `current_user.id` |
49+
50+
|---|---|---|
51+
52+
| Where | filter values (JSON) | RLS `using` expressions |
53+
54+
| Resolved | client-side, before the query | server-side, during the query |
55+
56+
| Purpose | presentation scope | access enforcement |
57+
58+
| Bypassable | yes — it's just a filter | no |
59+
60+
Never reach for a context token to keep a user away from data. Removing
61+
62+
a `\{current_user_id\}` filter widens a *view*; it must never widen
63+
64+
*access*.
65+
66+
# Where the tokens are honoured
67+
68+
Filter values on every surface that resolves placeholders — object list
69+
70+
views, dashboard widgets, reports, SDUI page components. Navigation
71+
72+
(`recordId` / `params`) additionally resolves `AppContextSelector` ids
73+
74+
such as `\{active_package\}`; those are nav-only and are NOT valid inside
75+
76+
filter values, because filters are not evaluated with the sidebar's
77+
78+
selector state.
79+
80+
# Out of scope
81+
82+
- `current_user.*` RLS expressions — see `@objectstack/plugin-security`.
83+
84+
- `\{date-macros\}` — the clock-based sibling; see `./date-macros.zod.ts`.
85+
86+
- `titleFormat` field interpolation (`\{user_id\}` etc.) — that substitutes
87+
88+
*record fields*, an unrelated mechanism that happens to share braces.
89+
90+
<Callout type="info">
91+
**Source:** `packages/spec/src/data/context-tokens.zod.ts`
92+
</Callout>
93+
94+
## TypeScript Usage
95+
96+
```typescript
97+
import { ContextToken, ContextTokenPlaceholder } from '@objectstack/spec/data';
98+
import type { ContextToken, ContextTokenPlaceholder } from '@objectstack/spec/data';
99+
100+
// Validate data
101+
const result = ContextToken.parse(data);
102+
```
103+
104+
---
105+
106+
107+
---
108+
109+
110+
---
111+

content/docs/references/data/index.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ This section contains all protocol schemas for the data layer of ObjectStack.
77

88
<Cards>
99
<Card href="/docs/references/data/analytics" title="Analytics" description="Source: packages/spec/src/data/analytics.zod.ts" />
10+
<Card href="/docs/references/data/context-tokens" title="Context Tokens" description="Source: packages/spec/src/data/context-tokens.zod.ts" />
1011
<Card href="/docs/references/data/data-engine" title="Data Engine" description="Source: packages/spec/src/data/data-engine.zod.ts" />
1112
<Card href="/docs/references/data/datasource" title="Datasource" description="Source: packages/spec/src/data/datasource.zod.ts" />
1213
<Card href="/docs/references/data/date-macros" title="Date Macros" description="Source: packages/spec/src/data/date-macros.zod.ts" />

content/docs/references/data/meta.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
"seed",
2828
"seed-loader",
2929
"---More---",
30+
"context-tokens",
3031
"field-value"
3132
]
3233
}

packages/cli/src/commands/compile.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { validateStackExpressions } from '@objectstack/lint';
1212
import { validateVisibilityPredicates } from '@objectstack/lint';
1313
import { validateWidgetBindings } from '@objectstack/lint';
1414
import { validateDashboardActionRefs } from '@objectstack/lint';
15+
import { validateFilterTokens } from '@objectstack/lint';
1516
import { validateResponsiveStyles } from '@objectstack/lint';
1617
import { validateSecurityPosture, validateOrgAxisRedLines, buildAccessMatrix, diffAccessMatrix } from '@objectstack/lint';
1718
import { validateReadonlyFlowWrites } from '@objectstack/lint';
@@ -293,6 +294,31 @@ export default class Compile extends Command {
293294
}
294295
}
295296

297+
// 3a-ter. Filter placeholder resolvability (#3574). A filter value that
298+
// resolves in neither vocabulary — `{current_user}` instead of
299+
// `{current_user_id}` — reaches the data engine as a literal and
300+
// matches nothing, so the surface renders empty with no error. That
301+
// silent zero is indistinguishable from a genuine zero at review
302+
// time, and an AI author reads it as a successful query. Fails the
303+
// build because authoring time is the last point the author sees it.
304+
if (!flags.json) printStep('Checking filter placeholders (#3574)...');
305+
const filterTokenFindings = validateFilterTokens(result.data as Record<string, unknown>);
306+
const filterTokenErrors = filterTokenFindings.filter((f) => f.severity === 'error');
307+
if (filterTokenErrors.length > 0) {
308+
if (flags.json) {
309+
console.log(JSON.stringify({ success: false, error: 'filter placeholder validation failed', issues: filterTokenErrors }));
310+
this.exit(1);
311+
}
312+
console.log('');
313+
printError(`Filter placeholder check failed (${filterTokenErrors.length} issue${filterTokenErrors.length > 1 ? 's' : ''})`);
314+
for (const f of filterTokenErrors.slice(0, 50)) {
315+
console.log(` • ${f.where}: ${f.message}`);
316+
console.log(chalk.dim(` ${f.hint}`));
317+
console.log(chalk.dim(` rule: ${f.rule} at ${f.path}`));
318+
}
319+
this.exit(1);
320+
}
321+
296322
// 3c. SDUI scoped-styling correctness (ADR-0065) — a styled node without
297323
// an `id` drops its CSS silently; Tailwind-in-className does nothing
298324
// from metadata. Same bar for hand-authored and AI-generated pages

packages/cli/src/commands/validate.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { validateListViewMode } from '@objectstack/lint';
1313
import { validateViewContainers } from '@objectstack/lint';
1414
import { validateWidgetBindings } from '@objectstack/lint';
1515
import { validateDashboardActionRefs } from '@objectstack/lint';
16+
import { validateFilterTokens } from '@objectstack/lint';
1617
import { validateResponsiveStyles } from '@objectstack/lint';
1718
import { validateJsxPages, validateReactPages, validateReactPageProps, validatePageSourceStyling } from '@objectstack/lint';
1819
import { validateCapabilityReferences } from '@objectstack/lint';
@@ -250,6 +251,36 @@ export default class Validate extends Command {
250251
}
251252
}
252253

254+
// 3a-ter. Filter placeholder resolvability (#3574) — a filter value like
255+
// `{current_user}` resolves in no vocabulary, reaches the data engine
256+
// as a literal, matches nothing, and the surface renders empty with
257+
// no error anywhere. Silent-zero is indistinguishable from a genuine
258+
// zero, so it survives human review; and an AI author reads the 0 as
259+
// a successful query. Caught here because authoring time is the only
260+
// place the diagnostic can still reach the author.
261+
if (!flags.json) printStep('Checking filter placeholders (#3574)...');
262+
const filterTokenFindings = validateFilterTokens(result.data as Record<string, unknown>);
263+
const filterTokenErrors = filterTokenFindings.filter((f) => f.severity === 'error');
264+
if (filterTokenErrors.length > 0) {
265+
if (flags.json) {
266+
console.log(JSON.stringify({
267+
valid: false,
268+
errors: filterTokenErrors,
269+
warnings: [...widgetWarnings, ...actionRefWarnings],
270+
duration: timer.elapsed(),
271+
}, null, 2));
272+
this.exit(1);
273+
}
274+
console.log('');
275+
printError(`Filter placeholder check failed (${filterTokenErrors.length} issue${filterTokenErrors.length > 1 ? 's' : ''})`);
276+
for (const f of filterTokenErrors.slice(0, 50)) {
277+
console.log(` • ${f.where}: ${f.message}`);
278+
console.log(chalk.dim(` ${f.hint}`));
279+
console.log(chalk.dim(` rule: ${f.rule} at ${f.path}`));
280+
}
281+
this.exit(1);
282+
}
283+
253284
// 3b. SDUI scoped-styling correctness (ADR-0065) — a styled node's
254285
// responsiveStyles must be scopable (needs an `id`), reference real
255286
// CSS properties + design tokens, and carry a `large` base;

packages/lint/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,4 +178,7 @@ export type {
178178
DashboardActionRefSeverity,
179179
} from './validate-dashboard-action-refs.js';
180180

181+
export { validateFilterTokens, FILTER_TOKEN_UNKNOWN } from './validate-filter-tokens.js';
182+
export type { FilterTokenFinding, FilterTokenSeverity } from './validate-filter-tokens.js';
183+
181184
export { buildAccessMatrix, diffAccessMatrix } from './build-access-matrix.js';

0 commit comments

Comments
 (0)