Skip to content

Commit 2d34f32

Browse files
os-billclaude
andauthored
feat(spec): the seven converged rule-array filter doors name the array form when they refuse the record form (#17846)
Fixes #17320 Clause-②: no — the change is to what a refusal **says**. No accept set moves in either direction, no key is added to any published payload, and the generated `json-schema/` + `authorable-surface` artifacts are byte-identical after the change (`git status` clean across two full `@objectstack/spec` builds). `check:api-surface` is green with no regeneration: the helper is module-internal and is not exported from any barrel, exactly as its model `shared/strict-object.ts` is not. ## What was wrong Seven `filter` doors converged on `z.array(ViewFilterRuleSchema)` in the objectui#6206 family. On the record form an author used to write, each produced exactly one zod issue and nothing else — measured on the built artifact before the change, at all seven: ``` [invalid_type] path=["filter"] expected=array message: Invalid input: expected array, received object ``` The prescription was already written down twice, in two places a parse never reaches: every one of the seven `.describe()` strings, and in full in the three `18.*-filter-rule-array` semantic migration entries. Nothing bridges `.describe()` into a zod issue, and this package installs no global error map. Re-verified on this branch's own head with the card's own lit control: `setErrorMap` / `z.config` under `packages/spec/src` → **0** hits; `strictObjectError` (which does exactly this bridging for the unknown-key case) → **14** hits, so the probe reaches. The population at these doors is the authors — human and AI — whose previously-legal metadata the convergence broke, which is when a refusal most needs to name the new spelling. ## The seven doors, located by declaring symbol Each was re-derived from the tree rather than trusted from the card; the card's list is **correct and complete**. The marker that separates a *converged* door from a door that was always an array is the migration pointer in its own `.describe()` — `The MongoDB-style record form is refused — see migration ...` — which occurs exactly 7 times in non-test sources, in exactly these two files: | door | declaring symbol | file | |---|---|---| | the binding every data-bound element carries | `ElementDataSourceSchema.filter` | `packages/spec/src/ui/page.zod.ts` | | `object-grid` | `ObjectGridPropsSchema.filter` | `packages/spec/src/ui/component.zod.ts` | | `object-metric` | `ObjectMetricPropsSchema.filter` | `packages/spec/src/ui/component.zod.ts` | | `object-kanban` | `ObjectKanbanPropsSchema.filter` | `packages/spec/src/ui/component.zod.ts` | | `object-calendar` | `ObjectCalendarPropsSchema.filter` | `packages/spec/src/ui/component.zod.ts` | | `element:number` | `ElementNumberPropsSchema.filter` | `packages/spec/src/ui/component.zod.ts` | | `element:record_picker` | `ElementRecordPickerPropsSchema.filter` | `packages/spec/src/ui/component.zod.ts` | The six `ComponentPropsMap` rows name those schemas, so the card's `ComponentPropsMap['x'].filter` spelling and the symbol spelling are the same door. Four other `z.array(ViewFilterRuleSchema)` keys exist in `ui/` (`RecordRelatedListProps.filter` and its Add-affordance picker, `ViewTabSchema.filter`, `ListViewShapeSchema.filter`, `FormFieldPublicPickerSchema.filter`, `ListPageSchema.filterBy`) — none carries the migration pointer, because none of them ever took the record form. They are out of this card's population and are untouched. ## The shape `shared/strict-object.ts` is the model, for the reason the card gives: guidance **derived from the schema** rather than transcribed beside it, so it cannot drift. A hand-copied sentence at seven sites is what that argues against — and this card's own subject is a prescription that fell out of step with a refusal. New module `packages/spec/src/ui/filter-rule-array.ts` exports one helper, wired through the zod-v4 `{ error }` param at all seven doors. Everything it can derive, it derives: - the rule shape `[{ field, operator, value }, ...]` is read from `ViewFilterRuleSchema`'s own shape (`_zod.def.shape`), on first refusal — never at module load, which would force the `lazySchema` while `view.zod` is still initialising under `OS_EAGER_SCHEMAS=1`, the import-cycle footgun `strictObjectError` already defers around; - the canonical operator is `normalizeFilterOperator('eq')`, the same fold the door itself runs; - the worked rewrite is computed from **the author's own record**, so the example names their fields. What stays per-call is what carries judgement rather than transcription — the same split `strictObject` draws: `surface` and the `migration` id. Both are pinned: the test holds every `migration` equal to a real entry in `MIGRATIONS_BY_MAJOR`, and holds every wired door's `surface` equal to the one its own `strictObject` declaration registered (walked out of `strictObjectDeclarations()`, with a lit control that the walk reached all seven). Fall-through is deliberate and pinned: the map answers only a plain record and returns `undefined` for everything else, as `flattenedViewOverlayFields()` does. A blanket message would overwrite the element-level issues an array author needs, which is the diagnosis this change exists to protect. ## Before / after, per door, on the BUILT artifact Seven separate readings, taken by parsing against `packages/spec/dist/ui/index.mjs` — `pnpm --filter @objectstack/spec build` run to completion (both tsup passes; `check-dts-emitted: 34/34`) before each side. **Before** — every door, exactly one issue: `[invalid_type] path=["filter"] expected=array`, *"Invalid input: expected array, received object"*. **After** — every door, still exactly one issue, still `invalid_type` at `filter`, now saying (this is `object-grid`; the other six differ only in the surface and, for `element:number` / `element:record_picker`, the migration id): > `` `filter` `` on this `object-grid` takes the ViewFilterRule ARRAY form `[{ field, operator, value }, ...]`, and this value is the MongoDB-style record form this door took before the one-filter-orthography convergence. Write one rule per record key — they AND — so this filter becomes `[{ field: 'status', operator: 'equals', value: 'active' }]`. Legacy operator shorthands (`eq`, `gt`, `notIn`, …) are accepted and normalized on parse. Full conversion table: migration `element-data-source-and-object-block-filter-rule-array`. The message **names the new spelling**: the array form, the author's own field lifted into `field`, the canonical `equals`, and the entry id. Per-door surfaces after the change: `this element data source` · ``this `object-grid` `` · ``this `object-metric` `` · ``this `object-kanban` `` · ``this `object-calendar` `` · ``this `element:number` `` · ``this `element:record_picker` ``. Per-door migration ids: the five `element-data-source-and-object-block-filter-rule-array` doors, plus `element-number-filter-rule-array` and `element-record-picker-filter-rule-array`. **Door-shaped negative controls, all seven, after the change** — unchanged from before: - an array with a bad element → one issue at `filter.0.operator`, *"Invalid option: expected one of "equals"|"not_equals"|…"* — zod's own words, no guidance text, and the array door itself says nothing at `filter`; - a string → *"Invalid input: expected array, received string"*; - a valid rule array → accepted at all seven. ## Ablation The pins resolve **`src/`**, not `dist/`: `filter-rule-array-guidance.test.ts` imports `./component.zod`, `./page.zod`, `./view.zod` relatively, and `packages/spec`'s vitest config declares no alias that would route them elsewhere. So **no rebuild leg is needed**, and both legs below changed the verdict from a source-only mutation, which is itself the proof. **Leg A — restore the two door files to their pre-change bytes** (`git checkout BASE -- THE_TWO_DOOR_PATHS`), i.e. the helper exists but nothing is wired: - on-disk proof of the mutation, read first: `ruleArrayFilterError` occurrences `page.zod.ts` 2 → **0**, `component.zod.ts` 7 → **0**; - verdict: **12 failed | 15 passed (27)** — every pin that asserts the new behaviour is red; - the 15 that stayed green are the controls, which is what a control is for: the negative-control cases (§2), the migration-registry pins (§3), and the helper's own unit pin, which leg A cannot reach. **Leg B — neutralise the helper itself** (one injected early `return undefined`): - on-disk proof: marker occurrences 0 → **1**, verified before the run; - verdict: **13 failed | 14 passed (27)** — the same 12 plus the helper unit pin. Both legs restored and **proven restored by `git hash-object` against the HEAD blob**, not assumed from an exit code: leg A `3c4942e3cbfd8071eb372b9dec91dced7682c1d1` / `65eb6d491b12e9879238bafa03c7c127e0e9ae47`, leg B `9b2f92cf38f5d3fc6534f87e6b637825319d100a`, each equal to `git rev-parse HEAD:PATH`, with `git diff HEAD` empty for those paths afterwards. Both scripts carried `trap RESTORE EXIT INT TERM` with absolute paths resolved from `git rev-parse --show-toplevel`, and both treated an empty or mismatched hash as a loud failure. Leg B's first attempt is worth recording: a `perl -0pi` quoting error wrote nothing, the marker count came back **0**, and the guard refused the run rather than reporting a green ablation over an unmutated tree. ## Changeset Measured, not assumed. Both tsup passes confirmed finished before the reading (`check-dts-emitted: @objectstack/spec - 34/34`), then `npm pack --dry-run --json` — 2012 packed files: - **positive control** (the new runtime message text): present in **18** packed files (`dist/*/index.js|.mjs`, browser builds included); - **negative control** (text that exists only in the new test file): present in **0**; - **lit control** (a pre-existing shipped string, same scan, same file list): present in **62** — so the scan reaches. `src/ui/page.zod.ts` and `src/ui/component.zod.ts` are additionally shipped **as source** by `files[]`'s `src/**/*.zod.ts`. The new helper and the new test are not packed. ⇒ published text moves ⇒ `.changeset/17320-filter-rule-array-guidance.md`, `patch`. ## Verification - `pnpm --filter @objectstack/spec test` — **472 files / 13426 tests passed**. - `pnpm --filter @objectstack/spec typecheck` — green (`tsc --noEmit`, `check:scripts-typecheck`, `check:test-typecheck`: the test layer compiles, 54 files / 259 pinned errors held). - `pnpm lint` (`eslint . --no-inline-config`, repo-wide, no narrowing) — green. - Derived gate families: `node scripts/pm/dispatch-gates.mjs --commands` → **82**; all 82 run with the exit code captured before any pipe; `--ran` reconciliation: **82 derived, 82 run, 0 NOT-MEASURED, 0 UNRUN** (a derived zero — every family recorded a code and none is 3). Five needed a second, correct invocation and are reported at their real reading, not their first: `check:doc-formula-expressions`, `check:lean-entry-closure`, `check:dual-build-cjs-loads` and `check:type-check-debt` each exited **3 = PREREQUISITE NOT MET = NOT MEASURED** and were re-run after building the closure they named (the last two after a full `turbo run build` over every workspace package); `check:react-declaration-parity` exited 1 only because `MANIFEST` was unset, and is green run as CI runs it, with the baseline ratchet clean. - Control-byte sweep over the five changed files: **0** hits for `[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]`, with the lit control (same engine, same file list, class widened by one printable byte) hitting 149 / 209 / 840 / 3133 / 48. `pnpm check:nul-bytes` green. - Commit messages swept per token, each counted separately, with a lit control file that hits every one: the eleven relation stems **0** each, `#` + digits **0**, model identifiers **0** (the only matches for a deliberately over-broad model pattern are the two required `Claude-Session:` trailers). Gate sweep derived and run at `cea666718f`; `origin/main` was merged once more afterwards (`cdfd8d142a`, disjoint files) and the pin file re-run green on that head. Merged `origin/main` before opening, as asked — PR #17835 is parked on `ui/page.zod.ts` and its hunks are untouched. ## 验收备注 Out of scope, noted, not filed — no PR or person is queued to touch these files for these reasons: - Nothing gates the `see migration ...` ids that seven `.describe()` strings already carry; a renamed or deleted entry would strand all seven silently. This change's own `migration` ids are pinned against the registry, so the coupling is checked on the new channel but not on the old one. Carrier: none today. - The second guess the card predicts — an ObjectQL AST tuple array — still lands as a bare `invalid_type` at `filter.0`, raised by `ViewFilterRuleSchema` itself rather than by the array door. Out of this card's population (the array door is the subject), and deliberately left alone so the element-level diagnosis stays zod's. - `objectStackErrorMap` (`shared/error-map.zod.ts`) does exist and does handle `invalid_type` — it is opt-in per parse (`safeParsePretty`), never installed globally. The card's "no global error map" reading is exact as written; this is a note that the package is not entirely without one, in case a future round looks for a home for cross-cutting guidance. --- _Generated by [Claude Code](https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3c86008 commit 2d34f32

5 files changed

Lines changed: 514 additions & 7 deletions

File tree

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
The seven converged rule-array `filter` doors name the ViewFilterRule array form when they refuse the record form
6+
7+
Seven `filter` doors converged on `z.array(ViewFilterRuleSchema)` in the
8+
objectui#6206 family — `ElementDataSourceSchema.filter` (`ui/page.zod.ts`) and
9+
the `object-grid` / `object-metric` / `object-kanban` / `object-calendar` /
10+
`element:number` / `element:record_picker` rows of `ComponentPropsMap`
11+
(`ui/component.zod.ts`). Each previously accepted the MongoDB-style record
12+
(`{ status: 'active' }`), and each now refuses it — measured on the built
13+
artifact, with exactly one issue apiece: `invalid_type` at `filter`, *"Invalid
14+
input: expected array, received object"*, and nothing else.
15+
16+
The prescription for that transition was already written down twice, in two
17+
places a parse never reaches: every one of the seven `.describe()` strings, and
18+
in full in the three `18.*-filter-rule-array` semantic migration entries.
19+
Nothing bridges `.describe()` into a zod issue and this package installs no
20+
global error map, so the one population whose metadata the convergence broke —
21+
the authors, human and AI, who wrote the previously-legal form — received the
22+
single sentence that does not say what to write instead.
23+
24+
Each of the seven now answers that value with the new spelling, through the
25+
zod-v4 `{ error }` param this package already uses for targeted guidance
26+
(`shared/expression.zod.ts`, `ui/view.zod.ts`, `shared/strict-object.ts`):
27+
28+
> `filter` on this `object-grid` takes the ViewFilterRule ARRAY form
29+
> `[{ field, operator, value }, ...]`, and this value is the MongoDB-style
30+
> record form this door took before the one-filter-orthography convergence.
31+
> Write one rule per record key — they AND — so this filter becomes
32+
> `[{ field: 'status', operator: 'equals', value: 'active' }]`. Legacy operator
33+
> shorthands (`eq`, `gt`, `notIn`, …) are accepted and normalized on parse.
34+
> Full conversion table: migration
35+
> `element-data-source-and-object-block-filter-rule-array`.
36+
37+
Following `strictObject`'s model rather than transcribing a sentence seven
38+
times: the rule shape is read from `ViewFilterRuleSchema`'s own shape, the
39+
canonical operator is `normalizeFilterOperator('eq')` — the same fold the door
40+
itself runs — and the worked rewrite is computed from the author's own record,
41+
so the example names their fields. A pin holds each door's `migration` id equal
42+
to a real registry entry and each door's `surface` equal to the one its own
43+
`strictObject` declaration registered.
44+
45+
⛔ No accept set moves. The doors refuse exactly the shapes they refused
46+
before, the generated `json-schema/` and `authorable-surface` artifacts are
47+
byte-identical after the change, and the map returns `undefined` for everything
48+
that is not a plain record — so an array author's element-level issues
49+
(`filter.0: Invalid option: expected one of "equals"|…`) and a non-record value
50+
(*"expected array, received string"*) still arrive in zod's own words.
51+
52+
**Shipped, which is why it carries a changeset rather than `skip-changeset`.**
53+
Measured on the built artifact after both tsup passes finished: the new message
54+
text is present in **18** published files of `npm pack --dry-run`'s 2012, the
55+
test-only text is present in **0** (negative control), and a pre-existing
56+
shipped string reaches **62** as the lit control proving the scan reaches.
57+
`src/ui/page.zod.ts` and `src/ui/component.zod.ts` are also shipped as source
58+
by `files[]`'s `src/**/*.zod.ts`.

packages/spec/src/ui/component.zod.ts

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { RETIRED_PAGE_COMPONENT_TYPES } from './page.zod';
1717
// shared source rather than re-spelled here (#6276).
1818
import { SortItemSchema } from '../shared/enums.zod';
1919
import { strictObject } from '../shared/strict-object';
20+
import { ruleArrayFilterError } from './filter-rule-array';
2021
import type { KeySetGuidance } from '../shared/suggestions.zod';
2122
// [#13855] The section → field-group reference form, shared with
2223
// `FormSectionSchema` (view.zod.ts) so one mixing rule serves both escape hatches.
@@ -1850,7 +1851,12 @@ export const ElementNumberPropsSchema = lazySchema(() => strictObject({
18501851
* array, by design). The record form is refused at `filter`; the migration
18511852
* prescription is the `element-number-filter-rule-array` semantic entry.
18521853
*/
1853-
filter: z.array(ViewFilterRuleSchema).optional()
1854+
filter: z.array(ViewFilterRuleSchema, {
1855+
error: ruleArrayFilterError({
1856+
surface: 'this `element:number`',
1857+
migration: 'element-number-filter-rule-array',
1858+
}),
1859+
}).optional()
18541860
.describe('Filter rules narrowing the aggregate — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` input in this map shares. The MongoDB-style record form is refused — see migration `element-number-filter-rule-array`'),
18551861
format: z.enum(['number', 'currency', 'percent']).optional().describe('Number display format'),
18561862
prefix: z.string().optional().describe('Prefix text (e.g. "$")'),
@@ -2230,7 +2236,12 @@ export const ElementRecordPickerPropsSchema = lazySchema(() => strictObject({
22302236
* (`ds.filter ?? props.filter`) is `ElementDataSourceSchema`'s key, not this
22312237
* entry's subject.
22322238
*/
2233-
filter: z.array(ViewFilterRuleSchema).optional()
2239+
filter: z.array(ViewFilterRuleSchema, {
2240+
error: ruleArrayFilterError({
2241+
surface: 'this `element:record_picker`',
2242+
migration: 'element-record-picker-filter-rule-array',
2243+
}),
2244+
}).optional()
22342245
.describe('Filter rules narrowing which records the picker offers — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography the array-declared `filter` doors of this map share. The MongoDB-style record form is refused — see migration `element-record-picker-filter-rule-array`. The binding-level `dataSource.filter` wins outright when both are set'),
22352246
/**
22362247
* Row order (#6276). The flat shorthand for `dataSource.sort`, and the same
@@ -2491,7 +2502,12 @@ export const ObjectGridPropsSchema = lazySchema(() => strictObject({
24912502
* `filter`; the migration prescription is the
24922503
* `element-data-source-and-object-block-filter-rule-array` semantic entry.
24932504
*/
2494-
filter: z.array(ViewFilterRuleSchema).optional()
2505+
filter: z.array(ViewFilterRuleSchema, {
2506+
error: ruleArrayFilterError({
2507+
surface: 'this `object-grid`',
2508+
migration: 'element-data-source-and-object-block-filter-rule-array',
2509+
}),
2510+
}).optional()
24952511
.describe('Base query filter — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in this map shares; lowered to the wire `$filter`. THE key, singular — not the plural misspelling. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array`'),
24962512
defaultFilters: z.unknown().optional()
24972513
.describe('Legacy base-filter fallback, read only when `filter` is absent. Prefer `filter`'),
@@ -2705,7 +2721,12 @@ export const ObjectMetricPropsSchema = lazySchema(() => strictObject({
27052721
* at `filter`; see migration
27062722
* `element-data-source-and-object-block-filter-rule-array`.
27072723
*/
2708-
filter: z.array(ViewFilterRuleSchema).optional()
2724+
filter: z.array(ViewFilterRuleSchema, {
2725+
error: ruleArrayFilterError({
2726+
surface: 'this `object-metric`',
2727+
migration: 'element-data-source-and-object-block-filter-rule-array',
2728+
}),
2729+
}).optional()
27092730
.describe('Filter the aggregation is scoped by — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in this map shares. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array`'),
27102731
format: z.string().optional().describe("Number format pattern (e.g. '0,0', '$0,0', '0%')"),
27112732
currency: z.string().optional().describe("ISO currency code (e.g. 'USD') — enables currency formatting"),
@@ -2777,7 +2798,12 @@ export const ObjectKanbanPropsSchema = lazySchema(() => strictObject({
27772798
* `filter`; see migration
27782799
* `element-data-source-and-object-block-filter-rule-array`.
27792800
*/
2780-
filter: z.array(ViewFilterRuleSchema).optional()
2801+
filter: z.array(ViewFilterRuleSchema, {
2802+
error: ruleArrayFilterError({
2803+
surface: 'this `object-kanban`',
2804+
migration: 'element-data-source-and-object-block-filter-rule-array',
2805+
}),
2806+
}).optional()
27812807
.describe('Base query filter, handed to the wire `$filter` — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in this map shares. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array`'),
27822808
/**
27832809
* Row cap (#16503 — the spec half of objectui#8172; decision batch #68,
@@ -2928,7 +2954,12 @@ export const ObjectCalendarPropsSchema = lazySchema(() => strictObject({
29282954
* `filter`; see migration
29292955
* `element-data-source-and-object-block-filter-rule-array`.
29302956
*/
2931-
filter: z.array(ViewFilterRuleSchema).optional()
2957+
filter: z.array(ViewFilterRuleSchema, {
2958+
error: ruleArrayFilterError({
2959+
surface: 'this `object-calendar`',
2960+
migration: 'element-data-source-and-object-block-filter-rule-array',
2961+
}),
2962+
}).optional()
29322963
.describe('Base query filter — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in this map shares. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array`'),
29332964
/**
29342965
* Row order for the fetched events — the same `SortItem` ARRAY form

0 commit comments

Comments
 (0)