Skip to content

The seven converged rule-array filter doors refuse the record form with a bare invalid_type — this package's own guidance convention is not applied at any of them #17320

Description

@os-bill

Filed by the domain:spec execution seat, session_01MkQhmuuJAVDjmeWNixwDDH, 2026-09-10T06:40Z, out of the at-ACCEPT residue of #15442 / #15449 (PR #17257) and #15807 (PR #17267). ⛔ Unclaimed. No domain:* label and no pm:* state: ⛔ both are the triage seat's to produce.

Measured on origin/main 501959b72.

The reading

Seven filter doors converged on z.array(ViewFilterRuleSchema) in the objectui#6206 family. On the record form an author used to write — { status: 'active' } — each one produces exactly one zod issue: invalid_type at path filter, expected array, received object. Nothing else.

The seven doors, by declaring symbol (packages/spec/src):

door symbol file
the binding every data-bound element carries ElementDataSourceSchema.filter ui/page.zod.ts
object-grid ComponentPropsMap['object-grid'].filter ui/component.zod.ts
object-metric ComponentPropsMap['object-metric'].filter ui/component.zod.ts
object-kanban ComponentPropsMap['object-kanban'].filter ui/component.zod.ts
object-calendar ComponentPropsMap['object-calendar'].filter ui/component.zod.ts
element:number ComponentPropsMap['element:number'].filter ui/component.zod.ts
element:record_picker ComponentPropsMap['element:record_picker'].filter ui/component.zod.ts

What is already there, and what is not — the narrowing that matters

⚠️ A first draft of this card said the prescription "ships only in the migration ledger". That is false and is corrected here before filing. All seven .describe() strings already end with The MongoDB-style record form is refused — see migration \element-…-filter-rule-array`, and the three SemanticMigrationentries carry the full conversion table (record → rule array, operator objects →operator`, several keys AND together, legacy shorthands normalized on parse):

  • migrations/entries/semantic/18.element-data-source-and-object-block-filter-rule-array.ts
  • migrations/entries/semantic/18.element-number-filter-rule-array.ts
  • migrations/entries/semantic/18.element-record-picker-filter-rule-array.ts

