Skip to content

fix(automation,objectql): a filter that loses a condition must not run (#3810) - #3831

Merged
os-zhuang merged 3 commits into
mainfrom
claude/template-tokens-filters-notifications-nicmhd
Jul 28, 2026
Merged

fix(automation,objectql): a filter that loses a condition must not run (#3810)#3831
os-zhuang merged 3 commits into
mainfrom
claude/template-tokens-filters-notifications-nicmhd

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Closes#3810.

#3810 时我描述的是"flow 筛选静默吞掉 date macro"。动手前先实测,发现真实情况严重得多,而且大部分与 filter token 无关。下面三条是同一个失败模式的三个出口:查询匹配了作者本想排除的行

1. flow 筛选会静默塌缩成"匹配全部"

flow 模板插值器把"这个 token 没解析出来"表达为 undefined。在 message 里那渲染成空文本——无害。在 filter 里它移除了一个条件,而少一个条件匹配的行更多。 当它是唯一条件时:

{owner: '{record.ownr}'}// → {}

{} 交给 deleteMany 就是整张表

实测四种成因全部命中(探针跑完即删):

写法插值后幸存条件
{record.ownr} 字段名拼错{}0 ← 匹配所有行
{someInputVar} 该次运行没传{}0 ← 匹配所有行
{record.owner.manager} lookup 穿透{}0 ← 匹配所有行
{current_user_id} filter token{}0 ← 匹配所有行
{status:'open', owner:'{record.ownr}'}{"status":"open"}1 ← 从"我的 open"变成"所有 open"

一个字段名拼写错误 + 一个 delete_record 节点 = 静默清空整张表。 这是既有行为,不是 #3582 引入的。

get_record / update_record / delete_record 现在在插值抹掉任何作者写下的条件时拒绝执行,并指名是哪个模板。护栏的判据是丢失而不是为空——刻意不写 filter 的作者不受影响("删除全部"仍然可表达),而三缺一同样失败,因为从"我的 open"放宽到"所有 open"是同一类 bug。

2. filter 占位符从未到达能解析它的引擎(#3810 本体)

config.filter 是两种 {…} 方言唯一相遇的位置:flow 模板方言({record.owner})和 filter 占位符方言({current_year_start},由 resolveFilterTokens() 解析)。谁赢完全由求值顺序偶然决定:flow 插值器先跑,找不到叫 current_year_start 的 flow 变量,就把它抹了。

新增 interpolateFilter() 把这个位置归还给拥有它的方言:整串 token 若无 flow 变量可解析、且是已知 filter 占位符,则原样透传交给引擎展开。

这是所有权归还,不是宽容 fallback(PD #12):config.filter 本来就是 filter 方言的地盘,flow 模板只是路过。flow 变量保留优先级——同名 flow 变量仍然遮蔽占位符,所以今天能跑的模板不会改变含义(有测试锁住)。

3. 引擎解析读路径但不解析写路径(#3582 的遗漏,我的)

resolveFilterTokens() 上个 PR 只接到了 find/findOne/count/aggregate。于是同一个 filter 因动词不同选中不同的行集:

find ({owner:'{current_user_id}'}) → driver 收到 {"owner":"usr_1"} ← 已解析
update ({owner:'{current_user_id}'}) → driver 收到 {"owner":"{current_user_id}"} ← 字面量
delete (同上) → 同上,字面量
where.id 走 by-id 快路径 → 主键 = "{current_user_id}" ← 字面量当 id

一个 flow 用 find_records 预览"哪些会被改",再用 update_records 用同一个 filter 去改——两者作用于不同的行集

这正是 AGENTS.md PD #10 点名的 #3106 形状,只是下沉了一层:evaluator 存在,只是没接到全部调用点。"A case label is not enforcement; check the call site."

update / delete 现在也解析,且在 by-id 快路径认领标量 where.id之前——否则未解析的 {current_user_id} 会被当成主键本身绑定。调用方的 options 不被改写(flow 节点 config 会跨次运行复用)。

三者的关系

不是三个独立补丁,是一套:②放行之后必须有人解析,所以③是前提;①兜住剩下的 fail-open——它覆盖拼写错误/缺变量/lookup 穿透,这些是②③都碰不到的。

方向也值得记一笔:③是 fail-closed(token 当字面量 → 匹配零行,少改少删),①②修的是 fail-open(条件消失 → 匹配全部)。后者才是数据破坏。

验证

  • 新增 28 个测试:crud-filter-guard.test.ts(13,含"刻意空 filter 仍可执行"和"flow 变量遮蔽占位符"两个反向锁),engine-filter-tokens.test.ts 补 6 个写路径用例(含 by-id 顺序回归、读写一致性、不改写调用方 options)。
  • 断言落在驱动真正收到的 where 上,不是中间结果。
  • pnpm build 通过;pnpm test 132/132 全绿。

写测试时踩到一个值得说的点:三个"应当成功"的用例最初失败,原因是 ADR-0049 的 runAs 门(#3760)先拒绝了数据操作。这说明护栏位置足够靠前(在任何数据操作之前),但也意味着若不声明 runAs: 'system',那些"应当失败"的用例会因为错误的原因通过。已在测试 flow 上显式声明并注释。

文档

修正两处因本次改动而失效的说法:

  • skills/objectstack-query/rules/filters.md 里我在 Template tokens in filter values and notification templates are not evaluated at runtime #3582 加的"flow 筛选用不了 token"——现在能用了;
  • validate-flow-template-paths.ts 的 "resolves to an empty string at runtime (silently)"——在 filter 位置现在是拒绝执行,不再是静默空串。该 lint 仍然值得保留(构建期抓到胜过运行期失败,且其他 config 块行为不变),注释里写清了。

🤖 Generated with Claude Code

https://claude.ai/code/session_01EdCvGtER9SzJopS4Yh3Pa1


Generated by Claude Code

#3810)
Three related holes, all ending in "the query matched rows the author excluded".
1. A FLOW FILTER COULD SILENTLY WIDEN TO MATCH EVERYTHING.
The flow template interpolator expresses "this token did not resolve" as
`undefined`. In a message that renders as empty text — harmless. In a FILTER it
removes the condition, and a removed condition matches MORE rows. When it was the
only condition, `{ owner: '{record.ownr}' }` became `{}`, and `{}` handed to
`deleteMany` is every row in the table.
So one mistyped field name in a `delete_record` node silently emptied the object.
Reproduced with all four causes: a typo (`{record.ownr}`), an input the run never
received, a lookup hop (`{record.account.name}` — the trigger record carries a
scalar id), and a filter placeholder.
`get_record` / `update_record` / `delete_record` now refuse to execute when
interpolation erased any authored condition, naming the offending template. The
guard keys on LOSS, not emptiness: an author who deliberately wrote no filter is
unaffected, and losing one of two conditions still fails, because widening from
"my open records" to "all open records" is the same class of bug.
2. FILTER PLACEHOLDERS NEVER REACHED THE ENGINE THAT RESOLVES THEM.
`config.filter` is where two `{…}` dialects meet — the flow template dialect
(`{record.owner}`) and the filter placeholder dialect (`{current_year_start}`,
`{current_user_id}`, resolved by `resolveFilterTokens()`). Evaluation order picked
the winner by accident: the flow interpolator ran first, found no flow variable by
that name, and erased it.
`interpolateFilter()` hands that position back to the dialect that owns it — a
whole-string token that no flow variable resolves and that IS a recognised
placeholder passes through verbatim for the engine to expand. Flow variables keep
precedence, so a template that works today cannot change meaning.
3. THE ENGINE RESOLVED PLACEHOLDERS ON READS BUT NOT ON WRITES.
`resolveFilterTokens()` reached find/findOne/count/aggregate only. So the SAME
filter selected different rows depending on the verb: `find({owner:
'{current_user_id}'})` matched the signed-in user's rows while `update`/`delete`
compared the literal token text and matched none — a flow that previewed with one
and acted with the other operated on two different row sets. The #3106 shape one
layer down: the evaluator existed, only some call sites reached it.
`update` and `delete` now resolve too, BEFORE the by-id fast path claims a scalar
`where.id` (otherwise an unresolved `{current_user_id}` would be bound as the
primary key itself). Caller options are never mutated.
Docs corrected: the caveat added in #3582 saying flow filters cannot use tokens is
now wrong, and the build lint's "resolves to an empty string silently" no longer
describes filter positions.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EdCvGtER9SzJopS4Yh3Pa1
@vercel

vercelBot commented Jul 28, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectstackIgnoredIgnoredJul 28, 2026 9:37am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 4 package(s): @objectstack/lint, @objectstack/objectql, packages/services, @objectstack/spec.

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

  • content/docs/ai/agents.mdx(via @objectstack/spec)
  • 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 packages/spec)
  • content/docs/automation/flows.mdx(via @objectstack/spec)
  • content/docs/automation/hook-bodies.mdx(via @objectstack/lint, packages/spec)
  • content/docs/automation/hooks.mdx(via @objectstack/spec)
  • content/docs/automation/index.mdx(via @objectstack/spec)
  • content/docs/automation/webhooks.mdx(via packages/services, @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 @objectstack/objectql, packages/spec)
  • content/docs/concepts/north-star.mdx(via packages/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 packages/objectql, @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/objectql)
  • content/docs/deployment/troubleshooting.mdx(via @objectstack/spec)
  • content/docs/deployment/validating-metadata.mdx(via @objectstack/spec)
  • content/docs/deployment/vercel.mdx(via @objectstack/objectql)
  • 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/spec)
  • content/docs/kernel/contracts/metadata-service.mdx(via packages/spec)
  • content/docs/kernel/contracts/storage-service.mdx(via packages/spec)
  • content/docs/kernel/index.mdx(via packages/spec)
  • content/docs/kernel/runtime-services/audit-service.mdx(via packages/services)
  • content/docs/kernel/runtime-services/email-service.mdx(via packages/spec)
  • content/docs/kernel/runtime-services/index.mdx(via packages/services, packages/spec)
  • content/docs/kernel/runtime-services/queue-service.mdx(via packages/spec)
  • content/docs/kernel/runtime-services/settings-service.mdx(via packages/services)
  • content/docs/kernel/runtime-services/sharing-service.mdx(via packages/spec)
  • content/docs/kernel/runtime-services/sms-service.mdx(via packages/spec)
  • content/docs/kernel/runtime-services/storage-service.mdx(via packages/spec)
  • content/docs/kernel/services-checklist.mdx(via @objectstack/objectql, @objectstack/spec)
  • content/docs/kernel/services.mdx(via @objectstack/objectql)
  • content/docs/permissions/authentication.mdx(via @objectstack/objectql)
  • content/docs/permissions/authorization.mdx(via @objectstack/lint, @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/development.mdx(via @objectstack/spec)
  • content/docs/plugins/index.mdx(via @objectstack/objectql, @objectstack/spec)
  • content/docs/plugins/packages.mdx(via @objectstack/objectql, packages/services, @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/i18n-standard.mdx(via packages/services, @objectstack/spec)
  • content/docs/protocol/kernel/index.mdx(via @objectstack/objectql, @objectstack/spec)
  • content/docs/protocol/kernel/lifecycle.mdx(via @objectstack/spec)
  • content/docs/protocol/kernel/plugin-spec.mdx(via @objectstack/spec)
  • content/docs/protocol/kernel/runtime-capabilities.mdx(via @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/objectql, @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/objectql, @objectstack/spec)
  • content/docs/releases/index.mdx(via @objectstack/spec)
  • content/docs/releases/v12.mdx(via @objectstack/spec)
  • content/docs/releases/v13.mdx(via @objectstack/spec)
  • content/docs/releases/v16.mdx(via @objectstack/spec)
  • content/docs/releases/v9.mdx(via @objectstack/objectql, @objectstack/spec)
  • content/docs/ui/actions.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/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.

claude added 2 commits July 28, 2026 09:04
`content/docs/references/` is generated from the spec TSDoc; the resolver
description in `date-macros.zod.ts` / `context-tokens.zod.ts` changed in the
previous commit without the regenerate step, so `check:docs` failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EdCvGtER9SzJopS4Yh3Pa1
@os-zhuang
os-zhuang marked this pull request as ready for review July 28, 2026 09:52
@os-zhuang
os-zhuang merged commit de9af8a into mainJul 28, 2026
17 checks passed
@os-zhuang
os-zhuang deleted the claude/template-tokens-filters-notifications-nicmhd branch July 28, 2026 09:52
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationprotocol:datasize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Flow node filters silently blank date macros: the template engine consumes {…} before the query engine sees it

2 participants

@os-zhuang@claude