feat(spec): converge ComponentPropsMap['element:number'].filter onto the ViewFilterRule array form (#12039, ui#6206 Option B) - #14404
Conversation
…the ViewFilterRule array form (ui#6206 Option B) Accept-set change on one props-map entry: `filter` was `FilterConditionSchema` (the MongoDB-style record), now `z.array(ViewFilterRuleSchema)` — the one filter orthography every sibling `filter` input in the map already declares. Sequenced consumer-first per the 2026-08-25 Option-A ordering ruling: the objectui pin (d8ec8d6d) carries objectui#6828, re-measured before this move. ADR-0087 semantic entry `element-number-filter-rule-array` carries the prescription; `ElementNumberPropsParsed` declared and the Iso818 pin deleted with its receipt (ADR-0122); orthography tests added. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GDA48PuRFrHyRfdkBz8m21
… fixture to the array orthography; regenerate api-surface, export-origins, docs The ruled migration sweep found exactly one first-party author of the record-form `filter` on `element:number` — `component.test.ts`'s "should accept full number props" fixture — and zero in examples/, skills/, create-objectstack, content/docs/, packages/apps/. The entry and changeset now say so. `check:generated --fix` proved three artifacts stale and regenerated them: api-surface and export-origins carry the new `ElementNumberPropsParsed` export, the docs reference carries the array shape. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GDA48PuRFrHyRfdkBz8m21
📓 Docs Drift Check4 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. What this run could not see
Coarse fallback — 128 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 bc118cab41209b26b8cbf393c8c767de9df9edb1 && git checkout bc118cab41209b26b8cbf393c8c767de9df9edb1
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 5ec4f6fa4c60e0e0a40986ee6a5d3a4b4b380c6c 8068a7f317684f55c70fcc33e182ea0d67395cb7 && git checkout -B drift-repro 5ec4f6fa4c60e0e0a40986ee6a5d3a4b4b380c6c && git merge --no-ff 8068a7f317684f55c70fcc33e182ea0d67395cb7
node scripts/docs-audit/affected-docs.mjs --json 5ec4f6fa4c60e0e0a40986ee6a5d3a4b4b380c6c |
Fixes #12039
Key 2 of the card, and the half that completes it (Key 1 landed via PR #12120):
ComponentPropsMap['element:number'].filterconverges onto theViewFilterRulearray form.Ruling of record (implemented exactly; not re-decided)
filterbecomesz.array(ViewFilterRuleSchema).optional(), the shape every otherfilterinput incomponent.zod.tsalready declares (record:related_list, its Add-affordance picker)..objectui-shaonorigin/mainisd8ec8d6d4f011b11c8eb1e6dbd364ef206711391, which carries it (comments 5492894409, 5502817852).Step 0 — measurement at the pin (before any edit)
Premise re-verified on
origin/main@8094834at fetch time:packages/spec/src/ui/component.zod.ts:1802readfilter: FilterConditionSchema.optional().describe('Filter criteria')insideElementNumberPropsSchema; siblings at lines 1089 / 1125 readz.array(ViewFilterRuleSchema).At objectui pin
d8ec8d6d…(cd ../objectui && git fetch origin d8ec8d6d…, read withgit show PIN:PATH, nothing checked out in the shared tree):Adapter lowers on the aggregate path —
git show PIN:packages/data-objectstack/src/index.ts,aggregate()at lines 4841–4927: the analytics branch readspayload.where = Array.isArray(params.filter) ? translateFilterArray(params.filter) : params.filter(lines 4924–4926, the objectui#6828 change, with its header comment naming objectui#6302). The spec-shape branch (where) stays strict by the 2026-08-30 option-A ruling and is not the element's path. PASS.Pin test exists and covers rule-object arrays —
git show PIN:packages/data-objectstack/src/aggregate-filter-lowering.test.tssucceeds; cases cited: "translates a single rule into an AST tuple" ([{ field: 'stage', operator: 'equals', value: 'won' }]→['stage', '=', 'won']), "the lowered value passes the AST gate the raw one fails" (isFilterAST(rules)false → true after lowering), "maps operator aliases the way the find() path does", "joins several rules withand", "lowers rules SPREAD into a logical node", and the cross-path parity block "produces the SAMEwhereas the AST-tuple equivalent". PASS.Renderer is an opaque pass-through —
git show PIN:packages/components/src/renderers/basic/elements.tsx:ElementNumberRendererdeclaresfilter?: unknown(line 373) and callsadapter.aggregate(props.object, { field, function: props.aggregate, groupBy: '_all', filter: props.filter })(lines 402–407); the fallbackadapter.find(props.object, { $filter: props.filter })(line 419) goes through the sametranslateFilterArrayviaconvertQueryParams. Nothing between the authored array and the adapter rewrites it. PASS.objectstack analytics door, replayed from PR feat(spec): converge ComponentPropsMap['object-grid'].data onto ViewDataSchema (ui#6207 Option A); record the ui#6206-B measurement fork #12120's Key 2 section (tsx script over
service-analyticssrc after building its dependency closure —pnpm --filter '@objectstack/service-analytics^...' build, VERDICT command-exit 0):isFilterAST([{ field: 'stage', operator: 'equals', value: 'won' }])→falseisFilterAST([['stage', '=', 'won']])→truelowerAnalyticsWhere({ where: [{ field: 'stage', operator: 'equals', value: 'won' }] })→ throws[analytics] received a 'where' array that is not a filter: …lowerAnalyticsWhere({ where: [['stage', '=', 'won']] })→{ "stage": "won" }Expected and acceptable: the door itself is unchanged by design (raw rule-object array refused, AST tuples accepted). What changed since the recorded fork is reading 1 — the adapter now lowers before the wire — so the end-to-end chain is authored array → adapter lowering → AST → accepted. The stop condition (readings 1 or 2 failing) did not trigger.
The change
packages/spec/src/ui/component.zod.ts— theelement:numberentry'sfilteronly:z.array(ViewFilterRuleSchema).optional()with a describe that names the orthography and the migration id;ViewFilterRuleSchemawas already imported.FilterConditionSchemastays imported becauseelement:record_picker.filter(now line 2141) still uses it — observed, not changed, per the dispatch.ViewFilterRuleSchemanormalizesoperatoron parse (input ≠ infer —ViewFilterRuleParsedalready exists for that reason), soElementNumberPropsSchemaleaves the isomorphic pin family:ElementNumberPropsParseddeclared, theIso818pin deleted with its receipt, the pin count 836 → 835 in the header, the test title and the assertion.packages/spec/src/migrations/entries/semantic/18.element-number-filter-rule-array.ts(surface / replacement / reason / acceptanceCriteria, following Key 1's18.object-grid-data-view-data-converged.ts);registry.tsregenerated withgen:migration-registry(144 semantic entries; Key 1'sobject-grid-data-view-data-convergedand feat(spec): refuse undeclared keys onaddressandlocationvalues —AddressSchema/LocationValueSchemastrict (#13802) #14335'saddress-location-value-unknown-keys-refusedboth present). Changeset.changeset/element-number-filter-rule-array.md:@objectstack/specminor,**BREAKING**, FROM → TO block, and theadr-0087: registered element-number-filter-rule-arraymarker.packages/spec/src/ui/component.test.ts, new describe block): array form accepted and echoed (the card's acceptance criterion); the realViewFilterRuleSchemais what is wired (eqnormalizes toequals; a scalarinvalue is refused atfilter.0.value); the record form refused at thefilterpath withcode: 'invalid_type',expected: 'array'(both the plain record and the operator-object record); an orthography pin thatelement:numberandrecord:related_listgive the same verdicts on the same value; a multi-rule positive control with a realinrule.check:generated --fixafter proving exactly three stale:api-surface/ui.json+export-origins/ui.json(the newElementNumberPropsParsedexport) andcontent/docs/references/ui/component.mdx(the array shape).authorable-surfacewas already green (the key set did not change). No hand-docs edit: the only prose mention ofelement:numberundercontent/docs(ui/pages.mdx:187) lists element names and says nothing about the filter shape.Ruled migration check — the sweep
Commands (tracked files on this branch):
Result: one in-repo author of the props-level record form —
packages/spec/src/ui/component.test.ts"should accept full number props" (filter: { status: 'paid' }), rewritten in this PR to[{ field: 'status', operator: 'equals', value: 'paid' }](fixture triage: it used the form this entry alone accepted; its assertions are about format/prefix/suffix and still hold, plus one on the parsed filter). The threepage.test.tshits authordataSource.filter(ElementDataSourceSchema, a different key and schema — not this entry). Zero authors inexamples/,skills/,packages/create-objectstack/,content/docs/(other than the name list),packages/apps/. Out-of-repo authors get the prescription via the ADR-0087 entry.Verification
Union derived with
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack(no paths; stderr stamped "derived from the tree of 'objectstack-ai/objectstack' at commit 8068a7f"): 74 commands, each run underscripts/pm/os-verify-lock.shthrough a runner that redirects output to a per-command log and captures the exit code before any pipe. HEAD for every reading below:8068a7f31(git rev-parse --short HEADafter the last commit; the tree was clean throughout).Named must-haves, each on
8068a7f31:pnpm --filter @objectstack/spec run typecheck—tsc --noEmitclean,check:scripts-typecheckclean, "check:test-typecheck: OK — @objectstack/spec's test layer compiles under packages/spec/tsconfig.test.json; 54 file(s) / 262 error(s) / 146 pinned signature(s) held" (VERDICT command-exit 0; re-run after the last commit).pnpm --filter @objectstack/spec exec vitest run --maxWorkers=2 src/ui/component.test.ts src/type-alias-convention.pin.test.ts— Test Files 2 passed (2), Tests 238 passed (238).pnpm --filter @objectstack/spec exec vitest run --maxWorkers=2(the full spec suite) — Test Files 450 passed | 1 skipped (451); Tests 12111 passed | 1 skipped (12112), Duration 325.86s, VERDICT command-exit 0.pnpm --filter @objectstack/spec check:generated— "✓ All 15 generated artifacts are up to date" (that run includescheck:migration-registry✓,check:spec-changes✓,check:upgrade-guide✓,check:docs✓,check:authorable-surface✓,check:test-typecheck✓).check:api-surface✓ andcheck:export-origins✓ (exit 0 on the rebuiltdist);check:authorable-surface✓ ("🔒 authorable-defaults/ verified against upstream d62f990 — 1236 default(s) unchanged"; no anchor hand-edited,baseRevleft to lag);scripts/check-adr-0087-registration.mjs— "✓ check-adr-0087-registration: 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition … registered element-number-filter-rule-array (new here)";check:doc-authoring— "✓ doc authoring guard: 14375 customer-facing string(s) across 705 spec sources clean — no internal issue-id references";check:nul-bytes— "check-nul-bytes: OK (scanned 7843 text file(s) -- 7843 tracked, 0 untracked-not-ignored; skipped 7 binary; no raw ASCII control bytes).".The rest of the 74-command union: 72 of 74 exit 0 on
8068a7f31, per-command exit codes captured from redirection. Readings that first printed PREREQUISITE NOT MET / "dist is OLDER than src" (their own words: nothing measured, not a finding) were re-run after the prerequisite was built —pnpm --filter '@objectstack/lint...' build, thenturbo run build --filter='./packages/*' --filter='./packages/*/*'(70/70 tasks successful, VERDICT command-exit 0) — and are green:check:doc-formula-expressions,check:doc-security-posture,check:browser-reachable-entries,check:dual-source-exports,check:entry-nameability,check:exported-any,check:skill-examples("✅ 259 prose examples type-check across 3 surface(s)"),check:dev-prereqs("✓ 67 package build artifacts present"),check:dual-build-cjs-loads("102 published require entry point(s) across 66 package(s) load"),check:type-check-coverage("67/78 workspace packages type-checked"),check:type-check-debt("27 ledger entr(ies) re-measured … none above its recorded number").check:query-options-erasureran alone under the lock after a first attempt was cut by the container's foreground cap: "✓ query-options-erasure ratchet holds: 67 unswept non-test site(s) in 17 file(s), none new". The ratchet families (check:slot-lookup,check:query-options-erasure,check:type-check-debt,check:spec-parsed-alias,check:where-matcher,check:engine-double-contract,check:test-source-alias) are all green on this head. The two remaining commands are the not-runnable-here pair below.Type-level reverse verification against the rebuilt
dist/*.d.ts(probe file inpackages/lint,tsc --noEmit --ignoreConfigagainst@objectstack/spec/ui): the record formfilter: { status: 'won' }→ red,TS2353 … 'status' does not exist in type '{ field: string; operator: unknown; value?: … }[]'; the array form → green (0 errors). The probe was deleted afterwards.Not runnable here, by their own verdicts:
check:react-declaration-parity(needs objectui'ssdui.manifest.json, produced only bypnpm sdui:manifeston a pin bump — an on-demand gate by maintainer decision; this is the gate whose exemptionelement:number.filter:arraybecomes deletable downstream) andcheck-test-completeness(reads a savedturbo run testlog; NOT MEASURED locally per its text). Repo-widepnpm lintand the multi-package farm are CI's runs (declared narrowing — CI runs the farm on this PR).H17 note
packages/spec/src/ui/view.zod.tsis not touched —ViewFilterRuleSchemais imported from it incomponent.zod.tsonly (on-hold #8346 declares it Restart-touch).Session: https://claude.ai/code/session_01GDA48PuRFrHyRfdkBz8m21
🤖 Generated with Claude Code
https://claude.ai/code/session_01GDA48PuRFrHyRfdkBz8m21
Generated by Claude Code