Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions .changeset/filter-context-tokens-gate.md
Original file line number Diff line number Diff line change
@@ -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.
111 changes: 111 additions & 0 deletions content/docs/references/data/context-tokens.mdx
Original file line number Diff line number Diff line change
@@ -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.

<Callout type="info">
**Source:** `packages/spec/src/data/context-tokens.zod.ts`
</Callout>

## 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);
```

---


---


---

1 change: 1 addition & 0 deletions content/docs/references/data/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ This section contains all protocol schemas for the data layer of ObjectStack.

<Cards>
<Card href="/docs/references/data/analytics" title="Analytics" description="Source: packages/spec/src/data/analytics.zod.ts" />
<Card href="/docs/references/data/context-tokens" title="Context Tokens" description="Source: packages/spec/src/data/context-tokens.zod.ts" />
<Card href="/docs/references/data/data-engine" title="Data Engine" description="Source: packages/spec/src/data/data-engine.zod.ts" />
<Card href="/docs/references/data/datasource" title="Datasource" description="Source: packages/spec/src/data/datasource.zod.ts" />
<Card href="/docs/references/data/date-macros" title="Date Macros" description="Source: packages/spec/src/data/date-macros.zod.ts" />
Expand Down
1 change: 1 addition & 0 deletions content/docs/references/data/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"seed",
"seed-loader",
"---More---",
"context-tokens",
"field-value"
]
}
26 changes: 26 additions & 0 deletions packages/cli/src/commands/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<string, unknown>);
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
Expand Down
31 changes: 31 additions & 0 deletions packages/cli/src/commands/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<string, unknown>);
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;
Expand Down
3 changes: 3 additions & 0 deletions packages/lint/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Loading