Commit 2d34f32
feat(spec): the seven converged rule-array
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>filter doors name the array form when they refuse the record form (#17846)1 parent 3c86008 commit 2d34f32
5 files changed
Lines changed: 514 additions & 7 deletions
File tree
- .changeset
- packages/spec/src/ui
| 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 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
17 | 17 | | |
18 | 18 | | |
19 | 19 | | |
| 20 | + | |
20 | 21 | | |
21 | 22 | | |
22 | 23 | | |
| |||
1850 | 1851 | | |
1851 | 1852 | | |
1852 | 1853 | | |
1853 | | - | |
| 1854 | + | |
| 1855 | + | |
| 1856 | + | |
| 1857 | + | |
| 1858 | + | |
| 1859 | + | |
1854 | 1860 | | |
1855 | 1861 | | |
1856 | 1862 | | |
| |||
2230 | 2236 | | |
2231 | 2237 | | |
2232 | 2238 | | |
2233 | | - | |
| 2239 | + | |
| 2240 | + | |
| 2241 | + | |
| 2242 | + | |
| 2243 | + | |
| 2244 | + | |
2234 | 2245 | | |
2235 | 2246 | | |
2236 | 2247 | | |
| |||
2491 | 2502 | | |
2492 | 2503 | | |
2493 | 2504 | | |
2494 | | - | |
| 2505 | + | |
| 2506 | + | |
| 2507 | + | |
| 2508 | + | |
| 2509 | + | |
| 2510 | + | |
2495 | 2511 | | |
2496 | 2512 | | |
2497 | 2513 | | |
| |||
2705 | 2721 | | |
2706 | 2722 | | |
2707 | 2723 | | |
2708 | | - | |
| 2724 | + | |
| 2725 | + | |
| 2726 | + | |
| 2727 | + | |
| 2728 | + | |
| 2729 | + | |
2709 | 2730 | | |
2710 | 2731 | | |
2711 | 2732 | | |
| |||
2777 | 2798 | | |
2778 | 2799 | | |
2779 | 2800 | | |
2780 | | - | |
| 2801 | + | |
| 2802 | + | |
| 2803 | + | |
| 2804 | + | |
| 2805 | + | |
| 2806 | + | |
2781 | 2807 | | |
2782 | 2808 | | |
2783 | 2809 | | |
| |||
2928 | 2954 | | |
2929 | 2955 | | |
2930 | 2956 | | |
2931 | | - | |
| 2957 | + | |
| 2958 | + | |
| 2959 | + | |
| 2960 | + | |
| 2961 | + | |
| 2962 | + | |
2932 | 2963 | | |
2933 | 2964 | | |
2934 | 2965 | | |
| |||
0 commit comments