Skip to content

fix(driver-sql,driver-memory,formula)!: { field: {} } 四个后端一律拒收 —— 零个操作符的字段约束不再有三个答案 (#5240) - #5327

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-5240-empty-field-spec-reject
Aug 4, 2026
Merged

fix(driver-sql,driver-memory,formula)!: { field: {} } 四个后端一律拒收 —— 零个操作符的字段约束不再有三个答案 (#5240)#5327
os-zhuang merged 2 commits into
mainfrom
claude/issue-5240-empty-field-spec-reject

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes #5240

按维护者拍板取拒收(不重新论证 TRUE / FALSE)。四个后端对 { field: {} } 一律抛
INVALID_FILTER / 400,消息里指名出事位置(如 filter.$or[0].stage)。


1. 现场核对(STALE-PREMISE 自检)

worktree 基于 origin/main = 26e1029f5,已含 5aae79096(PR #5296 / #5146)。
issue 正文那张四路径对照表逐条实测结果:

路径 issue 的说法 实测(改前) 结论
driver-sql 顶层 plain map INVALID_FILTER(#5041) Operator "=" on field "stage" requires a single comparable value… 成立
driver-sql 组合子内 不产出 SQL → TRUE {$not:{stage:{}}} → 返回全部 4 行;{$or:[{stage:{}},{owner:'u2'}]}['2'](子句被丢) 成立
formula keys.length === 0 显式 FALSE ✅ 成立 成立
driver-memory checkCondition 落到 JSON.stringify → FALSE ⚠️ 半对 已修正,见下

修正一条:driver-memory 那行指的是 memory-matcher.ts,而它不是驱动的实时路径。
InMemoryDriver.find 只从该文件 import 了 getValueByPath,过滤实际由
convertToMongoQuery()mingo 完成。实测 mingo 把 { a: {} } 读作
a 深等于空文档」——我特意播了一行 a: {} 的数据,它被选中了。也就是说这条路径
给的不是 FALSE,而是另一个 filter,只是在正常数据上碰巧看起来像 FALSE。

因此 driver-memory 的两个过滤面(实时 mingo 路径 + 跨后端一致性套件所用的参考匹配器)
补了闸门 —— 只改派发单点名的 memory-matcher.ts 的话,用户真正走的那条路径
仍然静默,拍板要的「四后端一律拒收」不成立。

同日 churn 的两处重核(#5243 / #5296)

2. 闸门的位置(与派发单的一处偏差,已如实记录)

派发单建议「接在归约之后的编译分支里」。实作把它接在归约的校验遍历里
(reduceFilterKey 的字段分支,与 assertFilterNode / assertFilterNodeList 并列),
理由是编译分支会漏:

{ $or: [ { a: {} }, {} ] }

{} 是 TRUE 析取项(#5134 的单位元),整个 $or 归约成 'true',
applyFilterConditionverdict === 'true'直接 return,编译分支永远见不到
{ a: {} } —— 拒不拒收就取决于它的兄弟节点。这正是 reduceFilterNode 自己的注释
警告过的「gate conditional on evaluation order」。归约遍历是穷尽且不短路的,所以闸门放在
那里。归约的判定一行未改,只是多了一处 refusal;这条已在测试里具名钉住
(beside a sibling that would settle the node first)。

同样的理由,两个 JS 后端也改成「先走一遍整棵树校验,再求值」:它们的求值器会短路
(every/some,且节点遇到第一个 false 就 return),闸门若放在求值里,
同一条策略会因为被测记录的不同而时而拒收时而不拒收。求值逻辑本身逐字未动。

3. 四个后端各自的改法

改动 位置
driver-sql emptyFieldConstraintError + isEmptyFieldConstraint;接在 reduceFilterKey(组合子/整树)与 applyFilters 的 plain-map 循环(顶层)。顶层原本报 #5041 的通用「cannot be bound as a SQL parameter」,现在与组合子内同一条消息 —— 一个条件一种措辞。nullGuardForFieldSpec 的空 spec 分支删除。 sql-driver.ts
driver-memory 新增 filter-refusal.ts(把既有的 unsupportedFilterError 收进来,避免同包两份信封),两个过滤面共用:normalizeFilterCondition(实时 mingo 路径,mingo 之前)与 memory-matcher.match(参考匹配器,求值之前) memory-driver.ts / memory-matcher.ts / filter-refusal.ts
formula assertFilterShapematchesFilterCondition 入口走一遍整树;evalFieldkeys.length === 0 保留为兜底(函数要保持全域),但不再是本后端对该形状的答案 matches-filter.ts
driver-sqlite-wasm 无源码改动(SqliteWasmDriver extends SqlDriver),但不假设「继承了就没问题」 —— 单独一份 pin 套件验证 refusal 穿过它自定义的 sql.js 方言后 code/status 仍完好 仅测试

四家同一个 INVALID_FILTER / 400。

4. ⚠️ 连带:RLS check 的可观察行为变更

formulamatchesFilterConditionplugin-security 对 insert/update 后像执行
行级 check 的路径(security-plugin.ts:1538)。改为抛出后落在 #4775
「求不出值 = 该次操作失败」的既定姿态上。这不只是「拒绝得更响」,有一类结果直接翻转:

check 策略 改前 改后
{ a: {} } FALSE → 写入被拒(403 PermissionDenied) 抛出 → 该次写入失败(400 INVALID_FILTER)
{ $or: [ { a: {} }, { owner: '{userId}' } ] } FALSE 被另一析取项吸收 → 写入放行 抛出 → 该次写入失败
{ $not: { a: {} } } !false → 写入放行 抛出 → 该次写入失败

后两行是原本能成功、现在会失败的写入。 这是拍板的目的而非副作用,changeset 与
上面的表都如实写了,没有轻描淡写。三条都有具名测试钉住。

同一路径的另一个消费者 explain-engine.ts:523(/explain 的记录级归因)也会因此
抛出而不是给出裁决 —— 一条坏策略在诊断面上同样响亮失败。这与拍板方向一致,故未加
try/catch 吞掉;若维护者希望 explain 降级为「策略不可求值」的裁决而非报错,请示下,
我另开一单(未擅自扩范围到 plugin-security)。

5. 测试

套件 结果
@objectstack/driver-sql 786 passed, 44 skipped(65 files)—— 含新增 sql-driver-empty-field-constraint.test.ts 21 条
@objectstack/driver-memory 320 passed(12 files)—— 含新增 17 条(实时路径 + 参考匹配器各一组)
@objectstack/formula 357 passed(16 files)—— 含新增 17 条
@objectstack/driver-sqlite-wasm 237 passed(17 files)—— 含新增 5 条
@objectstack/objectql 1882 passed(117 files)
@objectstack/plugin-security 731 passed(34 files)
@objectstack/service-analytics 555 passed(42 files)
@objectstack/service-storage 283 passed(21 files)
@objectstack/plugin-sharing 347 passed(13 files)
typecheck(四包) 9 tasks successful
eslint --no-inline-config(全部改动文件) exit 0

非空形状逐字符不变 —— 具名断言了几条普通 filter 的 SQL 文本:

{ stage: 'won' }                       → select `id` from `deal` where `stage` = 'won'
{ amount: { $gt: 15 } }                → select `id` from `deal` where `amount` > 15
{ $or: [{stage:'won'},{owner:'u2'}] }  → ... where ((`stage` = 'won') or (`owner` = 'u2'))
{ $not: { stage: 'won' } }             → ... where not (((`stage` is not null) and (`stage` = 'won')))
{ stage: { $in: ['won','open'] } }     → ... where `stage` in ('won', 'open')

以及 #5134 的布尔单位元原样保留({} 这个空节点{ field: {} } 是两个形状)。

反向验证(把改动 stash 掉,新用例必须失败)

########## driver-sql ##########
 × top level → 400 INVALID_FILTER naming filter.stage
   AssertionError: expected 'Operator "=" on field "stage" require…' to contain 'filter.stage'
 × inside $or → 400 INVALID_FILTER naming filter.$or[0].stage
   Error: expected the driver to refuse this filter, but it resolved
 × wrapping the refused shape in a combinator no longer turns it into match-all
   AssertionError: promise resolved "[ '2' ]" instead of rejecting
 × and $not does not swallow it into the #5146 NULL-safe rewrite either
   AssertionError: promise resolved "[ '1', '2', '3' ]" instead of rejecting
      Tests  11 failed | 10 passed (21)

########## driver-sql · sql-driver-not-null-safe(#5146 的用例改到新事实)##########
 × a field constrained by zero operators is REFUSED, not rewritten (#5240)
   AssertionError: promise resolved "[ '1', '2', '3', '4' ]" instead of rejecting
      Tests  1 failed | 24 passed (25)

########## driver-memory ##########
 × top level → 400 INVALID_FILTER naming filter.stage
   AssertionError: expected undefined to be 'INVALID_FILTER'
 × the refusal replaces a filter that was silently something ELSE
   AssertionError: promise resolved "[]" instead of rejecting
 × the refusal does not depend on the RECORD being tested
   AssertionError: expected [Function] to throw an error
      Tests  12 failed | 5 passed (17)

########## formula ##########
 × a check that used to ALLOW (the false disjunct was absorbed) now fails
   AssertionError: expected [Function] to throw an error
      Tests  9 failed | 8 passed (17)

########## driver-sqlite-wasm ##########
      Tests  4 failed | 1 passed (5)

注意第一轮 stash 时 wasm 套件全绿 —— 因为它消费的是 @objectstack/driver-sql
构建产物,stash 源码碰不到它。于是用 stash 后的源码重新 build 了一次 driver-sql
再跑,才得到上面的 4 failed。这条记在这里,免得下一位据此误判「继承的那个后端不需要验」。

那两行 10 passed / 8 passed 也是证据的一部分:改前就通过的正是「非空形状逐字符不变」
那一组,说明闸门没有误伤。

6. 未收窄的契约(状态如实声明)

本 PR 让实现比已声明契约更严。 packages/spec 一行未改:FilterConditionSchema
的非递归半边今天仍是 z.record(z.string(), z.unknown()),即 { field: {} } 在 spec 层
依旧声明合法。收窄 schema 与把该 case 补进 FILTER_LOGIC_CASES 归 spec 车道
(建议与 #5239#5146 的 spec 半边同批)。测试里对这些形状的 as cast 都带注释指明了
这一状态,不是在假装契约已经收了。

7. 范围外

新开 issue #5324(实测本单前提时发现,unassigned,未在本 PR 修):
driver-memory 的实时查询路径根本不支持 $not —— normalizeFilterCondition 原样透传给
mingo,而 MongoDB 没有文档级 $not,于是 $not任何位置都抛无 code / 无 status
MingoError(500 形状,逃出 #4436 建立的信封)。CEL !expr 降下来的 RLS scope 在该
驱动上因此直接报错。至今没被测出来的原因:FILTER_LOGIC_CASES 对 driver-memory 只经由
参考匹配器跑,而 driver-sql / sqlite-wasm / mongodb 三家都是穿过真驱动跑的。
本 PR 的 driver-memory 套件里有一条用例把这个现状钉住(并注明由 #5324 负责改变它),
以免默默断言一个并不存在的行为。


Generated by Claude Code

… all four backends (#5240)

A field constrained by ZERO operators is a shape `FilterConditionSchema` still
declares legal, and one filter carrying it had three answers in this repo:

- driver-sql refused it at the top level (the #5041 comparand gate) but DROPPED
  it inside `$and`/`$or`/`$not`, where a predicate that emits nothing means
  "matches every row" — so `{ $or: [{ a: {} }, { b: 2 } ] }` compiled to
  `(b = 2)` by losing a clause, and the same `{ a: {} }` was a 400 at the top
  level and a silent match-all one combinator deep;
- driver-memory answered "matches nothing" incidentally, and did so through TWO
  independent paths that had never been compared: the live query path (mingo
  reads `{ a: {} }` as "deep-equals the empty document") and the reference
  matcher (`JSON.stringify` structural equality);
- formula answered `false` from an explicit fail-closed arm.

Ruled on #5240: refuse it everywhere, with one `INVALID_FILTER` / 400 envelope
and a message naming the position (`filter.$or[0].stage`). The shape is almost
always an authoring accident — a filter builder that recorded a field and never
its operator — and both silent readings answer it with a row count the author
never asked for.

The gate sits on the #5134 validation walk, beside `assertFilterNode`, not in
the emitter: the walk is exhaustive, while the emitter returns early whenever an
identity settles a node, so an emitter-side gate would let
`{ $or: [{ a: {} }, {} ] }` through and make the refusal depend on the shape's
siblings. The reduction's VERDICT is unchanged (a field key still contributes
`'clause'`), so every filter that compiled before compiles byte-identically.

`nullGuardForFieldSpec`'s `entries.length === 0` escape — added by #5146 so the
NULL-safe rewrite would not rule on #5240 from there — is removed with the
ambiguity it protected: the refusal now fires before that rewrite runs.

BREAKING: `matchesFilterCondition` is the RLS `check` evaluation path, so a
`check` policy carrying `{ field: {} }` now fails the operation (#4775 posture)
instead of evaluating to `false`. Where such a constraint sat under an `$or`
beside a satisfied branch, or under a `$not`, the old `false` was absorbed and
the write was ALLOWED; those writes now fail.

Implementation is stricter than the declared contract: narrowing
`FilterConditionSchema` and adding the case to `FILTER_LOGIC_CASES` is the spec
lane's half of #5240.

Fixes #5240
@vercel

vercel Bot commented Aug 4, 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 4, 2026 8:50pm

Request Review

@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation tests tooling and removed size/xl labels Aug 4, 2026
@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/formula, @objectstack/driver-memory, @objectstack/driver-sql.

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

  • content/docs/data-modeling/drivers.mdx (via @objectstack/driver-memory, @objectstack/driver-sql)
  • content/docs/data-modeling/formulas.mdx (via @objectstack/formula)
  • content/docs/data-modeling/validation.mdx (via @objectstack/formula)
  • content/docs/deployment/vercel.mdx (via @objectstack/driver-memory)
  • content/docs/getting-started/glossary.mdx (via @objectstack/driver-memory, @objectstack/driver-sql)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/driver-memory, @objectstack/driver-sql)
  • content/docs/permissions/authentication.mdx (via @objectstack/driver-memory)
  • content/docs/plugins/anatomy.mdx (via @objectstack/driver-sql)
  • content/docs/plugins/index.mdx (via @objectstack/driver-memory)
  • content/docs/plugins/packages.mdx (via @objectstack/formula, @objectstack/driver-memory, @objectstack/driver-sql)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/driver-sql)
  • content/docs/protocol/kernel/lifecycle.mdx (via @objectstack/driver-sql)
  • content/docs/protocol/objectql/query-syntax.mdx (via @objectstack/driver-memory, @objectstack/driver-sql)
  • content/docs/protocol/objectui/record-alert.mdx (via @objectstack/formula)
  • content/docs/releases/implementation-status.mdx (via @objectstack/driver-memory, @objectstack/driver-sql)
  • content/docs/releases/v15.mdx (via @objectstack/formula)
  • content/docs/releases/v16.mdx (via @objectstack/formula)

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.

Copy link
Copy Markdown
Contributor Author

PM 复核(会话 session_01Pbu27iNUfQCHeuS551Rqo7):接受,并对开放问题裁 A(否决窗口开放)

开放问题:formula 改抛出后,/explain 的记录级归因也会抛

取 A(维持现状,plugin-security 一行不改),理由不是「与拒收方向一致」这句口号,而是两点具体的:

  1. 抛出没有丢失信息**。异常指名了出事位置(filter.$or[0].stage),操作者从诊断面得到的仍然是「哪条策略的哪个键坏了」—— 与 /explain 本来要给的答案是同一件事,只是以异常而非裁决的形式呈现。B 要解决的是呈现形式,不是信息缺失。
  2. B 会在诊断路径上重新引入一处消费端容忍 —— 让 /explain 把「这条策略根本不可求值」降级成一种裁决展示,等于在唯一一个专门用来看清策略的地方,把坏策略渲染成一种正常状态。这正是本单要消灭的形状,换了个位置。

但 B 的动机是真的:一个诊断工具在它要诊断的东西坏掉时自己也坏掉,这值得单独判 —— 那是关于 /explain 契约的产品问题(它应该「求值策略」还是「报告策略的可求值性」),不该由一个 filter 闸门的 PR 顺手定。另开单,不阻塞本 PR。

否决窗口:维护者若认为 /explain 必须始终返回裁决,回一句我即刻转 B 并另派。

复核确认(对 GitHub 实况与本地 diff,非照抄报告)

范围状态

packages/spec/** 未动 —— 实现现在比已声明契约更严,契约收窄(FilterConditionSchemaz.record 半边 + FILTER_LOGIC_CASES)归 spec 车道,建议与 #5239#5146 spec 半边同批。PR 正文已如实写明这一状态,没有假装契约已收。


Generated by Claude Code

@os-zhuang
os-zhuang marked this pull request as ready for review August 4, 2026 21:05
@os-zhuang
os-zhuang added this pull request to the merge queue Aug 4, 2026
Merged via the queue into main with commit 0f17114 Aug 4, 2026
24 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-5240-empty-field-spec-reject branch August 4, 2026 21:18
os-zhuang pushed a commit that referenced this pull request Aug 4, 2026
Second relay merge: #5300 / #5304 / #5306 / #5308 / #5318 / #5326 / #5327.
Textually clean, but the os-regen driver defers generated artifacts rather than
text-merging them, so `json-schema.manifest.json` again came out holding this
branch's pre-merge side — this time still listing `ui/EmbedConfig` and
`ui/NotificationAction`, both retired by #5300. Reset the deferred artifacts to
`origin/main`, rebuilt from the merged tree, regenerated wholesale.

Post-regen assertions (a silent one-side drop is exactly what this catches):
api-surface delta vs `origin/main` is exactly this PR's four additions and ZERO
removals; manifest delta is one addition (`ui/ViewItemWire`) and zero removals;
every sibling retirement stays removed (`ui/EmbedConfig`, `ui/NotificationAction`,
`system/HttpServerConfig`, `ui/Animation`, `ui/ZIndex`) and every sibling
addition stays present (`FilterArray` ×7, `EmailProvider` ×2).
`check:authorable-surface` (+ its #5304 `.base.json` anchor) is green and the
anchor file is byte-identical to `origin/main` — not hand-edited.

`metadata-form-zod-reconciliation.test.ts` co-edited with #5280/#5318 and merged
SEMANTICALLY, not by taking a side: #5318 rewrote the docblock, imports, helpers
and test bodies, while this PR's only edit is `unwrap`'s `pipe` case, so the two
did not overlap textually — but they do interact, and in the direction that
matters. #5318's `isRetiredAt` / `authorableKeysOf` both route through
`unwrap`/`keysOf`, and `view`'s root is now a `z.preprocess` pipe. Measured both
ways: without this PR's #4488-style fix `unwrap(view root)` resolves to
`transform` and `keysOf` returns NULL, so #5318's brand-new tombstone assertions
would be VACUOUS on `view` (and the pre-existing key-bearing assertion would
fail outright); with it, 89 keys. Both PRs' assertions are live on every type.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ErbEDVAg1No9gdg1pgDAGB
os-zhuang pushed a commit that referenced this pull request Aug 5, 2026
…ity-batch

Textual conflict: packages/plugins/driver-mongodb/src/mongodb-filter.ts —
both sides rewrote translateFilter/translateCondition (#5239 reduction vs
#5329 array-dialect deletion + #5368 $null gate/path threading). Resolution
keeps both: main's array refusal and path threading, this branch's
three-valued reduction and shape gates; the three helpers both sides defined
(unsupportedFilterError, describeFilterOperand, safeShapePreview) are
de-duplicated onto main's copies.

Semantic reconciliation the textual merge could not see (AGENTS.md s10):
main's #5347 $null comparand gate sat in the emitter, and this branch's
reduction makes emitters skippable by a boolean identity — { $or: [ {},
{ stage: { $null: 'yes' } } ] } would have translated to match-all while
driver-sql refuses it. The gate's load-bearing copy moved onto the
validating walk (reduceFilterKey), mirroring driver-sql's #5368 placement;
the emitter arm keeps its local check. Pinned in
mongodb-null-comparand-refusal.test.ts (three identity-sibling fixtures).

Fixture triage: the 'legacy array dialect is untouched' pin in
mongodb-filter-boolean-identity.test.ts pinned a dialect #5329 deleted —
replaced wholesale with the surviving boundary ([] = absent filter =
match-all, non-empty array refused before the reduction runs).

The reduceFilterKey field-arm comment on { field: {} } is recalibrated to
current main: #5327 gated the shape on the other four backends; this driver
remaining the one still answering it is now tracked by #5376.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ErbEDVAg1No9gdg1pgDAGB
os-zhuang pushed a commit that referenced this pull request Aug 5, 2026
本 PR 的 spec 半边是契约文档,机械合并会把 base 时代的论断带上 main;
逐条对当前 origin/main 实测后校订:

- FilterConditionSchema 的 NULL-safe $not 合规段:read-scope-sql 已由
  #5326 对齐(#5297 关闭)、filter-normalizer 已由 #5335 对齐(#5325
  关闭),七个面全部一致 —— 「尚未合规、指向 #5297」改写为已闭合的事实。
- 「Deliberately NOT declared here」:空组合子单位元由「两立场对峙、
  上交 #5322」改为「#5322 已拍板取单位元,实施在 #5365(排在本 PR 之后
  合入);main 上两个 analytics 编译器今天仍拒收,故本 PR 仍不在此声明,
  声明随 #5365 翻正」;{ field: {} } 由「无后端设闸」改为「#5327 已闸
  四家,driver-mongodb 是唯一还在作答的后端(#5376)」。
- filter-logic-conformance.ts 族 2/3 状态行同步重测:族 2 的后端阻塞
  已清零,唯余 fixture 工作;族 3 的四家闸门已落,阻塞改为表形扩展 +
  mongodb(#5376)。族 1 段落一字未动 —— 由 #5365 在其同步轮删除,
  已约定分工。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ErbEDVAg1No9gdg1pgDAGB
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Aug 6, 2026
…ack-ai#5074) (objectstack-ai#5319)

* feat(spec)!: split ViewItemSchema into an authoring gate and a wire variant (objectstack-ai#5074)

`ViewItemSchema` carried two contracts at once: the authoring surface
`defineViewItem()` and Studio's view-create form parse, AND member 1 of the
`ViewMetadataSchema` union that `saveMetaItem` validates every persisted `view`
body against. The wire role needed Studio's round-trip keys through, so the
shape stayed open — and `defineViewItem({ …, confg: {…} })` parsed clean,
handing back a ViewItem with no view configuration at all.

Per the maintainer's ruling (option A, 2026-08-04):

- `ViewItemSchema` is strict on both arms — the authoring gate.
- `ViewItemWireSchema` is the `.strip()` wire variant and is member 1 of
  `ViewMetadataSchema`, with `isPinned` / `sortOrder` DECLARED on it instead of
  surviving because nobody closed the member. Both are built from one
  `viewItemArmShape()`, so the two postures cannot drift into two
  transcriptions (a `discriminatedUnion` cannot be `.extend()`ed).

The scope addendum's hard requirement was recursive-effective openness, which a
posture flip cannot deliver: `.strip()` re-opens a member's TOP level only, so
the console-decorated NESTED blocks were still reached at full strictness.
`stripViewConsoleDecorations` (+ the declared `VIEW_CONSOLE_ROW_DECORATIONS`
vocabulary) removes them at the wire door before the union runs — the
write-path mirror of `stripReadDecorations`. That let both blocked sites close:

- `ViewFilterRuleSchema` — objectstack-ai#5114's hotfix was explicitly provisional; retired.
- `ListView.sort[]` — 批 18's revert (objectstack-ai#5070); the `direction → order` alias
  (objectstack-ai#4721, a silently REVERSED sort) comes back with it.

`id` is still not declared anywhere: it is a React list key, and declaring it
would teach an AI author to emit a UUID (批 18 Q1, rejected on record).

Two gate walkers went silent on `view` under a preprocess-rooted registration —
the exact blind spot objectstack-ai#4488 already fixed in `check-liveness.mts` — and are
fixed the same way here. A gate that stops covering a type is worse than one
that fails.

Fixes objectstack-ai#5074

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

* chore(spec): regenerate baselines after merging origin/main (objectstack-ai#5074)

The os-regen merge driver defers generated artifacts rather than text-merging
them, so `json-schema.manifest.json` came out of the merge holding this
branch's pre-merge side — which still listed `system/HttpServerConfig`,
`ui/Animation` and `ui/ZIndex`, all retired by the objectstack-ai#5289/objectstack-ai#5293 chain. Reset the
deferred artifacts to `origin/main`, rebuilt, and regenerated wholesale.

Asserted after regenerating, because a silent one-side drop is exactly what
this step exists to catch: the api-surface delta vs `origin/main` is exactly
this PR's four additions and ZERO removals; the manifest delta is one addition
(`ui/ViewItemWire`) and zero removals; the sibling retirements
(`HttpServerConfigSchema`, `ui/Animation`, `ui/ZIndex`) all stay removed.

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

* chore(spec): regenerate baselines after merging origin/main (spec 车道接力)

Second relay merge: objectstack-ai#5300 / objectstack-ai#5304 / objectstack-ai#5306 / objectstack-ai#5308 / objectstack-ai#5318 / objectstack-ai#5326 / objectstack-ai#5327.
Textually clean, but the os-regen driver defers generated artifacts rather than
text-merging them, so `json-schema.manifest.json` again came out holding this
branch's pre-merge side — this time still listing `ui/EmbedConfig` and
`ui/NotificationAction`, both retired by objectstack-ai#5300. Reset the deferred artifacts to
`origin/main`, rebuilt from the merged tree, regenerated wholesale.

Post-regen assertions (a silent one-side drop is exactly what this catches):
api-surface delta vs `origin/main` is exactly this PR's four additions and ZERO
removals; manifest delta is one addition (`ui/ViewItemWire`) and zero removals;
every sibling retirement stays removed (`ui/EmbedConfig`, `ui/NotificationAction`,
`system/HttpServerConfig`, `ui/Animation`, `ui/ZIndex`) and every sibling
addition stays present (`FilterArray` ×7, `EmailProvider` ×2).
`check:authorable-surface` (+ its objectstack-ai#5304 `.base.json` anchor) is green and the
anchor file is byte-identical to `origin/main` — not hand-edited.

`metadata-form-zod-reconciliation.test.ts` co-edited with objectstack-ai#5280/objectstack-ai#5318 and merged
SEMANTICALLY, not by taking a side: objectstack-ai#5318 rewrote the docblock, imports, helpers
and test bodies, while this PR's only edit is `unwrap`'s `pipe` case, so the two
did not overlap textually — but they do interact, and in the direction that
matters. objectstack-ai#5318's `isRetiredAt` / `authorableKeysOf` both route through
`unwrap`/`keysOf`, and `view`'s root is now a `z.preprocess` pipe. Measured both
ways: without this PR's objectstack-ai#4488-style fix `unwrap(view root)` resolves to
`transform` and `keysOf` returns NULL, so objectstack-ai#5318's brand-new tombstone assertions
would be VACUOUS on `view` (and the pre-existing key-bearing assertion would
fail outright); with it, 89 keys. Both PRs' assertions are live on every type.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Aug 6, 2026
…t:{}}` 为零行、`{}` 析取项吸收 `$or` (objectstack-ai#5325) (objectstack-ai#5335)

`filter-normalizer.ts` 的 `buildNode` 是这个包里第二份同缺陷拷贝。第一份
(`read-scope-sql.ts` 的 `compileNode`,RLS 读作用域)由 objectstack-ai#5297 修好;这一份编译的是
dashboard widget / dataset 作者自己写的 `where`,是各自独立的函数,所以那一单合入后
同样三条仍然在。以 driver-sql `sql-driver-not-null-safe.test.ts` 逐行相同的 fixture
在 sql.js 上实测(行 3、4 的 stage 为 NULL,行 3 的 amount 为 NULL,行 4 的 owner 为 NULL):

| `where`                                        | 改前 | 改后 |
|---|---|---|
| `{ $not: { stage: 'won' } }`                   | `2`  | `2,3,4` |
| `{ $not: { stage: { $in: ['won'] } } }`        | `2`  | `2,3,4` |
| `{ $not: {} }`                                 | 全表 | 零行    |
| `{ $or: [{ stage: 'won' }, {}] }`              | `1`  | 全表    |
| `{ $not: { $or: [{stage:'won'},{owner:'u1'}] } }` | `2` | `2,4` |

守卫加在 normalizer 而不是 `native-sql-strategy`:在这一层它是结构(`$and` 里多一个
`{col: {$null: false}}`),经 `filterNodeToCondition` 交给引擎后在任何驱动上都成立,
包括本身不 NULL-safe 的那些。只加在 raw-SQL 那条路径等于说「分析查询的 `$not` 是什么
意思取决于哪个驱动接住它」,正是 objectstack-ai#5146 花一整轮消灭掉的东西。引擎路径因此会双重加
守卫,已实测幂等:`NOT (c IS NOT NULL AND (c IS NOT NULL AND c = v))` 与单层等价,
代价只是一层冗余谓词。

`NormalizedFilterNode` 新增布尔常量 kind。该联合此前只有 `leaf | and | or | not`,
没有 FALSE 的表示法 —— 这正是 `{$not:{}}` 只能编译成「什么都不发」的根本原因。三个
编译器各自实现它:`native-sql-strategy.compileFilterNode`(`1 = 0` / `1 = 1`,与
`read-scope-sql` 和 driver-sql 的 `applyFalseConstant` 同一拼法)、
`objectql-strategy.filterNodeToCondition`(`{$not: {}}`,driver-sql / formula /
driver-memory 参考匹配器早已钉住的零行写法,objectstack-ai#5134)、`renderFilterNodeSql`(回显给
浏览器的展示 SQL,它同样必须复现执行)。`collectFilterLeaves` 对常量返回空数组 ——
常量约束的是行,不是列,不参与跨对象信封检查。

params 绑定错位隐患(objectstack-ai#5297 的现场教训)逐个核对过:改前三个编译器都不会发生,因为
每个返回 `null` 的分支都在 push 任何值之前就决定了。但本次新增的「TRUE 吸收 OR」
规则会丢弃已经编译(并已绑定)的兄弟分支,于是引入该隐患;两个 SQL 编译器因此都记下
进入组合子时的 `params.length`,吸收时截断回去(`native-sql-strategy` 连 joins 一起
还原),不变量写进 TSDoc:返回 `null` 的调用必须让 `params` 与进入时逐字节相同。
`filterNodeToCondition` 不绑值,无此形状。

一并收进来的两条,都是本次改动逼出来的,不是顺手扩范围:

- 空集合 `{$in: []}` / `{$nin: []}` 此前编译成空子句(= 无约束 = 画全表),现在是布尔
  常量。不这么改,NULL-safe 的 `$not` 会把 `{$not: {a: {$in: []}}}` 从「全部行」变成
  「只有 NULL 行」—— 被丢掉的合取项在否定里会翻转整条的答案。`read-scope-sql` 早就
  按常量处理(`FALSE_CLAUSE` / `1 = 1`)。
- 零个操作符的字段约束 `{a: {}}` 改为拒收,按 objectstack-ai#5240 已拍板的口径(driver-sql /
  driver-memory / formula 三个后端在 objectstack-ai#5327 已经这么做,analytics 是第四道门)。它此前
  不产出任何 leaf,而「不产出」就是常量 TRUE —— 在新的吸收规则下
  `{$or: [{a: {}}, {b: 2}]}` 会从 `b = 2` 放宽成全表。三个答案里必须选一个,跟随已有
  拍板而不是另造第三个。

非对象的 `$not` 操作数 / `$and` `$or` 分支元素同样改为拒收:此前 `{$not: null}` 整条
消失(等于不筛),而在吸收规则下把它读成 TRUE 会放宽到全表 —— 两种读法都不是垃圾输入
的正当解释,`read-scope-sql` 拒收同样的形状。

`$and: []` / `$or: []` 的空组合子不在本单范围(独立裁定 objectstack-ai#5322),仍然 fail-closed 抛错,
并加了用例把它钉在抛错这一侧,免得这次改写顺手把它变成布尔单位元。

反向验证:把三个源文件 stash 掉后,新用例 48 条里 36 条失败,五条实测行逐条复现 issue
正文的「实测行」那一列。

Fixes objectstack-ai#5325


Claude-Session: https://claude.ai/code/session_01Pbu27iNUfQCHeuS551Rqo7

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Aug 6, 2026
…filter input at the door (objectstack-ai#5347, objectstack-ai#5348) (objectstack-ai#5368)

Two shapes the Filter Protocol never declared reached the drivers, and every
driver ANSWERED them — with a different answer. Both are now refused with
INVALID_FILTER / 400, on the validating walk rather than in the emitter.

objectstack-ai#5347 — `$null` with a non-boolean comparand. `FieldOperatorsSchema` declares
`$null: z.boolean()`. Measured against one row with `stage: 'won'` and one with
`stage: null`, `{ stage: { $null: 'yes' } }` returned the NULL row on
driver-sql / driver-sqlite-wasm / Turso local (IS NULL — anything but `false`),
the valued row on driver-memory's query path and driver-mongodb (IS NOT NULL —
anything but `true`), and BOTH rows through driver-memory's reference matcher,
whose two conditionals a third value satisfies neither of, so the constraint
vanished. Three readings of one declared operator; the third is new evidence the
issue's own fixture could not show. Refused on all four backends per the ruling.

objectstack-ai#5348 — an undeclared `$op` in a node position. `FilterConditionSchema` declares
three `$`-keys at a node; driver-sql compiled the rest as COLUMNS, so
`{ $where: … }` / `{ $nor: … }` produced a predicate matching nothing and
reporting nothing. Its FIELD position had refused the same class of input since
objectstack-ai#3948/objectstack-ai#4436, so one driver answered two ways depending on depth.

Both gates sit in `reduceFilterKey` / `assertFilterConditionShape`, not in the
emitters, because the emitters are skipped wholesale by a boolean identity —
`{ $or: [ {}, { $where: … } ] }` would otherwise be refused or ignored depending
on its siblings. Same placement argument as objectstack-ai#5240/objectstack-ai#5327.

`nullValueSatisfiesOperator`'s `$null` arm is tightened from `value !== false`
to `value === true`: the two are equivalent only while the refusal holds, and
the lenient spelling would silently resume answering if the gate ever moved.
`$exists` keeps its lenient read deliberately — it has no comparand gate, so
tightening it alone would create the divergence rather than close one.

driver-sqlite-wasm and cloud's local/replica TursoDriver inherit both refusals
from SqlDriver; both verified by execution, not assumed.


Claude-Session: https://claude.ai/code/session_01Pbu27iNUfQCHeuS551Rqo7

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Aug 6, 2026
…bjectstack-ai#5239) (objectstack-ai#5323)

* fix(driver-mongodb): reduce empty $and/$or/$not to their boolean identity, refusing non-nodes first (objectstack-ai#5239)

`translateFilter` passed combinator arrays through verbatim, and MongoDB
answers an empty one with neither TRUE nor FALSE but a third behaviour: it
refuses the query (`$and/$or/$nor must be a nonempty array`). So `{$and: []}`
and `{$or: []}` reached find/count/updateMany/deleteMany as a server error
carrying no ADR-0112 code, while driver-sql (objectstack-ai#5134), driver-memory and formula
all answered them as identities.

Replaced with the same STRUCTURAL three-valued reduction: reduce the whole tree
to true/false/clause first, then emit. Empty `$and` becomes TRUE (no condition);
empty `$or` becomes FALSE and emits a real zero-row condition
(`{_id: {$in: []}}`) — emitting nothing would be `{}`, which find/updateMany/
deleteMany read as EVERY document, the opposite answer. Every `$and`/`$or`
array emitted is therefore guaranteed non-empty.

Shape rejection lands in the same change and runs BEFORE any identity: measured
on main, `{$or: [new Date()]}` translated to `{$or: [{}]}` (every document) and
`{$or: 'x'}` / `{$not: null}` translated to `{}` (every document). updateMany
and deleteMany translate the same `where`, where that is data loss rather than a
wrong row count. Non-nodes now raise INVALID_FILTER / 400 naming the position;
the gate judges by PROTOTYPE, since Date/RegExp/class instances satisfy
`typeof x === 'object'` while enumerating empty.

spec is documentation only: FilterConditionSchema's contract TSDoc now states
the NULL-safe `$not` semantics ruled in objectstack-ai#5146, and filter-logic-conformance.ts
records the measured matrix for the three ruled-but-not-yet-enrolled case
families. The four FILTER_LOGIC_CASES rows objectstack-ai#5239 asks for are deliberately NOT
added: read-scope-sql and the analytics filter-normalizer, both enrolled
backends, refuse empty combinators fail-closed by design and pinned test, which
contradicts the identity ruling — escalated as objectstack-ai#5322.

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

* docs(spec): 同步轮散文校订 —— 对 main@cdfbee2f0 实测后落笔 (objectstack-ai#5239)

本 PR 的 spec 半边是契约文档,机械合并会把 base 时代的论断带上 main;
逐条对当前 origin/main 实测后校订:

- FilterConditionSchema 的 NULL-safe $not 合规段:read-scope-sql 已由
  objectstack-ai#5326 对齐(objectstack-ai#5297 关闭)、filter-normalizer 已由 objectstack-ai#5335 对齐(objectstack-ai#5325
  关闭),七个面全部一致 —— 「尚未合规、指向 objectstack-ai#5297」改写为已闭合的事实。
- 「Deliberately NOT declared here」:空组合子单位元由「两立场对峙、
  上交 objectstack-ai#5322」改为「objectstack-ai#5322 已拍板取单位元,实施在 objectstack-ai#5365(排在本 PR 之后
  合入);main 上两个 analytics 编译器今天仍拒收,故本 PR 仍不在此声明,
  声明随 objectstack-ai#5365 翻正」;{ field: {} } 由「无后端设闸」改为「objectstack-ai#5327 已闸
  四家,driver-mongodb 是唯一还在作答的后端(objectstack-ai#5376)」。
- filter-logic-conformance.ts 族 2/3 状态行同步重测:族 2 的后端阻塞
  已清零,唯余 fixture 工作;族 3 的四家闸门已落,阻塞改为表形扩展 +
  mongodb(objectstack-ai#5376)。族 1 段落一字未动 —— 由 objectstack-ai#5365 在其同步轮删除,
  已约定分工。

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

* chore(spec): gen:schema 前移 authorable-surface 锚点至合并后的 merge-base (cdfbee2)

合并 origin/main 后重建时由 gen:schema 写出(先 commit merge 再跑生成,
objectstack-ai#5370 的锚点倒退陷阱按序避开):baseRev 28ad90ecdfbee2,随锚点带入
objectstack-ai#5312 的 api/ApiEndpoint 键面。check:generated 9/9 up to date,
check:authorable-surface 绿。非手改。

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
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 size/xl tests tooling

Projects

None yet

2 participants