Repository navigation
Commit 9df3934
fix(spec): the preset-comparand entry puts each carrier under the door that refuses it (#19801)
Fixes #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
**#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/`);
- #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 #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 #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 **#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 #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>1 parent f151ef2 commit 9df3934
3 files changed
Lines changed: 129 additions & 34 deletions
File tree
- .changeset
- packages/spec/src/migrations
- entries/semantic
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
10 | 10 | | |
11 | 11 | | |
12 | 12 | | |
13 | | - | |
14 | | - | |
15 | | - | |
16 | | - | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
17 | 34 | | |
18 | 35 | | |
19 | 36 | | |
20 | 37 | | |
21 | 38 | | |
22 | | - | |
23 | | - | |
24 | | - | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
25 | 43 | | |
26 | 44 | | |
27 | 45 | | |
| |||
32 | 50 | | |
33 | 51 | | |
34 | 52 | | |
35 | | - | |
36 | | - | |
37 | | - | |
38 | | - | |
39 | | - | |
40 | | - | |
41 | | - | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
42 | 69 | | |
43 | 70 | | |
44 | 71 | | |
| |||
49 | 76 | | |
50 | 77 | | |
51 | 78 | | |
52 | | - | |
53 | | - | |
54 | | - | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
55 | 92 | | |
56 | 93 | | |
57 | 94 | | |
| |||
0 commit comments