Skip to content

feat(spec): the seven converged rule-array filter doors name the array form when they refuse the record form - #17846

Merged
os-bill merged 4 commits into
mainfrom
claude/issue-17320-filter-rule-array-guidance
Sep 12, 2026
Merged

feat(spec): the seven converged rule-array filter doors name the array form when they refuse the record form#17846
os-bill merged 4 commits into
mainfrom
claude/issue-17320-filter-rule-array-guidance

Conversation

@os-bill

@os-bill os-bill commented Sep 12, 2026

Copy link
Copy Markdown
Collaborator

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/src0 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.mjspnpm --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 test472 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 --commands82; 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

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 15 documentable anchor(s).

4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/error-catalog.mdx (via invalid_type (literal, a string literal in ruleArrayFilterError))
  • content/docs/api/error-handling-server.mdx (via invalid_type (literal, a string literal in ruleArrayFilterError))
  • content/docs/deployment/cli.mdx (via invalid_type (literal, a string literal in ruleArrayFilterError))
  • content/docs/protocol/objectui/concept.mdx (via invalid_type (literal, a string literal in ruleArrayFilterError))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-0.mdx (via invalid_type (literal, a string literal in ruleArrayFilterError))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 136 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 6059b29c03e470dbaa513cc3f0992a8e610855bepackageMentionDocs.

Which tree this was computed on

This run read content/docs from a1c25d57bb30baba5d068b6a926da93664d0499e — the merge of head cdfd8d142add46f581e020ca621f27a8cf025f1f into base 6059b29c03e470dbaa513cc3f0992a8e610855be, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin a1c25d57bb30baba5d068b6a926da93664d0499e && git checkout a1c25d57bb30baba5d068b6a926da93664d0499e
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 6059b29c03e470dbaa513cc3f0992a8e610855be cdfd8d142add46f581e020ca621f27a8cf025f1f && git checkout -B drift-repro 6059b29c03e470dbaa513cc3f0992a8e610855be && git merge --no-ff cdfd8d142add46f581e020ca621f27a8cf025f1f

node scripts/docs-audit/affected-docs.mjs --json 6059b29c03e470dbaa513cc3f0992a8e610855be

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 6059b29c03e470dbaa513cc3f0992a8e610855be → pass the list as
args.docs, on the commit named under Which tree this was computed on.

os-bill commented Sep 12, 2026

Copy link
Copy Markdown
Collaborator Author

Seat review of head cdfd8d14accepted and landing, with one pre-check disposed of explicitly rather than waved through

domain:spec execution seat, 2026-09-12T12:12Z.

⚠️ Pre-check ② is exit 4, and it is the known T1 false positive — seven times

check-clause2-carriers --pair 17846exit 4, C5, seven T1 tells: ui/component.zod.ts:1854, :2239, :2505, :2724, :2801, :2957 and ui/page.zod.ts:226, each read as "a new key on a Zod object schema — the accept set gains a spelling an author may now write".

The declaration is not being flipped. I read the diff at all seven sites myself rather than reasoning about it:

-  filter: z.array(ViewFilterRuleSchema).optional()
+  filter: z.array(ViewFilterRuleSchema, {
+    error: ruleArrayFilterError({ surface: …, migration: … }),
+  }).optional()

⇒ the key name, its optionality and the element schema are byte-identical at every door. The only addition is a zod error param, which chooses the message produced on a refusal. ⭐ There is no input it causes to be accepted — an error param cannot widen an accept set, and T1's own sentence ("gains a spelling an author may now write") is false of it. The round measured the same conclusion from the other end: json-schema/ and the authorable-surface artifacts are byte-identical after two full builds, with check:authorable-surface and check:api-surface green and no regeneration.

Why that permits landing. The C5 row says of itself: "Report-only: ⛔ never a label written from this script — hanging or clearing a review gate from a checker would be issuing the verdict, which is 自查放行." It is not a gate, and nothing mechanical holds this PR. ⚠️ My own landing checklist had turned a report-only row into a blocker; that reading has parked three PRs today. The corrected discipline is not "ignore the row" but: the row stands until a direct reading of the thing it claims overturns it, per PR, recorded. That reading is above.

⚠️ And the row's stated second remedy — "or explain in the claim why this addition does not widen"has no reader in the code: c5WideningTell() compares the declared value against the diff's tells and nothing else. So this comment is a record for humans, ⛔ not a mechanism that clears anything. Both facts are filed as #17848, which also carries the other two PRs (#17638's strategy: z reads 1 on main and 1 on the branch; #17796's view: z.string().optional() reads 2 and 2).

The other three pre-checks

# check reading
at-tier verdict not owed — Clause-②: no
check-governed-merges --test over the five final paths exit 0 · ⭐ control with .claude/skills/pm-dispatch/SKILL.md appended → exit 3 GOVERNED
every check, newest run per name 35 names, 0 pending, 0 failing
carrier needs:contract-review absent on the PR and on card #17320, checked before any flip

What the round measured, accepted as read

Seven doors, seven separate readings before and after against the built artifact — not one generalised — each still producing exactly one issue at filter, now naming the array form, the author's own key lifted into field, the canonical operator and the migration id. Three door-shaped negative controls confirm the ordinary messages are untouched: a bad element still reports at filter.0.operator in zod's own words, a string still gets expected array, received string, and a valid rule array is accepted at all seven.

⭐ Two things in the ablation are worth keeping. It has two legs, and they differ by exactly one case — unwiring the doors reds 12, neutralising the helper reds the same 12 plus the helper's own unit pin, which leg A cannot reach. And leg B's first attempt did not happen: a quoting error wrote nothing, the on-disk marker count came back 0, and the guard refused the run rather than reporting a green ablation over an unmutated tree. ⛔ That is the vacuous pass caught by its own control, and it was reported rather than silently retried into existence.

⭐ The dispatch order's load-bearing claims were all re-derived and held — the first round today where nothing in my order turned out to be wrong. The one nuance it added rather than corrected: the pins resolve src/, so no rebuild leg was needed, and it said so instead of assuming it.

Landing

Flipping ready, re-reading ④ after the flip, arming, then confirming the queue ref.


Generated by Claude Code

@os-bill
os-bill marked this pull request as ready for review September 12, 2026 12:12
@os-bill
os-bill added this pull request to the merge queue Sep 12, 2026
Merged via the queue into main with commit 2d34f32 Sep 12, 2026
37 checks passed
@os-bill
os-bill deleted the claude/issue-17320-filter-rule-array-guidance branch September 12, 2026 13:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation protocol:ui size/l tests tooling

Projects

None yet

2 participants