Skip to content

fix(spec,core): recognise a filter placeholder by INTENT — {TODAY()} refuses loudly instead of comparing as a literal (#5586) - #6680

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-5586-filter-token-grammar
Aug 8, 2026
Merged

fix(spec,core): recognise a filter placeholder by INTENT — {TODAY()} refuses loudly instead of comparing as a literal (#5586)#6680
os-zhuang merged 2 commits into
mainfrom
claude/issue-5586-filter-token-grammar

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes #5586

What was wrong

UnknownFilterTokenError's coverage stopped at the token-NAME grammar. Recognition ran through /^\$?\{([a-zA-Z0-9_]+)\}$/, so any placeholder carrying a non-word character classified as null — "not a placeholder at all" — and was handed to the driver verbatim to be compared as a literal string. That is exactly the silent-wrong-result mode the diagnostic exists to abolish.

The failure was inverted against the author: misspelling {today} as {TODAY} was refused by name, while misspelling it as {TODAY()} returned rows — the wrong ones.

Premise verification against origin/main (0e043d8)

The issue is a lead, so both halves of its evidence were re-measured before any edit. Both hold.

classifyFilterToken (measured, packages/spec):

  "{today}"            => {"kind":"date-macro","token":"today"}
  "{TODAY}"            => {"kind":"unknown","token":"TODAY"}
  "{TODAY()}"          => null
  "{current-user-id}"  => null
  "{30 days ago}"      => null
  "{user.id}"          => null
  "${TODAY()}"         => null

Read path, measured through a real ObjectQL.find() with a recording driver:

  "{TODAY}"            -> THROW UnknownFilterTokenError code=FILTER_TOKEN_UNKNOWN status=400 token="TODAY"
  "{TODAY()}"          -> NO THROW — driver saw {"close_date":{"$lt":"{TODAY()}"}}
  "{current-user-id}"  -> NO THROW — driver saw {"close_date":{"$lt":"{current-user-id}"}}
  "{30 days ago}"      -> NO THROW — driver saw {"close_date":{"$lt":"{30 days ago}"}}
  "{user.id}"          -> NO THROW — driver saw {"close_date":{"$lt":"{user.id}"}}

The issue's row-count evidence follows from the last four lines: on a string comparison '2026-…' < '{' in lexicographic order, so a < window silently gained every future-dated row.

⚠️ Scope correction — the regex is in packages/spec, not packages/core

The dispatch scoped this to packages/core/src/utils/filter-tokens.ts. That file does not contain the grammar. classifyFilterToken and the regex live in packages/spec/src/data/context-tokens.zod.ts (line 104, aliasing DATE_MACRO_WRAPPED_RE at packages/spec/src/data/date-macros.zod.ts:203); packages/core only consumes the classifier. Fixing it in core would have meant a lenient re-implementation in a consumer — the contract-first anti-pattern — and would have left the author-time lint pass blind. So the fix landed at the producer, which is also what the dispatch's own mechanism assumption described ("widening the recognition regex in classifyFilterToken").

The two ⛔ files reserved for #5282 (packages/core/src/index.ts, packages/core/src/kernel*.ts) are untouched — verified in the diff.

The change

Recognition and vocabulary are now two named grammars rather than one, which is what route 1's 宽进严出 actually is:

  • FILTER_TOKEN_WRAPPED_RE = /^\$?\{([^{}]+)\}$/did the author mean a placeholder? Anchored over the whole value, matching the existing anchoring style, $ prefix included.
  • CONTEXT_TOKEN_WRAPPED_RE / DATE_MACRO_WRAPPED_RE — unchanged. They stay the well-formed token NAME grammar, which is what ContextTokenPlaceholderSchema / DateMacroPlaceholderSchema and scripts/analytics-reconcile/macros.ts actually want. Both schemas re-check membership after matching, so their verdicts are identical either way; widening them would have blurred a distinction the code depends on.

classifyFilterToken now recognises through the wide grammar and reports the raw text between the braces as the token, so the error names exactly what the author wrote.

No escape hatch, and why

Per the ruling, none ships. The premise measurement found zero legitimate consumers comparing a literal {…}-wrapped string through a filter:

surface measured method wide-shape filter comparands found
all structured metadata (1927 parsed .json / .yml / .yaml) walked the same filter / filters / runtimeFilter subtrees the lint rule walks 0 (and 0 narrow ones too)
packages/**, apps/, examples/, scripts/ TypeScript brace-wrapped string literals in filter-ish positions 4 non-comment hits, all flow templates ({record.id} x3, {currentTask.id})
showcase / CRM / todo example apps full objectstack build, which runs validateFilterTokens as a gating rule 0 new filter-token findings

An escape syntax is a public micro-contract with no measured pull (创业阶段聚焦原则); it can be added the day a real consumer appears.

Consumers of classifyFilterToken — declared per consumer

Enumerated by grep over origin/main. Nothing is silent here.

consumer verdict
packages/core/src/utils/filter-tokens.tshasFilterToken + resolveFilterTokens Changed (intended). The wide shapes now reach the kind === 'unknown' branch and throw. This is the fix; ObjectQL read + write paths and the analytics dataset executor all inherit it through this one function.
packages/lint/src/validate-filter-tokens.tsfilter-token-unknown Changed (intended). The same shapes are now reported at authoring time, where an AI author can still act on them. Measured impact on real metadata: 0 new findings (see table above).
packages/services/service-automation/src/builtin/template.tsinterpolateFilter Already conforming — not affected. It does not call classifyFilterToken. It uses its own wide regex /^\{([^{}]+)\}$/ and then consults isKnownFilterToken(name) — the token-name vocabulary predicate, which this PR does not touch. So {record.id} / {TODAY() + 30} still resolve from flow variables before the filter reaches ObjectQL, and an unresolvable one still fails the step through the node's collapse guard. Notably this surface had already adopted the wide recognition shape for the one position where the two brace dialects meet (#3810); this PR brings the platform diagnostic into line with it.
packages/spec/src/data/context-tokens.zod.tsContextTokenPlaceholderSchema Not affected. Uses the narrow name grammar and re-checks membership; verdict unchanged for every shape.
scripts/analytics-reconcile/macros.ts Not affected. Consumes DATE_MACRO_WRAPPED_RE directly, which is unchanged.

Two independent confirmations that the lint rule cannot reach flow filters, so no flow template becomes a build error: structurally, flows is a top-level stack key and validateFilterTokens only scans dashboards/objects/views/reports/datasets/pages/apps; and empirically, examples/app-showcase/src/automation/flows/index.ts:111 spells filter: { id: '{recordId}' }, which the narrow grammar already classified as unknown — CI is green today, so that walk demonstrably never reaches it.

Edge shapes — decided, not emergent

Recognition is one brace pair around the whole value. Each of these is pinned by a test rather than left to fall out of the regex:

value verdict why
{TODAY()} {current-user-id} {30 days ago} {user.id} throws UnknownFilterTokenError the fix
${TODAY()} throws, token TODAY() the $ prefix variant is recognised identically
{today} {current_user_id} resolves unchanged
{TODAY} throws, token TODAY regression control: the shape that already worked must keep working
a{b}c literal, reaches the driver merely contains braces — this is what keeps prose and titleFormat-style strings out of the rule
{a}{b} {{x}} {a}b x{a} literal not one wrapped token
{} literal there is no token to name in a diagnostic
{ current_user_id } throws, token " current_user_id " padding is not trimmed. Tolerating it here would make a spelling legal on this surface and illegal on every other one that spells the vocabulary out — a lenient consumer is precisely where AI-authored metadata errors hide.

Reverse verification

Direction predicted before running: red, and red only on the wide-shape assertions — the controls ({today}, {TODAY}, and every literal-passthrough shape) must not move, because the regex swap cannot reach them.

Reverting context-tokens.zod.ts to origin/main and rebuilding gave exactly that:

### SPEC (reverted) ###      Tests  6 failed | 19 passed (25)
### CORE (reverted) ###      Tests  5 failed | 36 passed (41)
### OBJECTQL (reverted) ###  Tests  6 failed | 21 passed (27)

All 17 failures are the wide-shape pins; every control assertion stayed green in all three suites.

Tests

Rejection-class cases assert the error identity — class, name, code, status, .token, and the offending token echoed in the message — never a bare toThrow(). The resolver already throws for other reasons, so a throw-only assertion could not separate "refused with the right envelope" from "blew up elsewhere".

  • packages/spec/src/data/context-tokens.test.ts — classification table for the four wide shapes, the $ variant, padding, and six literal-passthrough shapes.
  • packages/core/src/utils/filter-tokens.test.ts — full UnknownFilterTokenError envelope per shape; {today} still resolves; {TODAY} still refuses; literals still return by reference.
  • packages/objectql/src/engine-filter-tokens.test.ts — the read path where the bypass was measured: find() refuses and driver.find is never called; delete(multi:true) refuses on the write path too (verb parity, Flow node filters silently blank date macros: the template engine consumes {…} before the query engine sees it #3810 — a widened delete is the most expensive place for a silent literal).

No new fake engine was introduced; the existing recording driver in that file is reused.

Verification

spec                 343 files, 8831 tests   passed
core                  27 files,  511 tests   passed
objectql             149 files, 2531 tests   passed
lint                  62 files, 1599 tests   passed
service-automation    70 files,  849 tests   passed
service-analytics     69 files, 1411 tests   passed
plugin-approvals      19 files,  446 tests   passed

pnpm lint clean; tsc --noEmit clean for spec, and turbo typecheck clean for spec/core/objectql/lint/service-automation, the example apps and the downstream consumer contract. All 50 check:* steps enumerated from .github/workflows/lint.yml pass, plus check:type-check-debt ("none above its recorded number"). api-surface/data.json is regenerated for the one added export (FILTER_TOKEN_WRAPPED_RE, 0 breaking).

Note for client-side consumers

@object-ui/core's resolveDateMacros / resolveContextTokens live in the objectui repo and are unchanged. A client that forwards {TODAY()} now gets a 400 with a fixable message instead of a quietly wrong grid — the intended direction, and the reason this is stated in both directions in the changeset.


Generated by Claude Code

claude added 2 commits August 8, 2026 09:30
…wrapped value refuses loudly (#5586)

Recognition used the token-NAME grammar, so a placeholder carrying a
non-word character ({TODAY()}, {current-user-id}, {30 days ago},
{user.id}) classified as 'not a placeholder' and reached the driver to be
compared as a literal string — the silent-wrong-rows mode the diagnostic
exists to abolish.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MwoubC3jL271FYt9rGXwxb
@vercel

vercel Bot commented Aug 8, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 8, 2026 10:29am

Request Review

@github-actions github-actions Bot added the size/m label Aug 8, 2026
@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/core, @objectstack/spec.

119 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/actions-as-tools.mdx (via @objectstack/core)
  • content/docs/ai/agents.mdx (via @objectstack/spec)
  • content/docs/ai/knowledge-rag.mdx (via @objectstack/core)
  • content/docs/ai/natural-language-queries.mdx (via @objectstack/core)
  • content/docs/ai/skills-reference.mdx (via @objectstack/spec)
  • content/docs/ai/skills.mdx (via @objectstack/spec)
  • content/docs/api/client-sdk.mdx (via @objectstack/spec)
  • content/docs/api/environment-routing.mdx (via @objectstack/spec)
  • content/docs/api/error-catalog.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-client.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-server.mdx (via @objectstack/spec)
  • content/docs/api/index.mdx (via @objectstack/spec)
  • content/docs/automation/approvals.mdx (via @objectstack/spec)
  • content/docs/automation/connectors.mdx (via @objectstack/spec)
  • content/docs/automation/flows.mdx (via @objectstack/spec)
  • content/docs/automation/hook-bodies.mdx (via packages/spec)
  • content/docs/automation/hooks.mdx (via @objectstack/spec)
  • content/docs/automation/index.mdx (via @objectstack/spec)
  • content/docs/automation/webhooks.mdx (via @objectstack/core, @objectstack/spec)
  • content/docs/automation/workflows.mdx (via @objectstack/spec)
  • content/docs/concepts/architecture.mdx (via @objectstack/spec)
  • content/docs/concepts/design-principles.mdx (via packages/spec)
  • content/docs/concepts/index.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-driven.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-lifecycle.mdx (via packages/spec)
  • content/docs/concepts/north-star.mdx (via packages/core, @objectstack/spec)
  • content/docs/data-modeling/analytics.mdx (via @objectstack/spec)
  • content/docs/data-modeling/drivers.mdx (via @objectstack/spec)
  • content/docs/data-modeling/external-datasources.mdx (via @objectstack/spec)
  • content/docs/data-modeling/field-types.mdx (via @objectstack/spec)
  • content/docs/data-modeling/fields.mdx (via @objectstack/spec)
  • content/docs/data-modeling/formulas.mdx (via @objectstack/spec)
  • content/docs/data-modeling/index.mdx (via @objectstack/spec)
  • content/docs/data-modeling/objects.mdx (via @objectstack/spec)
  • content/docs/data-modeling/queries.mdx (via @objectstack/spec)
  • content/docs/data-modeling/schema-design.mdx (via @objectstack/spec)
  • content/docs/data-modeling/seed-data.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation-rules.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation.mdx (via @objectstack/spec)
  • content/docs/deployment/cli.mdx (via @objectstack/spec)
  • content/docs/deployment/migration-from-objectql.mdx (via @objectstack/core)
  • content/docs/deployment/tenancy-modes.mdx (via @objectstack/spec)
  • content/docs/deployment/troubleshooting.mdx (via @objectstack/spec)
  • content/docs/deployment/validating-metadata.mdx (via @objectstack/spec)
  • content/docs/getting-started/build-with-claude-code.mdx (via @objectstack/spec)
  • content/docs/getting-started/common-patterns.mdx (via @objectstack/spec)
  • content/docs/getting-started/examples.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-reference.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-start.mdx (via @objectstack/spec)
  • content/docs/getting-started/your-first-project.mdx (via @objectstack/spec)
  • content/docs/kernel/cluster.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/auth-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/cache-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/data-engine.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/index.mdx (via @objectstack/core, @objectstack/spec)
  • content/docs/kernel/contracts/metadata-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/storage-service.mdx (via @objectstack/spec)
  • content/docs/kernel/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/data-service.mdx (via @objectstack/spec)
  • content/docs/kernel/runtime-services/email-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/examples.mdx (via @objectstack/core, @objectstack/spec)
  • content/docs/kernel/runtime-services/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/queue-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/sharing-service.mdx (via @objectstack/spec)
  • content/docs/kernel/runtime-services/sms-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/storage-service.mdx (via @objectstack/spec)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/core, @objectstack/spec)
  • content/docs/kernel/services.mdx (via @objectstack/core, @objectstack/spec)
  • content/docs/permissions/authentication.mdx (via @objectstack/core)
  • content/docs/permissions/authorization.mdx (via packages/core, @objectstack/spec)
  • content/docs/permissions/permission-sets.mdx (via @objectstack/spec)
  • content/docs/permissions/permissions-matrix.mdx (via @objectstack/spec)
  • content/docs/permissions/positions.mdx (via @objectstack/spec)
  • content/docs/permissions/rls.mdx (via @objectstack/spec)
  • content/docs/permissions/sharing-rules.mdx (via @objectstack/spec)
  • content/docs/plugins/adding-a-metadata-type.mdx (via @objectstack/spec)
  • content/docs/plugins/anatomy.mdx (via @objectstack/core)
  • content/docs/plugins/development.mdx (via @objectstack/core, @objectstack/spec)
  • content/docs/plugins/index.mdx (via @objectstack/core, @objectstack/spec)
  • content/docs/plugins/packages.mdx (via @objectstack/core, @objectstack/spec)
  • content/docs/protocol/backward-compatibility.mdx (via @objectstack/spec)
  • content/docs/protocol/diagram.mdx (via packages/spec)
  • content/docs/protocol/kernel/config-resolution.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/http-protocol.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/i18n-standard.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/core, @objectstack/spec)
  • content/docs/protocol/kernel/lifecycle.mdx (via @objectstack/core, @objectstack/spec)
  • content/docs/protocol/kernel/plugin-spec.mdx (via @objectstack/core, @objectstack/spec)
  • content/docs/protocol/knowledge.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/query-syntax.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/schema.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/security.mdx (via packages/spec)
  • content/docs/protocol/objectql/state-machine.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/actions.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/concept.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/layout-dsl.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/record-alert.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/widget-contract.mdx (via @objectstack/spec)
  • content/docs/releases/implementation-status.mdx (via @objectstack/core, @objectstack/spec)
  • content/docs/releases/index.mdx (via @objectstack/spec)
  • content/docs/releases/v12.mdx (via @objectstack/core, @objectstack/spec)
  • content/docs/releases/v13.mdx (via @objectstack/spec)
  • content/docs/releases/v15.mdx (via @objectstack/core)
  • content/docs/releases/v16.mdx (via @objectstack/spec)
  • content/docs/releases/v17.mdx (via @objectstack/core, @objectstack/spec)
  • content/docs/releases/v9.mdx (via @objectstack/spec)
  • content/docs/ui/actions.mdx (via @objectstack/spec)
  • content/docs/ui/apps.mdx (via @objectstack/spec)
  • content/docs/ui/create-vs-edit-form.mdx (via @objectstack/spec)
  • content/docs/ui/dashboards.mdx (via @objectstack/spec)
  • content/docs/ui/field-grouping-and-order.mdx (via @objectstack/spec)
  • content/docs/ui/forms.mdx (via @objectstack/spec)
  • content/docs/ui/index.mdx (via @objectstack/spec)
  • content/docs/ui/public-data-collection.mdx (via @objectstack/spec)
  • content/docs/ui/setup-app.mdx (via @objectstack/spec)
  • content/docs/ui/translations.mdx (via @objectstack/spec)
  • content/docs/ui/views.mdx (via @objectstack/spec)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@github-actions github-actions Bot added documentation Improvements or additions to documentation protocol:data tests tooling labels Aug 8, 2026
@os-zhuang
os-zhuang marked this pull request as ready for review August 8, 2026 12:14
@os-zhuang
os-zhuang added this pull request to the merge queue Aug 8, 2026
Merged via the queue into main with commit b127c8b Aug 8, 2026
26 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-5586-filter-token-grammar branch August 8, 2026 12:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation protocol:data size/m tests tooling

Projects

None yet

2 participants