fix(spec)!: the filter doors refuse the three shapes they already declared refused (#19514) - #19750
Conversation
…Filters at parse (#19514) WIP checkpoint before the build/regeneration lap. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude <noreply@anthropic.com>
…e table (#19514) Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude <noreply@anthropic.com>
… ADR-0087 disposition (#19514) Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 6 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 136 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin f0c8425f4413540632cc2a72e6cb6dd94f018f4c && git checkout f0c8425f4413540632cc2a72e6cb6dd94f018f4c
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin c118524061a902880c6dbf9cb68c2df97c1a7694 aba7c1004694838ed0ddce2c45828df8f001c539 && git checkout -B drift-repro c118524061a902880c6dbf9cb68c2df97c1a7694 && git merge --no-ff aba7c1004694838ed0ddce2c45828df8f001c539
node scripts/docs-audit/affected-docs.mjs --json c118524061a902880c6dbf9cb68c2df97c1a7694
|
Contract reviewServed-tier: Reviewed and posted 2026-09-22T22:22Z by the at-tier review subagent the ① Derived judgments
② Semver level
③ Boundary flagsBlocking: none. Non-blocking:
Implemented-by: VERDICT: PASS Generated by Claude Code |
…iform claim (#19514) The reversal paragraph said every in-memory matcher "excludes every row". Re-measured by running all three backends at this head: driver-sql find() on the lowered { tags: ['a'] } -> THREW INVALID_FILTER / 400 (sqlite cell; the gate is assertCompilableComparand, upstream of dialect emission). Control { tags: 'a' } returned the row. driver-memory match({tags:['a']}, {tags:['a']}) -> THREW INVALID_FILTER / 400 (assertFilterConditionShape's implicit-equality arm). Control scalar comparand answered true. formula matchesFilterCondition(row, {tags:['a']}) -> false for every row, including a row whose stored value IS ['a']. Control answered true. So two backends REFUSE and one EXCLUDES. The conclusion is unchanged and stronger -- no backend answers the shape, and the stored view never returned filtered rows -- but the mechanism is now stated per backend at every site that ships: the view.zod.ts docblock, the changeset's section 1, and the view-filter-rule-scalar-operator-array-refused entry's reason and acceptanceCriteria (which project into CHANGELOG.md and the upgrade guide). The same sentence in view-filter-rule-value-shape.test.ts's docblock is corrected with it. registry.ts is the regenerated mirror of the entry. Prose only: no accept set, pin, test body or changeset level moves. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude <noreply@anthropic.com>
Contract review — round 2 (the pre-landing prose round)Served-tier: Reviewed and posted 2026-09-22T23:46Z by the at-tier review subagent the ① Derived judgments
② Semver level
③ Boundary flagsBlocking (1):
Non-blocking:
Implemented-by: VERDICT: FAIL — one blocking prose flag (the backend population), fix named above; every other settle point green. Generated by Claude Code |
第三轮派单 —— 外加 ⛔ 一条对本席刚刚贴出的那份记录的更正
⛔ 一、先更正本席自己:记录里那条「515 / 15043 复现不出来」是张冠李戴,而本席原样贴了出去记录 ①7 末尾写着:
本席实测,这条不成立 —— 因为本 PR 上从来没有过这个读数。
⭐ 而复核者自己在本轮记录 ①1 里也两次把 ⛔ 本席的过失是:照贴,没核。 本席这一班反复要求子代理「对树与 GitHub 核验,⛔ 不对自述核验」,而这一次本席把一条关于本席自己读数的指控原样发布,连一次 grep 都没跑 —— 跑一次只要几秒。⇒ 这条不适用于本 PR;记录其余各条(尤其阻塞项)不受影响,本席已逐条复核过它们的证据形状。 二、阻塞项(唯一一条):新写的散文把后端人口说小了,而且它出货五处出货点( 复核者实测:
⇒ 这与本轮要消除的缺陷同类:一句跨后端的全称断言,被一个出货后端证伪。⛔ 上一轮错在 memory 的机制,这一轮错在人口。 改法(纯散文,同样五处 +
⭐ 若你认为 三、非阻塞
四、正文与建卡 —— ⛔ 都是本席的,⛔ 不派给你
五、工作区纪律⛔ 不得在主检出 六、出货清单(⛔ 每条给读数)
⛔ 不要改 PR 正文 —— 按 Generated by Claude Code |
…nswer (#19514) The previous round fixed the mechanism sentence and, in the same sentences, introduced a false statement about the POPULATION: five shipping sites called @objectstack/formula "the one backend that answers the shape at all", the view.zod.ts docblock added "Two refusals and one exclusion: no backend selects the row the author meant", and acceptanceCriteria -- which projects into CHANGELOG.md and the protocol-18 upgrade guide -- said such a rule "has never returned filtered rows". There are FOUR shipped backends, and the fourth answers. Re-measured here, one backend at a time, on the lowered { tags: ['a'] }: driver-sql assertCompilableComparand(column, '=', value) at the bare { field: value } loop -> INVALID_FILTER / 400, and with it driver-turso, driver-sqlite-wasm and turso's remote transport. driver-memory match({tags:['a']}, {tags:['a']}) -> THREW INVALID_FILTER / 400 ("The implicit-equality comparand on field \"tags\" requires a single comparable value, but received an array"). Control scalar comparand answered true. formula matchesFilterCondition -> false for all four rows probed, including a row stored as exactly ['a']. Control answered true. driver-mongodb translateFilter({tags:['a']}) -> EMITS {"tags":["a"]} unchanged (control {tags:'a'} -> {"tags":"a"}); the engine's shared doors normalizeFilterComparandTypes and assertListComparandShapes BOTH pass the shape; and MongoDB's query semantics make that document an exact-array equality -- measured through mingo, which matches a row stored as exactly ['a'] (true) and neither 'a' nor ['a','b'] (false). A live mongod cell is NOT MEASURED: the egress proxy blocks the binary download. What is measured is the driver's compile face, the engine's shared doors, and the query semantics. So the corrected claim is narrower and true: no backend reads the array as the SCALAR the operator declares; three refuse or exclude it, and only MongoDB returns rows at all, and only for an array-valued field. The exclusivity wording is deleted at all five sites, plus a sixth in the changeset's FROM->TO section that generalised "none of these shapes has ever returned filtered rows". Prose only: the transpiled emit of view.zod.ts and view-filter-rule-value-shape.test.ts is byte-identical to the previous head, the entry's id / surface / replacement are unchanged, and no accept set, pin or changeset level moves. registry.ts is the regenerated mirror of the entry. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude <noreply@anthropic.com>
…#19514) Wording only, on top of the population fix: the view.zod.ts paragraph had "So ... So" across a sentence boundary, the test docblock had "the opposite, and the four shipped backends" where the clause is an apposition rather than a second conjunct, and the changeset's closing sentence read as if MongoDB's rows were about the same data the rule asked for. No claim moves. The transpiled emit of view.zod.ts and view-filter-rule-value-shape.test.ts is still byte-identical to 536bc37 (124,921 and 12,394 chars, same sha256), the entry file is untouched, and gen:migration-registry reproduces registry.ts byte-exact (blob b9363c8). Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Reviewed and posted 2026-09-23T02:06Z by the at-tier review subagent the ① Derived judgments
② Semver level
③ Boundary flagsBlocking (3). All three are prose, and all three ship: in
Non-blocking.
Implemented-by: VERDICT: FAIL — three blocking prose flags (F1: MongoDB 「and nothing else」; F2: 「never」 on driver-memory ≤ 17.3.0; F3: the |
…19514) Deletion only; no sentence is added and no measurement is restated. - The per-backend answer for the scalar-operator array is now stated only where the text is explicitly about the lowered equality node; the class-wide statements (the scalar entry's acceptanceCriteria, its "goes no further" sentence and closing clause, the changeset's scalar-operator-array bullet under FROM -> TO, the view docblock's "the query path refuses it" clause and the test docblock's per-backend passage) are gone. - The formula matcher is no longer counted among the backends a lowered view rule reaches, and no backend count remains. - The "only operator with table rows" claims are gone from the changeset, the icontains entry and the view docblock. - The scalar entry's surface keeps "every carrier of ViewFilterRuleSchema" and drops the enumeration after it (the seat's one authorised surface edit). - The icontains Done-when drops its "same message wherever" clause, the changeset drops "Each refusal carries its own prescription ...", and component.zod.ts drops the "ACCEPT SET back to the one the consumer already honours" clause. registry.ts regenerated by gen:migration-registry. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude <noreply@anthropic.com>
#19514) The seat's Q1 ruling (B) on round 5: the scalar entry covers all 13 scalar operators, so "None reads the array as the SCALAR the operator declares, so the earlier reading ... on MongoDB it selects by a predicate the rule never wrote." reads class-wide, where it is false (driver-memory answers contains ['a'] with rows). The whole sentence is deleted from the entry's reason; nothing else in the reason moves. registry.ts regenerated by gen:migration-registry. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude <noreply@anthropic.com>
⛔ 作废 —— 本 PR 的两份复核记录不是在契约复审档上跑的,裁决不算
怎么读出来的: 逐份读复核子代理自己的 transcript(逐条助手记录上 harness 写入的
两份作废记录首行的 原因(本席的错): 从 2026-09-23T00:34Z 起,本席派复核子代理时漏传了档位参数,子代理于是继承了本会话当时的档位。SKILL.md 明写「档位逐次派发显式传参,永不省略」,以及「未达档 ⛔ 不自审,起隔离达档子代理」。 影响: 没有任何东西是凭这两份记录落地的(两份都是 FAIL,本 PR 仍是草稿)。第四、五轮的删减以它们的发现为输入;这些删减本身由下一份记录重新裁决。本 PR 在 已改: 自本条起,每次派发都显式传档位;每份记录贴出前,先读复核子代理 transcript 的服役档,不符即不贴为裁决。 Generated by Claude Code |
Contract reviewServed-tier: Reviewed and posted 2026-09-23T07:28Z by the at-tier review subagent the ① Derived judgments
② Semver level
③ Boundary flagsBlocking (1):
Non-blocking:
Implemented-by: VERDICT: FAIL — the three narrowings, their controls, their pins, the backend and pin readings, the semver declaration and CI are all right; one shipped sentence (B1) names a tool that does not do what the sentence says on one of the three carriers the sentence itself lists, and the fix is a two-word deletion plus |
第六轮派单 —— 两处删除
先删再跑: Generated by Claude Code |
…#19514) Round 6, two deletions ordered by the seat: - B1: the scalar entry's acceptanceCriteria said "os validate and os lint report each one by path"; os lint does not report this rule for a view's filter. "and os lint" is deleted and the verb agrees with its single subject ("reports"), the one non-deletion the order allows. - N1: the attributions saying this change's review also ran driver-sql on a live PostgreSQL 16, driver-sqlite-wasm and turso's remote transport rest on records the seat voided. They are deleted from the scalar entry's reason, the changeset's "How each cell was measured" paragraph and the view.zod.ts docblock. The driver-memory 17.3.0 attributions stay. registry.ts regenerated by gen:migration-registry. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Reviewed and posted 2026-09-23T08:34Z by the at-tier review subagent the ① Derived judgments
② Semver level
③ Boundary flagsBlocking:
Non-blocking:
Implemented-by: VERDICT: FAIL — the previous record's B1 and N1 are closed by measurement, the delta is exactly the ordered deletions plus the one authorised agreement fix, and every accept-set, control, carrier, backend, pin, surface, semver and CI reading holds; one shipped sentence (B1 above) quotes a named sibling entry with four words it does not contain, and the fix is two deletions plus |
第七轮派单 —— 一处引文删两段
本席核实: 兄弟条目
先删再跑; Generated by Claude Code |
…9514) Round 7, B1: the scalar entry's reason quoted the sibling entry view-filter-rule-value-shaped-by-operator as "lowers to a bare deep-equality comparand, which every backend answers"; the sibling says "(it lowers to a deep-equality comparand)". "bare " and ", which every backend answers" are deleted from the quotation so it matches the sibling word for word, and the same two deletions are made in the test docblock. registry.ts regenerated by gen:migration-registry. Every other quotation this PR adds that is attributed to a named source was checked against that source, seam-joined; none carries a word its source lacks, so nothing else moves. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Reviewed and posted 2026-09-23T09:43Z by the at-tier review subagent the ① Derived judgments
② Semver level
③ Boundary flagsBlocking: none. Non-blocking:
Implemented-by: VERDICT: PASS — the B1 quotation now matches its sibling entry word for word and the delta moved nothing else (two emit lines in the entry and its mirror, the test file byte-identical, the registry byte-exact on regeneration); every other attributed quotation in the PR matches its source, the two remaining elisions carrying no foreign word; the three narrowings, their envelope and negative controls, every carrier, the backend cells, the pin readings, the public surface, the semver declaration and the head's CI all hold by my own measurement. |
…r that refuses it (objectstack-ai#19801) Fixes objectstack-ai#19778 Clause-②: no The shipped ADR-0087 semantic entry `filter-preset-ordering-comparand-refused` listed "page filter, component filter" among the `FilterConditionSchema` carriers. It also said "the schema door and the @objectstack/lint filter-preset-comparand rule refuse it at publish". Page and component filters are `ViewFilterRuleSchema` rule arrays, and no schema door judges a preset there. So an upgrader who swept stored pages with a schema parse found nothing and read the sweep as clean. This PR rewrites `surface`, `reason` and `acceptanceCriteria` as a three-way split, with each carrier named by its declared type and its key. It also narrows two more sentences that are false today (see below). No behaviour moves. No schema, accept set or lint rule is touched, and no export is added, removed or retyped. Every changed line in `registry.ts` is a string literal inside this one step-18 entry, which the exported `MIGRATIONS_BY_MAJOR` carries, so what moves in `dist` is prose. `packages/lint` is not touched; the lint's reach gap is **objectstack-ai#19791**. ## What the entry now says The groups list the carriers measured, not a closed partition. The grep sentence at the head of `acceptanceCriteria` is the catch-all, and it now also names a `gt`/`gte`/`lt`/`lte` lookup filter value. 1. **Slots typed `FilterConditionSchema`** are refused at parse, at the comparand's own path, and the lint rule reports them too. The slots are: - a dashboard widget filter; - a dashboard global-filter options-source filter (`optionsFrom.filter`); - a dataset filter and a dataset measure filter; - a report `runtimeFilter`, on the report or on a joined-report block; - a rollup `summaryOperations.filter`; - a `relatedListFilter`. 2. **Filters under a key the lint walks, whose declared type carries no preset check,** parse GREEN, and the lint rule is the only door. These are: - `ViewFilterRuleSchema` rule arrays: a view's `filter`, a page element's `dataSource.filter` and a page component's `filter` prop; - a Mongo-shape record typed as a loose record rather than `FilterConditionSchema`: a flow CRUD node's `config.filter`. The lint is also what refuses a preset in an ordering `[field, op, value]` triple. 3. **Filters under a key the lint does not walk** parse GREEN and lint GREEN, so neither door refuses them at publish. These are a page's `interfaceConfig.filterBy` rule array, and a lookup field's `lookupFilters`, whose ordering operators are spelled `gt` / `gte` / `lt` / `lte`. - `acceptanceCriteria` says the sweep is mechanical for groups 1 and 2 (`os validate` / `os lint`, plus a `safeParse` of the declaring schema for group 1), and **by hand** for group 3. - The hand sweep is one search per carrier: - a `filterBy` rule whose operator is an ordering one (`greater_than`, `greater_than_or_equal`, `less_than`, `less_than_or_equal`, `before`, `after`, `between`, or an alias); - a `lookupFilters` entry whose operator is `gt`, `gte`, `lt` or `lte`. These are the only ordering spellings that key accepts, and it has no `between`. - The windows come from `DATE_RANGE_PRESET_MACRO_WINDOWS`. The prose names no refinement function, no issue or PR number beyond the entry's existing ruling citations, and says nothing about `object-grid` `defaultFilters`. ## Two more sentences that were false, each changed alone - **`replacement`: "... dashboard date-filter positions ..., which is the only place any layer ever resolved them".** An analytics query's `timeDimensions[].dateRange` also accepts the preset names and resolves them. `packages/core/src/utils/analytics-date-range.ts` lowers them to a window. - Measured: `AnalyticsQuerySchema` with `dateRange: 'last_30_days'` is GREEN, and the control `"Last 7 days"` is refused at `timeDimensions.0.dateRange`. - The sentence now names both positions. The rest of `replacement` is unchanged. - **`reason`: "Ordering positions only, deliberately: equality and membership are NOT judged".** This is true of the schema door only. The lint rule's field-typed arm refuses a preset in an equality or membership position, in a filter its walk reaches, on a field it can resolve to a declared `date` or `datetime`. Where the filter binds to no object, or the field resolves to nothing, that arm cannot fire (see the binding rows below). The sentence now says which door is ordering-only, and what the arm needs. - The ruling citation, the "no D2 conversion" disposition and every other sentence are unchanged. ## Probe matrix: the prose against every cell Everything was parsed against the built `dist`, resolved through package exports: `@objectstack/spec/ui` and `/data` resolve to `packages/spec/dist/{ui,data}/index.js`, and `@objectstack/lint` to `packages/lint/dist/index.cjs`. - The rule is `{ field: 'close_date', operator: OP, value: V }` on a declared `date` field. - The preset `V` is `'last_30_days'`, or `['today', '2026-12-31']` for `between`. - The dark control is `'2026-01-01'` (or an ISO pair). It is GREEN in every cell of every row, so that column is omitted. | group | carrier: declared type and key | schema parse, preset | `@objectstack/lint`, preset | |:--|:--|:--|:--| | 1 | `DashboardWidgetSchema.filter` | REFUSED `filter.close_date.$gt` | REFUSED `dashboards[0].widgets[0].filter.close_date.$gt` | | 1 | `GlobalFilterOptionsFromSchema.filter` | REFUSED `filter.close_date.$gt` | REFUSED `dashboards[0].globalFilters[0].optionsFrom.filter.close_date.$gt` | | 1 | `DatasetSchema.filter` | REFUSED `filter.close_date.$gt` | REFUSED `datasets[0].filter.close_date.$gt` | | 1 | `DatasetMeasureSchema.filter` | REFUSED `filter.close_date.$gt` | REFUSED `datasets[0].measures[0].filter.close_date.$gt` | | 1 | `ReportSchema.runtimeFilter` | REFUSED `runtimeFilter.close_date.$gt` | REFUSED `reports[0].runtimeFilter.close_date.$gt` | | 1 | `JoinedReportBlockSchema.runtimeFilter` | REFUSED `runtimeFilter.close_date.$gt` | REFUSED `reports[0].blocks[0].runtimeFilter.close_date.$gt` | | 1 | `FieldSchema.relatedListFilter` | REFUSED `relatedListFilter.close_date.$gt` | REFUSED `objects[1].fields.parent.relatedListFilter.close_date.$gt` | | 1 | `FieldSchema.summaryOperations.filter` | REFUSED `summaryOperations.filter.close_date.$gt` | REFUSED `objects[1].fields.total.summaryOperations.filter.close_date.$gt` | | 2 | `ListViewSchema.filter`, `greater_than` / `between` | GREEN / GREEN | REFUSED `views[0].filter[0].value` / `.value[0]` | | 2 | `PageSchema` element `dataSource.filter`, both ops | GREEN / GREEN | REFUSED `pages[0].regions[0].components[0].dataSource.filter[0].value` / `.value[0]` | | 2 | `record:related_list` `filter` prop (its props schema, and `PageSchema`), both ops | GREEN, GREEN / GREEN, GREEN | REFUSED `...components[0].properties.filter[0].value` / `.value[0]` | | 2 | the same prop authored as a triple `['close_date', '>=', V]` | GREEN (`PageSchema`) | REFUSED `...properties.filter[2]` | | 3 | `PageSchema` `interfaceConfig.filterBy`, both ops | **GREEN / GREEN** | **GREEN / GREEN (0 findings)** | Controls on group 3, with the same rule: - **The slot is parsed.** A `filterBy` value `{ $x: 1 }` is REFUSED at `interfaceConfig.filterBy.0.value`. - **The key name decides.** The same rule under `interfaceConfig.filter` is refused by the lint at `pages[0].interfaceConfig.filter[0].value`, while `PageSchema` refuses the alias key. The `eq` rows back the narrowed `reason` sentence: - `FilterConditionSchema` `{ close_date: 'last_30_days' }` and `{ close_date: { $in: ['last_30_days'] } }` are both GREEN. - A widget filter `{ close_date: 'last_30_days' }` is refused by the lint at `dashboards[0].widgets[0].filter.close_date`. - A view rule `equals` preset is refused by the lint at `views[0].filter[0].value`, and a view rule `in` preset at `views[0].filter[0].value[0]`. - On a `select` field, `this_quarter` stays GREEN for both `equals` and `in`. The `replacement` sentence checks: - `DashboardSchema` `dateRange.defaultRange: 'last_30_days'` is GREEN, and the control `'last_60_days'` is refused at `dateRange.defaultRange`. - `DATE_RANGE_PRESET_MACRO_WINDOWS` is exported from `@objectstack/spec/data` with 13 keys. Rows to append to the matrix table (group column first): | group | carrier: declared type and key | schema parse, preset | `@objectstack/lint`, preset | |:--|:--|:--|:--| | 2 | flow `get_record` / `update_record` / `delete_record` node `config.filter` (a loose record), `$gt` and `$between` | GREEN (`FlowSchema`; `GetRecordConfigSchema` too) | REFUSED `flows[0].nodes[1].config.filter.close_date.$gt` / `.$between[0]` | | 3 | `FieldSchema.lookupFilters`, operator `gt` / `gte` / `lt` / `lte` | **GREEN** | **GREEN (0 findings)** | Both new rows read GREEN / GREEN with the ISO dark control. **The `lookupFilters` operator vocabulary**, each with value `'last_30_days'`: - GREEN at `FieldSchema`: `gt`, `gte`, `lt` and `lte`, plus the non-ordering `eq`, `ne`, `in`, `notIn` and `contains`. - REFUSED at `lookupFilters.0.operator`: `greater_than`, `greater_than_or_equal`, `less_than`, `after`, `before`, `between`, `>`, `>=`, `$gt` and `GT`. - The lint is GREEN on every spelling. - Key-name control: the same `{ field, operator: 'gt', value: 'last_30_days' }` under a view's `filter` is refused by the lint at `views[0].filter[0].value`. **Binding rows**, which back the tightened `reason` sentence: - A widget whose `dataset` binds to `deal` with `{ close_date: 'last_30_days' }` is REFUSED at `dashboards[0].widgets[0].filter.close_date`. - The same widget with `dataset: 'ghost'`, which resolves to nothing, is GREEN. - A bound widget with an undeclared field, `{ ghost_date: 'last_30_days' }`, is GREEN. - A bound `select` field with `this_quarter` is GREEN. - An unbound widget with an ordering `{ close_date: { $gt: 'last_30_days' } }` is REFUSED, because arm 1 is field-agnostic. **True on both trees** (replaces the round-2 paragraph). The whole matrix, the `eq` and `replacement` rows, and the rows above were run on two builds: - this branch, with `dist` built at `bff19cc8b9` (identical to head `a82423396b` under `packages/`); - objectstack-ai#19750's head at `07d787ce96`. The outputs are byte-identical, and the round-2 matrix output is unchanged. - **Tree control.** `FilterConditionSchema` on `{ name: { $icontains: '' } }` is GREEN here and REFUSED at `name.$icontains` on objectstack-ai#19750, so each run read its own `dist`. - **`origin/main` at `de4ed33fd5`.** `git diff --quiet 2cf9db7 de4ed33 -- packages/spec/src/data packages/spec/src/ui packages/spec/src/automation packages/spec/src/migrations packages/lint` exits 0, so none of the probed surfaces moved on `main`. ## Publishing reach `src/migrations/entries/**` is generator input. `registry.ts` is what `dist` is built from, and it was regenerated with `pnpm --filter @objectstack/spec gen:migration-registry`, never hand-edited. Counts use `grep -o -F | wc -l` over `dist/index.js`, `dist/index.mjs`, `dist/browser/index.js` and `dist/browser/index.mjs`. "Before" is base `2cf9db7c43`. For the new sentences, it is objectstack-ai#19750's `a17615e3ad` build, whose copy of this entry is byte-identical to base's (`git diff --quiet` exit 0); `registry.ts` at base holds 0 of them. | string | before | after | |:--|--:|--:| | `page filter, component filter, rollup filter` (removed) | 4 | **0** | | `the schema door and the @objectstack/lint filter-preset-comparand rule refuse it at publish` (removed) | 4 | **0** | | `report each one by path, so the sweep is mechanical` (removed) | 4 | **0** | | `which is the only place any layer ever resolved them` (removed) | 4 | **0** | | `Ordering positions only, deliberately: equality and membership are NOT judged` (removed) | 4 | **0** | | `is a rule array under a key the lint does NOT walk: it parses GREEN and lints GREEN, so neither door refuses it at publish` (new) | 0 | **4** | | `is two doors with different reach, not one: the FilterConditionSchema parse refuses the shape on the slots typed that way` (new) | 0 | **4** | | `Group (3) is BY HAND, because nothing reports it` (new) | 0 | **4** | | `stay fully legal where a layer resolves them to a window` (new) | 0 | **4** | | `Ordering positions only at the schema door, deliberately: it judges no equality` (new) | 0 | **4** | | `compared false against every row: HTTP 200` (dark control, unchanged sentence) | 4 | 4 | | `is a rule array under a key the lint DOES walk` (zero control) | 0 | 0 | **Round 3.** Counted with the same four bundles. "Before" is the build at `775de57ad9`, and "after" is the build at `bff19cc8b9`. | string | before | after | |:--|--:|--:| | `and by its key, in three groups` (removed) | 4 | **0** | | `is a rule array under a key the lint does NOT walk: it parses GREEN and lints GREEN, so neither door refuses it at publish` (removed) | 4 | **0** | | `which makes it the only door for a ViewFilterRuleSchema rule array` (removed) | 4 | **0** | | `because nothing reports it: search every` (removed) | 4 | **0** | | `position on a declared date or datetime field, and on a temporal field` (removed) | 4 | **0** | | `a list of what was measured, not a closed partition` (new) | 0 | **4** | | `a Mongo-shape filter record typed as a loose record rather than FilterConditionSchema` (new) | 0 | **4** | | `whose ordering operators are spelled gt / gte / lt / lte` (new) | 0 | **4** | | `the only ordering spellings that key accepts` (new) | 0 | **4** | | `on a field it can resolve to a declared date or datetime` (new) | 0 | **4** | | `compared false against every row: HTTP 200` (dark control) | 4 | 4 | | `a list of what was measured, and a closed partition` (zero control) | 0 | 0 | **Cumulative against base `2cf9db7c43`:** - The five round-2 removed strings still read 0. - Four of round 2's five new sentences still read 4. The fifth, the `does NOT walk` sentence, was reworded this round and is counted as removed above. - The changeset's reach bullet now says nine sentences. ## Verification Head is `a82423396b`. Every exit code was captured before any pipe. - `pnpm --filter @objectstack/spec check:migration-registry` → exit 0, "current (232 semantic, 207 retired-key, 183 retired-def)". Every changed line in `registry.ts` is a string literal inside this entry. - `pnpm --filter @objectstack/spec check:generated` → exit 0, "All 15 generated artifacts are up to date", with a declaration stamp match. - `pnpm --filter @objectstack/spec exec vitest run --project local --maxWorkers=2` → exit 0: 517 test files passed (517); 15091 tests passed, 1 todo. - `pnpm --filter @objectstack/spec typecheck` → exit 0, `check:test-typecheck: OK`. - Gates derived with `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` from this worktree: **80 derived**. On `--ran` reconciliation: **79 run, exit 0; 1 NOT MEASURED; 0 UNRUN**. - The NOT MEASURED one is `pnpm check:dual-build-cjs-loads`, exit 3 `PREREQUISITE NOT MET`. It needs a repo-wide build, which is CI's run. ⛔ It is not a pass. - `pnpm check:lean-entry-closure` exited 0 after the `@objectstack/objectql` closure was built. - The derivation warned **STALE TREE**: the branch is behind `origin/main` `de4ed33fd5`, and one gate file changed across that range (`scripts/check-spec-docblock-symbol-anchors.mjs`). - That gate and its `--self-test` were re-run from `main`'s copy on the merged generation, and both exit 0. The merged generation is a throwaway worktree at an unpushed merge commit of tree `a409220cc1`, built with `git merge-tree --write-tree HEAD origin/main`. - The same merged tree derives the identical 80 families, with no stale warning. - The branch is not merged: no conflict forces it. - The five artifact-roster gates whose roster sits under a touched directory all exit 0: `check-changeset-fixed.mjs`, `check:meta-url-spelling`, `check:authz-resolver`, `check:error-code-casing`, `check:filter-alias-parity`. - A control-byte self-scan of the three changed files finds 0 (grep exit 1). - Narrowed eslint on the two changed `.ts` files reports 2 files, 0 errors, 0 warnings. - The population is the flat config's ts/js object. - `--print-config` shows no `parserOptions.project` or `projectService`, so linting is not type-aware. - The repo-wide `pnpm lint` is CI's. - CI at `a82423396b`, on a single read: 35 check-runs, 32 success, 3 skipped, 0 failure. ## Acceptance notes - `FieldSchema.lookupFilters` was reported last round as outside both doors. It is now named in the entry's group 3, and the by-hand clause covers the operator spellings it actually takes. The lint-side fix for both group-3 carriers is **objectstack-ai#19791**. - A flow CRUD node's `config.filter` now sits in group 2. It is lint-refused, so it was always swept mechanically; the prose now says so. - `origin/main` moved to `de4ed33fd5` after this branch's base. None of the probed surfaces moved, and the one gate file that changed was re-run on the merged generation. Not merged. - When objectstack-ai#19791 lands, group 3's sentences and the by-hand clause go stale in the safe direction, for both carriers. That round owes one more edit and regeneration of this entry. --- _Generated by [Claude Code](https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr)_ --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…t cannot evaluate instead of answering every row (objectstack-ai#19833) Fixes objectstack-ai#19810 Clause-②: no The analytics draft-data preview (`packages/services/service-analytics/src/preview-evaluator.ts`, the ADR-0037 P3 Live Canvas path) answered **true for every row** for any `where` operator its switch had no case for. A drafted chart therefore silently IGNORED those filters and CHANGED at publish, where the real filter doors apply them. ## The enumeration, read at source before any edit At `origin/main` `c1dfa5241b`, `matchOp`'s switch carried exactly TEN cases and one default arm: | | | |---|---| | Evaluated (10) | `$eq`, `$ne`, `$gt`, `$gte`, `$lt`, `$lte`, `$between`, `$in`, `$nin`, `$contains` | | Default arm (`:109`) | `default: return true; // unknown operator — permissive (preview, reads only)` | | Answered for EVERY row | `$notContains`, `$startsWith`, `$endsWith`, `$icontains`, `$null`, `$exists` (the rest of `FILTER_OPERATORS`), the staged `$like` / `$ilike`, and any typo | The card's premise holds exactly as filed. The combinators `$and` / `$or` / `$not` were and remain handled by `matchesWhere` itself. ## The repair, and which contract it matches **Fail-closed by REFUSING** — `INVALID_FILTER` / `400`, through `filter-normalizer.ts`'s already-exported `invalidFilterError`. **No new error code and no new exported symbol** (the module's five exports are byte-identical before and after). Three candidate behaviours, and why refusal: - **answer true** — the defect; - **exclude the row** — makes the preview merely DIFFERENT from publish (zero rows where publish draws numbers). That is precisely the silent shape `lowerPreviewDateRange` abolished on this same evaluator (objectstack-ai#16322), so it trades one invisible divergence for another; - **refuse** — the only one that makes the disagreement VISIBLE to the author who can fix it. The yardstick is what the real filter doors do, read rather than invented: - `driver-memory`'s `uncompilableFieldOperatorError` (objectstack-ai#5345): "It is refused rather than dropped: a predicate that compiles to nothing does not narrow the query, it WIDENS it — the aggregate is then computed over rows the filter excluded, and a chart drawn over them looks like a working chart (objectstack-ai#3948, objectstack-ai#4286/ADR-0078)." - `service-analytics` **already** refuses `$like` / `$ilike` this way. From the `FILTER_OPERATORS` docblock's own face table: "`driver-mongodb`, `objectql` `having`, `service-analytics` — REFUSE, loudly, in the ADR-0112 `INVALID_FILTER` envelope". This change puts the preview face on the posture its own package already holds. - The triage note asked the preview to keep the refusal PR objectstack-ai#19750 / objectstack-ai#19514 gave the two filter doors for an empty or non-string `$icontains` comparand. It is kept the strong way round: the preview does not evaluate `$icontains` at all, so there is no comparand for it to disagree about — every spelling of it refuses. Two structural points, both copied from how this defect class was closed elsewhere: 1. **The vocabulary and the evaluator are ONE table.** The switch becomes a `Map` whose keys ARE what this face accepts — the shape `memory-analytics`' `MONGO_TO_CUBE_OPERATOR` took for the identical defect: "adding a row here is the only way to widen what this face accepts, and forgetting to add one is a loud refusal rather than a wrong number." A `Map` and not an object literal, so a constraint key naming an `Object.prototype` member cannot resolve to an inherited function and be called as a predicate. 2. **The gate is row-independent.** A per-row refusal only fires if some row reaches it, so a pending seed draft holding ZERO rows — the state a draft is authored in — would have answered an empty chart for a filter it cannot evaluate. The `where` tree is walked once before any row is read, the way `driver-memory`'s `assertFilterConditionShape` runs ahead of that driver's lowering. **⛔ No case was added, deliberately.** The default arm is the defect; adding `$icontains`, `$startsWith` and `$endsWith` would have left the next unhandled operator in exactly the same state, which is why the table and not a case list is the repair. Growing the arms is separate work with its own ordering already ruled: the `FILTER_OPERATORS` docblock's objectstack-ai#6520 constraint — a name must not land ahead of its evaluators — reads the same in this direction, so an arm joins the table in the PR that measures it against the shared text/temporal conformance kits. This face is not enrolled in `FILTER_TEXT_CASES` today, and its one shipped text arm (`$contains`) is itself off that contract (see Acceptance notes). ## Evidence — both directions Command, identical in both states: ``` pnpm --filter @objectstack/service-analytics exec vitest run --maxWorkers=2 \ src/__tests__/preview-unevaluable-operator.test.ts ``` **BEFORE** — `preview-evaluator.ts` restored to `c1dfa5241b` on disk (blob `227496af` confirmed on disk against `git rev-parse BASE:path`; marker counts `default: return true` = 1, `PREVIEW_FIELD_OPERATORS` = 0), the test file and everything else at HEAD: ``` Test Files 1 failed (1) Tests 14 failed | 17 passed (31) FAIL ... > does NOT answer the row that `name $icontains "acme"` excludes AssertionError: expected [ 'Acme Corp', 'Globex' ] to not include 'Globex' FAIL ... > refuses it in the ADR-0112 `INVALID_FILTER` / 400 envelope AssertionError: expected undefined to be an instance of Error FAIL ... > refuses over an EMPTY seed draft too — the walk is not a function of the data FAIL ... > refuses inside `$or`, `$and` and `$not` arms FAIL ... > refuses a constraint key that names an Object.prototype member FAIL ... > $notContains / $startsWith / $endsWith / $icontains / $null / $exists / $like / $ilike is refused, never answered for every row (8 rows) FAIL ... > refuses the drafted selection instead of charting every seed row AssertionError: promise resolved "{ rows: [ { …(2) }, { …(2) } ], …(1) }" instead of rejecting ``` `expected [ 'Acme Corp', 'Globex' ] to not include 'Globex'` is the card's claim measured: `Globex` does not match `name $icontains 'acme'`, and the preview charted it anyway. The restore leg was verified by hash, not by an exit code: on-disk blob back to `0e1bf302` = `HEAD:path`, `git diff HEAD` empty. **AFTER** — same command, tree at HEAD: ``` Test Files 1 passed (1) Tests 31 passed (31) ``` **The unchanged direction.** The 17 tests that pass in BOTH states are the regression guard, and they are meant to: a fail-closed default that starts rejecting rows which used to match correctly is the mirror-image defect. They assert both directions (a matching row still matches, a non-matching row still does not) for every one of the ten evaluated arms, plus the `$lte` bare-day rule (objectstack-ai#3777), implicit equality, `$and`, `$or`, `$not`, and an absent `where`. Every predicate body is byte-for-byte the `case` it replaces. ## Checks run locally at `2deda8dab5` - `pnpm --filter @objectstack/service-analytics test` — **114 files / 2442 tests passed** - `pnpm --filter @objectstack/service-analytics run typecheck` — exit 0 - `pnpm --filter '@objectstack/service-analytics^...' build` — exit 0 (dependency closure) - `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran ...` — **60 derived families accounted for: 57 run green, 3 NOT MEASURED** (`check:dual-build-cjs-loads`, `check:lean-entry-closure`, `check:type-check-debt` each exit 3, PREREQUISITE NOT MET — they read a whole-workspace `pnpm build`, which is CI's Build Core job). Among the 57: `check:where-matcher` (417 matchers, 0 silently-wrong), `check:nul-bytes`, `check:issue-citations`, `check:doc-authoring`, `check:empty-changeset`, `check:test-source-alias`, `check:undeclared-dep-imports`. - The four roster gates whose ledger sits under a directory this diff touches, read and run rather than assumed silent: `check-changeset-fixed`, `check:authz-resolver`, `check:error-code-casing`, `check:filter-alias-parity` — all exit 0. - `eslint . --no-inline-config` — the whole repo, not a narrowing: **7022 files, 0 errors, 0 warnings**, run at this PR's final commit. - Control-character self-scan over all three changed files: no hits. A changeset is included: `@objectstack/service-analytics` is published and this changes its runtime behaviour. ## Acceptance notes — found in passing, NOT fixed here 1. **`$contains` in this same file folds case, and the contract says it must not.** `matchOp`'s `$contains` arm is `String(value ?? '').toLowerCase().includes(String(expected ?? '').toLowerCase())`. `filter-text-conformance.ts` records that `$contains` / `$notContains` / `$startsWith` / `$endsWith` "compare CASE-SENSITIVELY" and that `driver-memory` moved its two folding faces onto the case-exact answer in objectstack-ai#6682. The same line also coerces a non-string stored value, which the objectstack-ai#14079 ruling type-gates. So the preview answers `$contains` differently from every published face — the same preview-vs-publish divergence this card is about, one arm over. ⛔ Deliberately untouched: this PR's second evidence direction is that the ten evaluated arms do not change behaviour. 2. **An undeclared `$`-key in a NODE position is silently read as a field name.** `matchesWhere` handles `$and` / `$or` / `$not` and falls through everything else to implicit equality, so `{ $nor: [...] }` compares `row['$nor']` and excludes every row without a word. The published path refuses it (`unknownLogicalOperatorError`). Fails closed rather than open, so it is not this card's harm — but it is silent. 3. **An empty field constraint `{ name: {} }` matches every row.** No operator keys, so the inner loop never runs. `driver-memory` refuses this shape (`emptyFieldConstraintError`, objectstack-ai#5240): "`{ status: {} }` did not mean 'no rows', it meant 'rows whose status is anything'". This one IS answer-true-shaped, on the same evaluator, and is outside the operator vocabulary this card closes. --- _Generated by [Claude Code](https://claude.ai/code/session_01AhQASwqJr2Z7XfGWUdvnbF)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Part of #19514
Clause-②: no
5786365089,5790830105); notice5790486118names two earlier records that do not count. This body does not restate them.What changes
Three accept-set narrowings in
@objectstack/spec.equals,not_equals,greater_than, …) is refused at parse when itsvalueis an array. The refusal names the list operator to use instead. Two carve-outs stay accepted: an omittedvalue, and the unary operators (is_empty,is_not_empty,is_null,is_not_null).icontainscomparands the conformance table rejects. The empty string and a non-string comparand are refused at both filter doors: the view rule and the$dialect. Both doors callisRefusedTextComparand.ObjectGridProps.defaultFiltersnarrows from an unconstrained value to the same rule-array shape asfilter. Per triage's rider, only the narrow-to-the-declared-shape arm is taken; refusing the key outright is not in this diff.Migration
Three ADR-0087 semantic entries:
view-filter-rule-scalar-operator-array-refused,filter-icontains-comparand-refused-at-parseandobject-grid-default-filters-rule-array. Each entry'sreasonandacceptanceCriteriasay what an upgrading author meets.Changeset
minoron@objectstack/specwith a BREAKING banner, under the launch-window convention.维护者速读
icontains跟空字符串或非字符串;表格defaultFilters写成不合规的形状。minor加 BREAKING 标注),附三条迁移说明,会进升级指南。🤖 Generated with Claude Code
https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1