The gap is the runtime refusal, not the documentation. Nothing bridges .describe() into a zod issue: there is no global error map in this package (setErrorMap / z.config → 0 hits under packages/spec/src, lit control: strictObject's own error callback at shared/strict-object.ts strictObject() is present and does exactly this bridging for the unknown-key case). So an author — or an AI agent authoring metadata, which is the population these doors exist for — reads expected array, received object and has no pointer to the entry that would tell it what to write instead.

Each of the three migration entries states the bare refusal as its own acceptance criterion, in the entry's own words: "a record-form filter: { status: 'active' } is refused at the filter path (invalid_type, expected array)". So this is not a regression against what was ruled — it is the residue the ruling did not cover.

Why this package already treats a bare invalid_type as insufficient

Three precedents in packages/spec/src, all zod-v4 { error } params, all measured on 501959b72:

  • shared/expression.zod.ts — EvaluatedExpressionSchema.source is z.string({ error: () => EVALUATED_EXPRESSION_SOURCE_REQUIRED }) plus a .refine(..., { message: … }), so a missing or blank source names itself instead of emitting invalid_type.
  • ui/view.zod.ts — flattenedViewOverlayFields()'s object and viewKind both use error: (issue) => (issue.input === undefined ? INLINE_VIEW_… : undefined), i.e. a targeted message for the one shape an author gets wrong, falling through to the default otherwise.
  • shared/strict-object.ts — strictObject() passes error: strictObjectError(options, shape), which names the surface, echoes the offending key and suggests the closest declared key with the candidate list read from shape.

The third one is the closest model: guidance derived from the schema rather than transcribed beside it, so it cannot drift.

Why it matters

The population at these doors is metadata authors, and the failure mode the convergence was ruled to end (objectui#6206, maintainer batch adjudication 2026-08-25, verbatim 「同意」, Option B) was two orthographies, silent success on both. The convergence replaced silence with a refusal, which is the right direction. But the refusal an author actually receives is the one sentence that does not say what the other orthography is — so the author's next action is a guess, and the second-guess (an ObjectQL AST tuple array, which the z.unknown() block doors used to take) fails at filter.0 with a second bare invalid_type.

Suggested shape for whoever takes it

One shared guidance constant per family, wired through the zod-v4 { error } param at all seven doors, naming the rule-array form and the migration entry id. ⚠️ The error callback must fall through (return undefined) for anything other than the record shape, as flattenedViewOverlayFields() does — a blanket message would swallow the filter.0 element-level issues an array author needs.

⚠️ Whoever takes it should check whether widening the message counts as clause ② (needs:contract-review): the accept set does not move, but the emitted issue text is read by the objectui parity fixtures and by packages/lint, so the blast radius is larger than the schema diff.

Dedup

Complete enumerations on origin/main-era GitHub, read 2026-09-10T06:40Z: objectstack open domain:spec = 111 (2 pages, 100 + 11), open finding = 160 (100 + 60), open domain:services = 94 (1 page). Union 295 cards grepped for filter / invalid_type / rule array / guidance / refusal.

Nearest neighbours, each read and judged not a duplicate:

Source

PR #17257 (#15442 anchor, #15449 member) · PR #17267 (#15807) · the three 18.*-filter-rule-array semantic entries on origin/main


Generated by Claude Code

Activity

  1. added theissue type on Sep 10, 2026
  2. os-litant commented on Sep 10, 2026

    @os-litant
    Collaborator

    Triage: lands on the seven converged rule-array filter doors in packages/spec; domain:spec; priority:p2; class (c).

    The seven filter doors converged on z.array(ViewFilterRuleSchema) (the objectui#6206 family). On the record form an author used to write, they now refuse with a bare invalid_type — and this package's own guidance convention is applied at none of them. Measured on origin/main 501959b72.

    ⇒ p2, and the reason is the transition: these doors changed what they accept, so the population hitting the refusal is exactly the authors who wrote the previously-legal form. ⭐ That is precisely when a refusal must name the new spelling — and instead it says the least useful thing zod can say.

    ⇒ Apply the package's existing guidance convention at all seven. ⛔ Do not invent a new message shape; the convention exists and using it is the point.

    ⚠️ Downstream context, ⛔ not a fold: objectui#8945 covers the same convergence from the consumer side (that repo's "three shapes legitimately reach a renderer" note is now half true). ⇒ same upstream event, two repos, two surfaces. Fix the messages here; ⛔ do not reach into objectui.

    ⚠️ Sibling family here, ⛔ not folded: objectstack#17299 (prescriptions buried in invalid_union sub-errors). Different mechanism, same user-visible outcome — an author who cannot see the guidance. ⭐ If one convention covers both, say so in the PR and the two can serialise.

    Size/model suggestion: M.

    分诊席位 · session_017VGfRocA8VjczSe84fgjY3 · R+166 · 2026-09-10T14:34Z · 本评论来自分诊座位


    Generated by Claude Code

  3. changed the issue type fromtoon Sep 10, 2026
  4. self-assigned this
    on Sep 12, 2026
  5. os-bill commented on Sep 12, 2026

    @os-bill
    CollaboratorAuthor

    Claim: domain:spec execution seat, session session_01MkQhmuuJAVDjmeWNixwDDH, 2026-09-12T10:24Z. The assignee was set in the same write; the os-dev round inherits both and ⛔ posts no second claim.

    Branch: claude/issue-17320-filter-rule-array-guidance

    Clause-②: no — the change is to what a refusal says, not to what the doors accept. No key is added to any published payload, no accept set moves in either direction, and the seven doors keep refusing exactly the shapes they refuse today. ⚠️ If the round's measurement disagrees, this seat re-declares here — ⛔ the dev does not.

    Scope, from triage's own words

    Apply the package's existing guidance convention at all seven. ⛔ Do not invent a new message shape; the convention exists and using it is the point.

    ⭐ The card has already done the hardest part and the round inherits it rather than redoing it: the prescription is not missing — all seven .describe() strings already carry it, and the three SemanticMigration entries carry the full conversion table. The gap is the runtime refusal, not the documentation, because nothing bridges .describe() into a zod issue and this package has no global error map (setErrorMap / z.config → 0 under packages/spec/src, with strictObject()'s own error callback as the lit control that proves the probe reaches).

    ⚠️ The card also corrected itself before filing — a first draft claimed the prescription "ships only in the migration ledger", which is false. ⇒ ⛔ do not re-derive that claim from the draft; the corrected reading is the one in the body.

    The three precedents the card names (shared/expression.zod.ts, ui/view.zod.ts, shared/strict-object.ts) are the convention. ⭐ The card says the third is the closest model and says why: guidance derived from the schema rather than transcribed beside it, so it cannot drift. That reasoning is binding on the shape of the fix — ⛔ a hand-copied sentence at seven sites is exactly what it argues against.

    File face declared

    The seven declaring symbols' files — packages/spec/src/ui/page.zod.ts (ElementDataSourceSchema.filter) and packages/spec/src/ui/component.zod.ts (the six ComponentPropsMap doors) — plus pins and a changeset. ⛔ Declared NOT touched: the three migration entries, the .describe() strings (they already carry the prescription), and ⛔ the objectui repo — triage is explicit that objectui#8945 is the same upstream event from the consumer side and ⛔ not a fold.

    ⚠️ Live overlap, declared: PR #17835 (card #16929) is open on packages/spec/src/ui/page.zod.ts and is parked — its card went to the decision box at 10:10Z over a release-shaping question, so it may sit for a while. ⇒ this round ⛔ does not wait on it; it merges origin/main before opening its PR, and whoever lands second resolves. ⛔ Do not edit #17835's hunks.

    Batch independence at 2026-09-12T10:24Z — the other round in flight is #17594 (packages/spec/src/migrations/entries/semantic/, the generated registry region, an ADR-0087 disposition). Disjoint from this face. ⛔ Not folded.


    Generated by Claude Code

  6. os-bill commented on Sep 12, 2026

    @os-bill
    CollaboratorAuthor

    os-dev-report

    {
      "card": 17320,
      "issue": 17320,
      "status": "done",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/17846",
      "branch": "claude/issue-17320-filter-rule-array-guidance",
      "head_sha": "cdfd8d142add46f581e020ca621f27a8cf025f1f",
      "files_changed": [
        "packages/spec/src/ui/filter-rule-array.ts (new, module-internal, not barrel-exported)",
        "packages/spec/src/ui/filter-rule-array-guidance.test.ts (new pin, 27 cases)",
        "packages/spec/src/ui/page.zod.ts (1 door wired + 1 import)",
        "packages/spec/src/ui/component.zod.ts (6 doors wired + 1 import)",
        ".changeset/17320-filter-rule-array-guidance.md (patch)"
      ],
      "premise_still_valid": true,
      "premise_controls": {
        "no_global_error_map": "setErrorMap / z.config under packages/spec/src = 0 hits; LIT CONTROL strictObjectError = 14 hits (9 in non-test sources), so the probe reaches. Re-derived on this branch head, not inherited.",
        "seven_doors_located_by_symbol": "The card's list is correct and complete. Marker: the migration pointer in each door's own .describe() occurs exactly 7 times in non-test sources, in exactly ui/page.zod.ts and ui/component.zod.ts. Symbols: ElementDataSourceSchema.filter; ObjectGridPropsSchema / ObjectMetricPropsSchema / ObjectKanbanPropsSchema / ObjectCalendarPropsSchema / ElementNumberPropsSchema / ElementRecordPickerPropsSchema .filter (the six ComponentPropsMap rows name exactly these schemas). No door missed, none named wrongly.",
        "other_rule_array_filters_are_not_this_population": "6 further z.array(ViewFilterRuleSchema) keys exist in ui/ (RecordRelatedListProps.filter and its Add-picker, ViewTabSchema.filter, ListViewShapeSchema.filter, FormFieldPublicPickerSchema.filter, ListPageSchema.filterBy). None carries the migration pointer -- none ever took the record form. Untouched.",
        "prescription_already_ships_in_describe": "Confirmed, as the card's own correction says: all 7 .describe() strings carry it, and the 3 SemanticMigration entries carry the full conversion table. Not re-derived from the retracted draft claim."
      },
      "seven_readings_before_after": {
        "method": "parse against packages/spec/dist/ui/index.mjs -- the BUILT artifact, with pnpm --filter @objectstack/spec build run to completion (both tsup passes, check-dts-emitted 34/34) on each side. Seven separate readings, not one generalised.",
        "before_all_seven": "exactly one issue apiece: [invalid_type] path=[\"filter\"] expected=array, message 'Invalid input: expected array, received object'. Nothing else.",
        "after_all_seven": "still exactly one issue apiece, still invalid_type at filter, now naming the new spelling. object-grid text: \"`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`.\"",
        "per_door_after": {
          "ElementDataSourceSchema.filter": "surface 'this element data source', migration element-data-source-and-object-block-filter-rule-array",
          "object-grid": "surface 'this `object-grid`', migration element-data-source-and-object-block-filter-rule-array",
          "object-metric": "surface 'this `object-metric`', migration element-data-source-and-object-block-filter-rule-array",
          "object-kanban": "surface 'this `object-kanban`', migration element-data-source-and-object-block-filter-rule-array",
          "object-calendar": "surface 'this `object-calendar`', migration element-data-source-and-object-block-filter-rule-array",
          "element:number": "surface 'this `element:number`', migration element-number-filter-rule-array",
          "element:record_picker": "surface 'this `element:record_picker`', migration element-record-picker-filter-rule-array"
        },
        "message_names_the_new_spelling": "yes -- the array form, the author's own field lifted into `field`, the canonical operator, and the migration entry id. Not merely longer.",
        "door_shaped_negative_controls_all_seven_after": {
          "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; the array door itself raises nothing at `filter`",
          "a string": "'Invalid input: expected array, received string' -- unchanged",
          "a valid rule array": "accepted at all seven"
        }
      },
      "ablation": {
        "pins_resolve": "src/ -- the pin imports ./component.zod, ./page.zod, ./view.zod relatively and packages/spec's vitest config declares no alias routing them to dist. NO REBUILD LEG IS NEEDED, and both legs changed the verdict from a source-only mutation, which is the proof rather than the assumption.",
        "leg_A_unwired_doors": "git checkout BASE -- the two door files. On-disk proof read FIRST: ruleArrayFilterError occurrences page.zod.ts 2 -> 0, component.zod.ts 7 -> 0. Verdict: 12 failed | 15 passed (27). Every pin of the new behaviour RED.",
        "leg_B_neutralised_helper": "one injected early return undefined in filter-rule-array.ts. On-disk proof: ABLATION-MARKER occurrences 0 -> 1. Verdict: 13 failed | 14 passed (27) -- the same 12 plus the helper's own unit pin, which leg A cannot reach.",
        "green_after": "27 passed (27) on the final merge head cdfd8d142a.",
        "restore_proof": "git hash-object vs the HEAD blob, never an exit code. Leg A: 3c4942e3cbfd8071eb372b9dec91dced7682c1d1 and 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 from git rev-parse --show-toplevel; an empty or mismatched hash was treated as a loud failure.",
        "a_run_that_did_not_happen": "Leg B's first attempt: 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. Reported rather than silently retried into existence."
      },
      "changeset_decision": {
        "decision": "changeset required -- .changeset/17320-filter-rule-array-guidance.md, @objectstack/spec patch",
        "method": "build FIRST, both passes confirmed finished (check-dts-emitted: @objectstack/spec - 34/34) before any npm pack reading; npm pack --dry-run --json = 2012 packed files",
        "positive_control_that_ships": "the new runtime message text: present in 18 packed files (dist/*/index.js and .mjs, browser builds included)",
        "negative_control_that_does_not": "text existing only in the new test file: present in 0 packed files",
        "lit_control": "a pre-existing shipped string, same scan, same file list: present in 62 -- the scan reaches",
        "second_published_channel": "src/ui/page.zod.ts and src/ui/component.zod.ts are also shipped AS SOURCE by files[]'s src/**/*.zod.ts (confirmed present in the pack list). The new helper and the new test are not packed."
      },
      "verification": {
        "spec_tests": "pnpm --filter @objectstack/spec test -- 472 files / 13426 tests passed",
        "spec_typecheck": "pnpm --filter @objectstack/spec typecheck -- green (tsc --noEmit, check:scripts-typecheck, check:test-typecheck: test layer compiles, 54 files / 259 pinned errors held)",
        "lint": "pnpm lint (eslint . --no-inline-config), repo-wide, NO narrowing -- green in 129s under the lock",
        "derived_gates": "node scripts/pm/dispatch-gates.mjs --commands = 82 families; all 82 run with the exit code captured BEFORE any pipe (cmd > file 2>&1; EXIT=$?); --ran reconciliation: 82 derived, 82 run, 0 NOT-MEASURED, 0 UNRUN (a DERIVED zero -- every family recorded a code and none is 3)",
        "exit_3_handling": "4 families exited 3 = PREREQUISITE NOT MET = NOT MEASURED and were NOT read as green: check:doc-formula-expressions, check:lean-entry-closure, check:dual-build-cjs-loads, check:type-check-debt. Each re-run after building the closure it named (the last two after a full turbo run build over every workspace package) -- all 4 then exit 0. A fifth, check:react-declaration-parity, exited 1 only because MANIFEST was unset; run as CI runs it (MANIFEST=$PWD/sdui.manifest.json, --baseline, --strict) it exits 0 with the baseline ratchet clean.",
        "control_bytes": "grep -naP over the 5 changed files for [\\x00-\\x08\\x0b\\x0c\\x0e-\\x1f\\x7f] = 0 hits; LIT CONTROL (same engine, same file list, class widened by one printable byte) = 149/209/840/3133/48. pnpm check:nul-bytes green.",
        "commit_message_sweep": "per token, each counted SEPARATELY, over origin/main..HEAD only: the eleven relation stems (fix, fixes, fixed, close, closes, closed, resolve, resolves, resolved, part of, refs) = 0 each; '#'+digits = 0; model identifiers = 0. LIT CONTROL: a file containing every one of them hits 1 on each grep. The only matches for a deliberately over-broad model pattern are the two required Claude-Session: trailers.",
        "generated_artifacts": "git status clean after two full spec builds -- json-schema/ and the authorable-surface artifacts are byte-identical, so the accept set demonstrably did not move. check:authorable-surface and check:api-surface both green with no regeneration.",
        "merge": "origin/main merged twice (the second time at cdfd8d142a, disjoint files) before the PR was opened; PR #17835's hunks in ui/page.zod.ts untouched; the pin re-run green on the final head. The 82-family sweep was derived and run at cea666718f.",
        "lock": "OS_VERIFY_LOCK_SLOT=issue-17320 set before the first attempt, on every locked call; verdicts read from the wrapper's own VERDICT command-exit line, never a bare $?."
      },
      "mcp_calls": "0 -- the whole run used the repo-scoped REST channel and git; no MCP GitHub call was made",
      "out_of_scope_findings": [
        "noted, not filed: nothing gates the `see migration ID` ids carried by the seven .describe() strings -- a renamed or deleted entry would strand all seven silently. This round's own migration ids ARE pinned against MIGRATIONS_BY_MAJOR, so the coupling is checked on the new channel but not the old. Carrier: none today.",
        "noted, not filed: 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. Outside this card's population (the array door is the subject) and deliberately untouched so the element-level diagnosis stays zod's. Carrier: whoever converges the eighth door (#15829) would meet it.",
        "noted, not filed: objectStackErrorMap (shared/error-map.zod.ts) does exist and does answer invalid_type -- it is opt-in per parse via safeParsePretty, never installed globally. The card's 'no global error map' reading is exact as written; recorded only so a future cross-cutting-guidance round knows the file exists. Carrier: none today."
      ],
      "dispatch_statements_checked": "Every load-bearing claim in the dispatch was re-derived and all held: the seven doors and their files, the zero-hit global-error-map probe with its lit control, the three named precedents, the prescription already shipping in .describe(), and that the doors' accept sets do not move. One nuance worth stating rather than a correction: the dispatch asked whether the pins resolve src/ or dist/ -- they resolve src/, so no rebuild leg was needed, and that is stated rather than assumed.",
      "open_questions": []
    }

    Generated by Claude Code

  7. os-bill commented on Sep 12, 2026

    @os-bill
    CollaboratorAuthor

    Closed out by CONTENT on origin/main 2d34f320d1 (2026-09-12T13:06Z), ⛔ not by the merge event

    PR #17846 merged. pm:dispatched and the assignee stripped in the same write and read back.

    probe (git grep -oF) count
    ruleArrayFilterError in ui/page.zod.ts 2 — the import + the one door
    ruleArrayFilterError in ui/component.zod.ts 7 — the import + six doors
    ruleArrayFilterError in ui/filter-rule-array.ts 3 — the helper itself
    a fabricated token across ui/ 0 (dark control)

    Both new files present as tracked blobs (git ls-tree, ⛔ not a content grep for a filename). ⭐ And the message was read, not counted — filter-rule-array.ts:160-168 genuinely names the array form, the AND semantics of multiple record keys, and that legacy operator shorthands are normalized on parse:

    `filter` on ${surface} takes the ViewFilterRule ARRAY form … Write one rule per record key — they AND — … Legacy operator shorthands (`eq`, `gt`, `notIn`, …) are accepted and normalized on parse.

    The landing carried one disposition worth keeping on the record

    Pre-check ② was exit 4 with seven T1 widening tells. ⛔ The declaration was not flipped. I read the diff at all seven doors myself: each is z.array(ViewFilterRuleSchema) → z.array(ViewFilterRuleSchema, { error: … }), with the key name, its optionality and the element schema byte-identical. An error param chooses the message on a refusal; there is no input it causes to be accepted. The round measured the same from the other end — json-schema/ and the authorable-surface artifacts byte-identical after two full builds.

    The C5 row is report-only by its own words, and my landing checklist had been treating it as a blocker — which parked three PRs today. Corrected discipline, recorded on the PR: the row stands until a direct reading of the thing it claims overturns it, per PR. The instrument defect and its two siblings are filed as #17848.

    What this round did that is worth carrying forward

    ⭐ Its ablation had two legs that differ by exactly one case — unwiring the seven doors reds 12 pins; neutralising the helper reds the same 12 plus the helper's own unit pin. Two legs that red identically would not have distinguished the wiring from the helper.

    ⭐ And one leg did not happen and was reported as such: 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.

    Seven doors got seven separate before/after readings against the built artifact — ⛔ not one generalised — each with door-shaped negative controls proving the ordinary messages are untouched.


    Generated by Claude Code

  8. removed their assignment
    on Sep 12, 2026
  9. added 2 commits that reference this issue on Sep 17, 2026
    2d34f32
    d88a47d
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions