fix(spec): refuse an absent value on a value-taking view filter operator at authoring time - #19861
Conversation
…tor at authoring time
The scalar arm of the view filter rule value check returned early on an
absent value for every operator, so a rule such as
{ field: 'name', operator: 'icontains' } parsed green while its published
description declares a scalar for every non-unary operator, and the query
path refuses the lowered [field, operator] node with 400 INVALID_FILTER.
The four unary operators stay valueless; in / not_in / between keep their
own arms. Adds the ADR-0087 semantic entry under protocol major 18 and the
regenerated migration registry.
Claude-Session: https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr
Co-authored-by: Claude <noreply@anthropic.com>
…ue arm The pin asserted that an icontains view rule with no value parses. That carve-out is gone: the value-shape check now refuses a missing value on every operator that takes one. The pin keeps its own half, that the text comparand door never judges absence: the refusal arrives once, in the absent-value arm's words, never in the conformance table's. Claude-Session: https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check2 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 136 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 8a983b94d1df26cec68edd780f828452c2df5c7b && git checkout 8a983b94d1df26cec68edd780f828452c2df5c7b
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 2c1011b01bc071c545f72f2761647b8d9ab56375 ab0104d0020f8f88ecc62828ed857827e5c28873 && git checkout -B drift-repro 2c1011b01bc071c545f72f2761647b8d9ab56375 && git merge --no-ff ab0104d0020f8f88ecc62828ed857827e5c28873
node scripts/docs-audit/affected-docs.mjs --json 2c1011b01bc071c545f72f2761647b8d9ab56375 |
Contract reviewServed-tier: 47/47 Isolated at-tier reviewer subagent, run by the ① Derived judgmentsRuling applied (card #19751): triage comment 2026-09-23T00:33Z graded class (b) Bug ("declared ≠ enforced"); the dispatch comment 2026-09-23T10:26Z ordered step 1 "measure the producer first ... if it does [save a valueless row], stop and report", and step 2 "only if the producer reading is clean: refuse an absent Refs:
② Semver levelDecision: Rule text applied:
The test set by the maintainer: does the change refuse input the published schema accepted before? Yes. The published surface accepted an absent value on the thirteen operators by six independent readings at 4112752 / origin/main: (a) the published type — The PR's own changeset agrees on the direction — Marker vs Blocking: ③ Boundary flags
Implemented-by: VERDICT: FAIL Blocking items:
Generated by Claude Code |
…ndow convention The changeset declared the view filter rule absent-value refusal BREAKING with a `(narrowing)` arm but graded it `patch`, and its banner sentence said the level was `patch` because nothing widens. During the launch window a breaking change ships as `minor` (check-changeset-no-major.mjs header), so the frontmatter now reads `minor` and the sentence states that convention instead of misstating it in the shipped CHANGELOG. One sentence is added naming the carve-out the sibling view-filter entry pending in the same changeset directory records (an omitted value still parses; an absent icontains comparand is left unjudged) as reversed here, so the two entries do not contradict each other in the compiled CHANGELOG. Claude-Session: https://claude.ai/code/session_019c3Hi6ZMU1p6m6aA6Bz45d Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: 99/99 Isolated at-tier reviewer subagent, run by the ① Derived judgmentsRuling applied (card #19751): triage Refs:
② Semver levelDecision: Rule text: AGENTS.md at
③ Boundary flagsPrior record's non-blocking notes, status at this head:
New at this head, all non-blocking:
Implemented-by: VERDICT: PASS Generated by Claude Code |
…e the derive path for enum members that cannot be spelled (objectstack-ai#19906) Fixes objectstack-ai#19678 Fixes objectstack-ai#19907 Clause-②: no Executes ruling comment `5805845085` on objectstack-ai#19907 (batch objectstack-ai#218 item 3, letter 乙, maintainer 「其他同意」). It narrows item 1 of ruling `5793380467` on objectstack-ai#19678 (batch objectstack-ai#217 item 5, letter 不动 + 声明), which the first round of this PR executed to the letter: > 1. The rule as recorded on `FormFieldSchema.options`' describe and in `defineForm`'s refusal: an enum-typed metadata-form row MAY carry an inline `options` list (human labels, a deliberate subset); a row whose members cannot be spelled as option values (a hyphen, a capital) OMITS `options`, the control derives the members from the served JSON Schema, and meanings go in `helpText`. The refusal names that path as the remedy. > 2. The 27 existing rows stay; objectstack-ai#19331's labels stay. > 3. PR objectstack-ai#19906 lands with its describe and remedy sentence narrowed to that wording. From ruling `5793380467`, the parts 乙 does not narrow still hold: `FormSelectOptionSchema.value` keeps the system-identifier bound, and `newTab` vs `new-tab` stays a recorded boundary, untouched here. No value bound, no schema shape, no key and no export moves. ## What changed - **The describe.** `FormFieldSchema.options` (`packages/spec/src/ui/view.zod.ts`, the `FormFieldBaseSchema` row) keeps its per-option `default` sentence and now adds: *On a metadata form (schema-bound, built by `defineForm`), an enum-typed row may list its members here, to give them human labels or to offer a deliberate subset. An option `value` is a lowercase system identifier, so a row whose members cannot be spelled as option values (a hyphen, a capital) omits `options`: the control derives the members from the served JSON Schema, and their meanings go in `helpText`.* The TSDoc above the row says the same thing and names both rulings. - **The wall.** `defineForm` calls `FormViewSchema.safeParse`. When the parse fails, it throws a `ZodRealError` built from the parse's own issues. That is the class `FormViewSchema.parse` threw before this PR: an `Error` whose `name` is `ZodError`. Its stack is captured at the `defineForm` call, so an uncaught module-load throw prints the issues, the remedy and the author's call site (round 3, below). Only one thing changes in the issues: a grammar refusal (`invalid_format` or `too_small`) at an inline option's `value` (path ending `options.INDEX.value`, also when nested inside the field-row union's `errors`) keeps its message and gets this sentence after it: *An enum member carrying a hyphen, a capital or a single character cannot be a form option `value`, which is a lowercase system identifier. When this row edits a spec enum whose members cannot be spelled as option values, omit `options`: the control derives the members from the served JSON Schema, and their meanings go in `helpText`.* No issue is added, removed or re-coded. - **Generated:** `content/docs/references/ui/view.mdx`, regenerated by `pnpm --filter @objectstack/spec gen:docs` after a spec build. Two table rows changed (the `options` row of the two FormField tables). `check:generated` then reported all 15 artifacts up to date. - **Changeset:** `.changeset/19678-form-option-enum-derive-remedy.md`, `@objectstack/spec: patch`, rewritten to state ruling 乙's rule. ### Round 2 (ruling 乙): what moved from the first round - The describe no longer says *a row whose key is a spec enum omits `options`*. It now permits an inline list on an enum-typed row and scopes the derive path to a row whose members cannot be spelled. - The remedy no longer says *When this row edits a spec enum, omit `options`*. It now conditions the same derive path on members that cannot be spelled as option values. - The verdict did not move. The same values are refused and the same values are accepted as on the first round's head `ebd7fc2fa8`. - The branch is merged with `origin/main` at `c8399867b8` (merge commit `61ff3aebe6`, through `scripts/pm/os-regen-merge.sh`). The wording commit is `182ed4c154` and the regeneration commit is `74ea5dbba3`. ### Round 3 (at-tier record `5818584341`: FAIL): the refusal is an `Error` with a stack again - **What the record found.** Round 2 threw `new z.ZodError(…)`. In zod v4 classic (`zod@4.6.1` here) that constructor has no `Error` parent, so the thrown object was not an `Error` and had no `stack`. An uncaught module-load throw printed only `ZodError { name: 'ZodError', message: [Getter/Setter] }`. That hid the issues and the remedy, the very wall both rulings require. `refusal()` asserted only `toBeInstanceOf(z.ZodError)`, and both shapes pass that. - **The fix** (`76a053e9d0`). `defineForm` now throws `new z.ZodRealError(withOptionValueDeriveRemedy(parsed.error.issues))` and captures its trace with `z.core.util.captureStackTrace(refusal, defineForm)`. `ZodRealError` is the class `FormViewSchema.parse` threw before this PR: its `name` is `ZodError`, it is an `Error`, and it passes `instanceof z.ZodError`. The walker is unchanged. It returns copies, it grows only `invalid_format` and `too_small` at a `…options.INDEX.value` path, it still walks `invalid_union`, and an unrelated refusal is still answered without the remedy. The verdict did not move. - **Why this route, and not either spelling in the record as written.** Both were measured on `zod@4.6.1`, each thrown uncaught from a scratch form module that parses with the source `FormViewSchema` (run by `tsx`). 1. **Neither spelling has a frame.** zod builds every `ZodRealError` with `Error.stackTraceLimit = 0` (`newError` in `zod/v4/core/core.js`). It captures a trace only inside `parse` (`util.captureStackTrace(e, callee)`), and `safeParse` never does. So `throw parsed.error` and a bare `throw new z.ZodRealError(…)` both print the issues as `[ZodError: …]`, with 0 `at` frames and no source line. Read directly in `node`: `new z.ZodError([])` is not an `Error` and its `stack` is `undefined`, `new z.ZodRealError([])` and a `safeParse` error are `Error`s whose stacks hold 0 frames, and the error `parse` throws holds 8. That `stack` is still a string, so the record's two assertions pass on both spellings. The fix captures the trace the way zod's own `parse` does. The callee is `defineForm`, so the first frame is the author's `defineForm(…)` call. 2. **A copy, not a mutation in place.** A mutation in place would not leak into another caller. Two parses of the same input share 0 issue objects, because zod's `finalizeIssue` builds each issue fresh and `lazySchema` caches the schema, never a result. A mutation of the first parse's issues showed up 0 times in the second parse. The hazard is order. zod 4.6.1 computes an error's `message` on its first read and caches it (`_zod.message`), and V8 formats the stack header on the first read of `.stack`. So a message grown in place reaches the printout only if nothing read `.message` or `.stack` before the mutation. Measured on `FormViewSchema.parse`'s own error, which has its frames. Grown with no earlier read, the remedy is in the issues, the `message` and the `stack` once each, and the uncaught printout carries it. After one earlier read of `.message`, the issues still carry it once, but the `message`, the `stack` and the printout carry it 0 times. An error built from issues that already carry the remedy does not depend on that order. - **The wall, proved with a real uncaught throw.** A scratch form module, shaped like `packages/spec/src/**/*.form.ts`, imports `defineForm` from the built `packages/spec/dist/ui/index.mjs` and calls it at module scope with `{ field: 'openIn', options: [{ label: 'New tab', value: 'new-tab' }] }`. A second module imports it, and nothing catches. Both ran under `node` 22.22.2, and stderr was captured: | read on stderr | the fix (`76a053e9d0`, `dist` built) | negative control: round 2's `new z.ZodError(…)` line, `dist` rebuilt | |:--|:--|:--| | node exit | 1 | 1 | | what it printed | `ZodError: [` followed by the issue list as JSON | `ZodError { name: 'ZodError', message: [Getter/Setter] }` and nothing else | | the issue path (`sections.0.fields.0`, then `options.0.value` in the union's branch) | present | absent | | the grammar message (`System identifier must be lowercase…`) | 1 | 0 | | the remedy sentence (omit `options`, the members come from the served JSON Schema) | 1 | 0 | | the remedy's scope (`cannot be spelled as option values`) | 1 | 0 | | stack frames | 4. The first is `action-behavior.form.mjs:5:35`, and node's caret points at `defineForm({` in that module | 0 | For the negative control, `view.zod.ts` was byte-identical to round 2's blob `d6471f538d06`, and `ablation-dist-preflight` found the old line in 11 built files. After the restore the `dist` was rebuilt. The old line is absent from all 216 built files, the tree is clean, and the fix's wall reads the same as before (stderr sha256 `7d118336d597` both times). - **The pin.** On every refusal, `refusal()` now asserts `toBeInstanceOf(Error)` and a string `stack`, the record's two. It also asserts that the stack names this test file, the module that called `defineForm`. The third assertion is the one that tells a trace-less `ZodRealError` apart. A new case reads the wall itself: the head of the stack (`ZodError: ` and the message) carries today's grammar message, read live off the object face and JSON-escaped, and the derive path with its scope. **Ablation, round 3.** One-shot, at `76a053e9d0`, through `scripts/ablation-replace.mjs` under the verify lock, one leg at a time. The test imports `./view.zod` as source, so no `dist` is in its path. | leg | mutation | anchor | blob | result | |:--|:--|:--|:--|:--| | 1 | the throw put back to round 2's `throw new z.ZodError(withOptionValueDeriveRemedy(parsed.error.issues));` | x1 → x0 | `e2feed0106e6` → `d6471f538d06` (round 2's blob, byte for byte) | `Tests 19 failed \| 14 passed (33)`, every one at `expect(thrown).toBeInstanceOf(Error)` | | 2 | only the trace capture deleted: a `ZodRealError` with no frame, the shape both spellings in the record give | x1 → x0 | `e2feed0106e6` → `11a3b9cfae81` | `Tests 19 failed \| 14 passed (33)`, every one at *the stack names no frame in the module that called defineForm*. The record's two assertions passed on this shape | The 19 red cases are the ones that go through `refusal()`. The 14 green ones build a form, parse a schema or read the describe, and never reach `refusal()`. Both legs were restored: after each, the blob was `e2feed0106e6`, equal to HEAD, `git diff HEAD` was empty, and `git status --porcelain` read 0 lines. - **The changeset is not reworded.** Its sentence "`defineForm` still throws a `ZodError` at module load with the same issues and codes" is literally true at this head. The thrown object is a `ZodRealError`, the class `FormViewSchema.parse` threw before this PR. Its issues are the parse's own, copied, with the same codes, and only the matching messages grow. - **No base merge.** `origin/main` moved 23 commits past the round-2 merge base `c8399867b8`, to `e8f163fc3a`. None of them touches this PR's four paths, `identifiers.zod.ts` or `field.zod.ts` (`git diff --name-only`: 0 hits). Derived on a probe tree at `e8f163fc3a` with this PR's four files, the gate list is the same 107 commands as in this worktree (the two sorted lists do not differ). No generated artifact moved: `check:generated` reports all 15 generated artifacts up to date at `76a053e9d0`, and `view.mdx` is unchanged from round 2, so nothing was regenerated. ### Where the refusal lives (found by content), and why the remedy is attached at `defineForm` - **The text** is `SystemIdentifierSchema`'s regex message, declared in `packages/spec/src/shared/identifiers.zod.ts` (lines 104 and 107 on the first round's base). It reaches the form face through `SelectOptionSchema.value` (`data/field.zod.ts`). `FormSelectOptionSchema` reuses that value **by reference**, and the `property schemas are shared BY REFERENCE` pin in `form-select-option.test.ts` holds it there. - **The thrower at module load** is `defineForm` (`ui/view.zod.ts`). On the base it threw through `FormViewSchema.parse`; since the first round it runs `safeParse` and throws the refusal itself. All 17 `packages/spec/src/**/*.form.ts` modules call it at module scope. - **The remedy cannot go where the text is declared.** The same grammar also bounds object-field options (`Field.select.options`) and three object-storage names. For those, "omit `options`, derive from the served JSON Schema" is the wrong advice. A form-face-only message would need a second `value` schema, and that breaks the by-reference derivation the ruling cites. A zod error map on a parent object cannot rewrite the issue either, because the regex check's own `error` resolves first. `defineForm` is the one door where the remedy is true: it stamps `data.provider: 'schema'` on every form it builds. So the sentence is appended there, and only there. ## Measured first, on `origin/main` @ `dabf8d795e` (first round) 1. **Today's refusal** for the card's own example, `defineForm({ schemaId: 'action', type: 'simple', sections: [{ label: 'X', fields: [{ field: 'openIn', options: [{ label: 'New tab', value: 'new-tab' }] }] }] })`: a `ZodError` from `defineForm`, with one `invalid_union` issue at `sections.0.fields.0`. Its object branch carries `{ code: 'invalid_format', format: 'regex', pattern: '/^[a-z][a-z0-9_.]*$/', path: ['options', 0, 'value'] }` with this message, verbatim: `System identifier must be lowercase, starting with a letter, and may contain letters, numbers, underscores, or dots (e.g., "user_profile" or "order.created")` `perRecord` and `system-data` gave the same issue shape and the same text. A one-character value gives `too_small` with `System identifier must be at least 2 characters`. 2. **The describe authors read** (`view.zod.ts:3235` on that base): `Options for select/multiselect/radio/checkboxes fields (per-option \`default\` is not accepted here — declare the pre-selected choice on the object definition)`. It does not name a JSON Schema, `helpText` or omitting `options`. 3. **Census of hand-listed enum members**: see Acceptance notes. None of the 27 rows is broken by this change, and under ruling 乙 every one of them is the permitted shape. ## Tests `packages/spec/src/ui/form-option-enum-derive.test.ts` (33 tests). Its assertions name subjects (omitting `options`, the JSON Schema, `helpText`, members that cannot be spelled) rather than whole sentences. - **The thrown class, and the printed wall (round 3).** Every refusal the file reads goes through `refusal()`, which asserts a `z.ZodError`, an `Error`, a string `stack`, and a stack that names this test file, the module that called `defineForm`. A new case reads the head of the stack, which is what an uncaught throw prints: `ZodError: `, today's grammar message JSON-escaped, and the derive path with its scope. - **Refusal.** The refusal for `new-tab`, `perRecord`, `system-data` (`invalid_format`) and `x` (`too_small`) names the derive path and scopes it to members that cannot be spelled. The grammar message is kept verbatim ahead of the remedy, read live off the object face. A nested row (composite `fields`) gets the same remedy. - **Firing controls for the predicates.** Both predicates are RED on today's message: the object face raises the grammar issue through the very property schema the form face shares, with no remedy. The blanket-rule predicate is LIT on the two spellings the first round shipped, so its "states no blanket rule" assertions cannot be vacuous. - **Ruling 乙 item 1, on real spec enums.** Each case has a firing and a dark control. Every enum is read off the served JSON Schema (`z.toJSONSchema(getMetadataTypeSchema(type))`, input side), so "unspellable", "spellable" and "subset" are measured, not assumed. - `object.managedBy` (members that cannot be spelled): with inline `options` it is REFUSED. Every unspellable member is refused with the remedy, and no spellable one is. The same row without `options`, meanings in `helpText`, is GREEN. - `object.sharingModel` with a labelled full list (the objectstack-ai#19331 shape): GREEN, labels kept. The same list with one member re-spelled with a hyphen is REFUSED at that member. - `field.deleteBehavior` master_detail subset (`cascade`, `restrict`, no `set_null`): GREEN, not widened. The lit precondition shows `set_null` is a served member. The same subset with one member capitalised is REFUSED at that member. - **The verdict did not move.** The same values are refused, `new_tab` is still accepted, and a spellable inline option still builds. - **The remedy is scoped.** An unknown key on the option, and an unrelated refusal on the same form, are both answered without it. - **The describe states ruling 乙's rule** in the served JSON Schema (`z.toJSONSchema(FormFieldSchema)`). It permits an inline list (human labels, a deliberate subset). It names the derive path, scoped to members that cannot be spelled. It no longer states the blanket rule. It keeps the per-option `default` sentence. **Old-wording pins, reversed rather than deleted.** A `git grep` for the old describe, the old remedy and the old ruling's 「never hand-listed」 found one assertion pinning the old wording: the describe test's `toContain('spec enum')`. It became the assertions above: the permission and the scoped derive path are present, and the blanket rule is absent. The file header's restatement of the old rule is rewritten to ruling 乙. The other 「never hand-listed」 hits in the repository (nine test and source comments) describe unrelated derived vocabularies. `../objectui` has no hit for either old sentence. **Ablation, round 2** (one-shot, at `74ea5dbba3`, through `scripts/ablation-replace.mjs` under the verify lock, one leg at a time, with the old wording put back). The test imports `./view.zod` as source, so no `dist` is in the path. | leg | mutation | anchor | blob | result | |:--|:--|:--|:--|:--| | 1 | remedy constant back to *When this row edits a spec enum, omit `options`* | x1 → x0 | `d6471f538d06` → `c91e601551c2` | `Tests 6 failed \| 26 passed (32)`: the four scoped-remedy cases, the nested row, the `managedBy` FIRING case | | 2 | describe back to *a row whose key is a spec enum omits `options`* | x1 → x0 | `d6471f538d06` → `1e03c6756624` | `Tests 3 failed \| 29 passed (32)`: the permission, scoped-derive and no-blanket-rule describe cases | Both legs went red in the expected direction. Both restored: blob after restore `d6471f538d06` == HEAD, and `git diff HEAD` was empty. The first round's ablation, at `2aa26de218`, removed the remedy altogether (`throw parsed.error;`) and gave `Tests 9 failed | 7 passed (16)`, which showed the remedy itself is load-bearing. Suite runs, all at `76a053e9d0` (the PR head): | run | result | |:--|:--| | `@objectstack/spec` `vitest run --project local` | `Test Files 532 passed (532)` · `Tests 15688 passed \| 2 todo (15690)` | | `@objectstack/spec` `vitest run --project repo` | `Test Files 35 passed (35)` · `Tests 602 passed (602)` | | `@objectstack/spec` `typecheck` (tsc + scripts + test layer) | exit 0. The test file is in `tsconfig.test.json`'s program, and `view.zod.ts` in `tsconfig.json`'s (`--listFilesOnly`: 1 hit each) | | `@objectstack/spec` `check:generated` | `All 15 generated artifacts are up to date`, against a `dist` built at this head | | `@objectstack/spec` `check:docs` | `225 generated files in sync with packages/spec` | | eslint, narrowed to the two changed `.ts` files | `--no-inline-config --format json`: 2 files, 0 errors, 0 warnings. Both are in eslint's population (`--print-config` resolves a config for each). The config is not type-aware (no `parserOptions.project` / `projectService`), so this diff cannot move a verdict on an untouched file. The changeset and `view.mdx` resolve no eslint config | **The regenerated page against `main`.** Against the merged `main` tip `c8399867b8`, `view.mdx` differs in exactly the two `options` rows. The six PRs that last moved that page on `main` are `95fb417ec8`, `48c91e9e46`, `9dcdb775a0`, `2b52a5b013`, `b01bdbc4d9` and `1ff3a8f210`. Every line they added that is still on `main`, 51 in all, was grepped quoted-exact (`git grep -F -c`). Each has the same count on `c8399867b8` as on this branch, with 0 mismatches. In round 3 neither side moved the page: `git diff --quiet` exits 0 for `view.mdx` from `c8399867b8` to `origin/main` `e8f163fc3a`, and from `74ea5dbba3` to `76a053e9d0`. **Gates:** `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` at `76a053e9d0` derived 107 commands. The count matches round 2's 107 at `74ea5dbba3`, and the list is identical to the one derived on a probe tree at `origin/main` `e8f163fc3a` with this PR's four files. The `--ran` reconciliation reports `107 derived famil(ies) accounted for — 107 run, 0 NOT-MEASURED`. All 107 exited 0 on the first run. Their prerequisites were built before it: a spec build, then a turbo build of every package except docs (`73 successful, 73 total`). In round 2, seven of them first exited 3 and went green once those prerequisites were built: - `check:doc-formula-expressions`, `check:doc-security-posture`, `check:docs-transcript-drift`: after the `@objectstack/lint...` closure. - `check:lean-entry-closure`: after the `@objectstack/objectql...` closure. - `check:skill-examples`, `check:dual-build-cjs-loads`, `check:type-check-debt`: after a turbo build of every package except docs (73 tasks). **The `Test Core (1/6)` walker race.** On the first round's head, `Test Core (1/6)` was red on `scripts/check-error-status-conformance.mjs`'s `walk()`: an ENOENT from a transient `tsup.config.bundled_*.mjs` (the open finding objectstack-ai#19667; objectstack-ai#19916 is closed). This base merge re-measured it. On `74ea5dbba3` every check run completed `success`, including `Test Core (1/6)` and all seven required contexts. That script is not edited here. ## Changeset: `patch` Runtime text in a released package changes. The `defineForm` refusal ships in `@objectstack/spec`'s `dist`, and the describe is served in the JSON Schema. That is a released-package change, so there is a changeset. Round 3 changes no word of it: the thrown class is `ZodRealError` again, so its sentence "`defineForm` still throws a `ZodError` at module load with the same issues and codes" is literally true. It is `Clause-②: no`: every value accepted or refused before is accepted or refused now, and nothing an author can write is added or removed. So it takes the checklist's `patch`, not `minor`. ## Sibling PRs - objectstack-ai#19861's region (`checkViewFilterRuleValueShape` / `ViewFilterRuleSchema`) has landed on `main` and came in with the base merge. It merged without a conflict, and this PR does not touch it. - objectstack-ai#19809 is the one open PR that also edits `view.zod.ts` and `view.mdx`. That was re-derived from the file lists of all 34 open PRs on 2026-09-24. Its regions (`PaginationConfigSchema`, the per-kind Gallery / Timeline / Kanban / AddRecord configs, `rowLimitKey`, the `CalendarConfig` type exports) do not overlap the `FormFieldBaseSchema.options` row or `defineForm`. Its `view.mdx` hunks do not touch the two `options` rows. If the two collide, the page is regenerated, never hand-merged. ## Acceptance notes - **Census: 27 inline `options` rows in 9 of the 17 `packages/spec/src/**/*.form.ts` modules** (`git grep` at `74ea5dbba3`: object 12, field 3, hook 3, action 3, page 2, and agent, skill, permission and email_template 1 each). The first round's census grouped them as 11 of 17 metadata forms. This round did not re-derive that grouping. Each row's key was resolved in the served JSON Schema at `dabf8d795e`. - All 27 keys are spec enums. None lists a non-member. **None contains an unspellable member.** So under ruling 乙 every row is the permitted shape, and item 2 keeps all of them. None is converted. - Lit control: the same instrument, run on the three option-less reference rows, reports the unspellable members it should: `object.managedBy` (4: `system-data`, `engine-owned`, `append-only`, `better-auth`), `action.execution` (`perRecord`) and `action.openIn` (`new-tab`). - **24 rows list every member with human labels**: object `fields.valueDomain`, `fields.deleteBehavior` (lookup row), `fields.returnType`, `fields.summaryOperations.function`, `ownership`, `sharingModel`, `editMode`, `lifecycle.class`, `lifecycle.storage.strategy`, `lifecycle.storage.unit`; field `returnType`, `summaryOperations.function`; hook `body.language`, `onError`, `runAs`; action `mode`, `body.language`, `operation`; page `type`, `interfaceConfig.recordAction`; agent `surface`; skill `surface`; permission `managedBy`; email_template `category`. - **3 rows are deliberate subsets**: object `fields.type` omits `secret` and `user`, and the two master_detail `deleteBehavior` rows (object `fields.deleteBehavior`, field `deleteBehavior`) omit `set_null`. - The objectstack-ai#19331 comment in `object.form.ts` ("Each enum gets an explicit `options` list because the bare member reads as a word…") and the served describe now agree. The first round's contradiction between them is what objectstack-ai#19907 decided. - **Boundary:** a schema-bound form view authored outside `defineForm` (a stack's `view` metadata with `data: { provider: 'schema' }`, parsed at compose or publish) still gets the bare grammar message. The ruling names the module-load refusal. The object-field option face is unchanged by design. --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #19751
Clause-②: no (narrowing)
What changes
checkViewFilterRuleValueShape(the value-shape refinement ofViewFilterRuleSchema,packages/spec/src/ui/view.zod.ts) now refuses a rule with NOvalueon every operator that takes one. Its scalar arm returned early onvalue === undefinedfor every operator, so{ field: 'name', operator: 'icontains' }parsed green, while the key's published description says every operator outsidein/not_in/betweenand the four unary operators takes a scalar, and the query path refuses the lowered rule with400 INVALID_FILTER.is_empty,is_not_empty,is_null,is_not_null) are answered first and stay valueless, with or without a value.in/not_in/betweenkeep their own arms, which already refused an absent value..optional()on the shape; the coupling lives in the refinement, like the other arms.value.describe()is unchanged (it already declares this contract), so no generated reference page moves.Refusal text, one issue at the rule's
valuepath:The leading sentence is the runtime's undefined-comparand sentence ("Filter comparand at PATH is undefined.") with the location named in the view vocabulary: operator and field, the same substitution the list and range arms already make. A view rule has no
wherepath, and the$spelling in that path is not one a view author can write. The unary operator names in the tail come from the schema's ownVIEW_FILTER_VALUELESS_OPERATORS.Producer reading (step 1): objectui at the pinned
.objectui-sha87af769e9a3ee28ace099fdd653d3ebd79fe82e2Read with
git show SHA:PATHfrom a local clone at that sha, not from a working tree.Does the console ever save a value-taking rule with no
value? No.foldFilterGroupToSpecRules, the one fold every view-filter writer sharespackages/app-shell/src/views/viewFilterFold.tsisFilterValueComplete(operator, value)is false (if (takesValue && isMissingValue(...)) continue)isFilterValueCompletepackages/components/src/custom/filter-builder.tsxvalue == null(also'',[], a half-filled pair), so an absent value is always incompleteFilterBuilderField/FilterBuilderWidget: thefilter-builderwidget thatview.form.tsnames forfilterandpage.form.tsforfilterBy, plus the per-tab filter editorpackages/app-shell/src/views/metadata-admin/widgets.tsxViewConfigPanelhosts the same inspector (ViewConfigPanel.tsx,ViewVariantInspector)packages/app-shell/src/views/ObjectView.tsxfoldUrlFilterTriplesToSpecRulespackages/app-shell/src/views/ObjectDataPage.tsxViewFilterRuleSchema.safeParseper rule, refused rules dropped; the URL triples (drillUrlFilters.ts,parseUrlFilterTriples) skip an empty param and always carry a valueOne edge, stated rather than hidden:
handleViewConfigSave(ObjectView.tsx) persists the config draft whole. A view whose STORED body already carries such a rule (hand-authored, or written by another tool) and is re-saved through the panel without its filter being touched now gets the refusal at save. That view already fails every query today (next table).Does anything drop a valueless row between storage and the query? No.
viewFilterRuleToNode, behindtoFilterNode/mergeFilterNodes(plugin-listbuildEffectiveFilter, plugin-viewObjectView,ObjectGrid,RelatedList,LineItemsPanel)packages/core/src/utils/filter-converter.tsvaluelowers to the 2-tuple[field, operator]and nothing skips it; its own comment records the runtime throwingINVALID_FILTER/ 400 for['name','icontains']lowerViewFilterRulepackages/rest/src/view-filter-rule-lowering.tspackages/metadata-protocol/src/protocol.tsisFilterAST, thenparseFilterAST, which throwsMeasured on this tree's spec source (4112752):
isFilterAST(['and', ['name','equals'], ['status','equals','open']])is true, andparseFilterASTof it throwsINVALID_FILTER/ 400, "Filter comparand at where.$and[0].name is undefined". One valueless rule fails the WHOLE view's query, its good rules included.So no working flow saves or executes this shape, and refusing it at save breaks nothing that works today.
Today's behaviour for the whole class (step 2)
Measured at
origin/main4112752 by script. The operator list isVIEW_FILTER_OPERATORSread at runtime; the unary set was derived by behaviour from the schema's own scalar arm (an array is refused on every non-list, non-range operator outside the private valueless set).ViewFilterRuleSchema,valueomitted, before this changeparseFilterAST([field, op])equals,not_equals,contains,not_contains,icontains,starts_with,ends_with,greater_than,less_than,greater_than_or_equal,less_than_or_equal,before,after(13)INVALID_FILTER/ 400, "Filter comparand at where.name (or where.name.$op) is undefined"in,not_inbetweenis_empty,is_not_empty,is_null,is_not_null{ "$null": true }/{ "$null": false }After this change the 13 are refused. The other rows are unchanged.
ADR-0087 reading (step 4)
scripts/check-changeset-no-major.mjs: the level does not carry breaking-ness, and "the mandatory information carriers for breaking-ness in the meantime are the BREAKING banner the author writes in the changeset body and the ADR-0087 migration-ledger disposition".scripts/check-adr-0087-registration.mjsthen requires a disposition on the declared-breaking changeset.registered, notnot-required. The author has a hand prescription (write the value, switch to a unary operator, or delete an unfinished row), andno-migration-prescriptionis refused for a body that carries one. None of the other categories fits: the package publishes, no existing entry covers absence, and the surface is a schema, not a runtime interface or a type surface.view-filter-rule-scalar-operator-array-refused(the sibling arm of this same check) andfilter-preset-ordering-comparand-refused(a shape that never executed usefully) both registered a semantic entry under protocol major 18.packages/spec/src/migrations/entries/semantic/18.view-filter-rule-absent-value-refused.ts.packages/spec/src/migrations/registry.tswas regenerated bypnpm --filter @objectstack/spec gen:migration-registryand not hand-edited;check:migration-registryis green. No D2 conversion: there is no value to infer.check-adr-0087-registration --base origin/mainreads the changeset as[BREAKING+clause-②-narrowing] registered view-filter-rule-absent-value-refused (new here).spec-changes.jsonanddocs/protocol-upgrade-guide.mddid not move. The protocol-18 step stays inert until the protocol major reaches 18, andcheck:spec-changes/check:upgrade-guideare green without regeneration.Changeset (step 7)
.changeset/19751-view-filter-rule-absent-value-refused.md,minoron@objectstack/spec.files[]shipsdistandsrc/**/*.zod.ts, and both carry the refinement. Its summary is the user-visible change: a stored view filter rule with no value on a value-taking operator is now refused at save instead of failing every query. It carries the BREAKING banner, a FROM/TO block,Clause-②: no (narrowing)and the registered disposition marker. One sentence names that it reverses the carve-out the still-pending #19514 changeset records (an omitted value "still parses", an absent comparand "is left unjudged"), so the two entries do not contradict each other in the compiled CHANGELOG. The #19514 file itself is not edited.Level:
minor. An accept-set narrowing declared(narrowing)is BREAKING (AGENTS.md, Post-Task Checklist step 3), and during the launch window a breaking change ships asminor: the header ofscripts/check-changeset-no-major.mjssays "During the launch window we ship breaking changes asminor", and that the BREAKING banner and the ADR-0087 disposition carry the break, not the level. Both precedents above shippedminorwith the same banner. The first round graded thispatch; the at-tier contract review (record 5808364674) failed that, and the patch-round commit ab0104d changes the frontmatter tominorand rewrites the banner sentence to state the convention ("Shipped asminorunder the repo's launch-window convention for accept-set narrowings"). That commit moves no package file.The PR's
Clause-②: no (narrowing)line is the claim's, copied verbatim, and matches the changeset's line. With the arm present, the level axis ofcheck-changeset-no-major.mjsjudges the level instead of standing down. Measured offline with--eventon this body, it refuses the first round'spatchhead 22a14a1 (exit 1) and passesminorat ab0104d (exit 0).Fixtures, examples and pins (step 5)
.ts/.tsx/.mts/.js/.mjs/.jsonoutsidecontent/docs/references/(1,739 files mentionoperator) found 311 object literals withfieldplus a string-literal value-taking operator (aliases folded). 20 of them have novaluekey, and none is a view filter rule in a shipped example or seed:expectedValue, a different schema) inexamples/app-showcase/qa/platform-smoke.test.json;packages/core/src/qa/runner.test.tsandpackages/spec/src/qa/testing.test.ts;packages/spec/src/ai/skill-trigger-condition-value-shape.test.ts;packages/metadata-protocol/src/protocol.graft-normalized-operators.test.ts;view-filter-rule-value-shape.test.ts.Markdown (
.md/.mdx) has no match. No fixture was an authoring mistake, so no fixture was edited.packages/spec/src/ui/view-filter-rule-value-shape.test.ts:equals + omittedandgreater_than + omitted, from accepted to refused.packages/spec/src/data/filter-icontains-parse-door.test.ts: "ABSENCE is left unjudged" now asserts that absence is refused once, in the absent-value arm's words and never in the conformance table's.src/data/, not besideview.zod.ts, and it had to move with the carve-out it pinned.ListView.filter, a tab filter,Page.filterBy, a related-list filter, a lookup picker filter, plusObjectGridProps.defaultFilters) are allz.array(ViewFilterRuleSchema). The full spec suite is green, and a new pin drives the refusal throughListView.filteratfilter.1.value.Tests
VIEW_FILTER_OPERATORSminus the four valueless operators. The valueless set is module-private inview.zod.tsand deliberately not exported, so the test reuses the file's existing transcription, and a new two-way sweep holds that transcription equal to the private set by behaviour: over every operator, an absent value is accepted exactly when the operator is valueless.vitest run --project localonview-filter-rule-value-shape.test.tsandfilter-icontains-parse-door.test.ts: 104 passed.scripts/ablation-replace.mjs: the anchorif (value === undefined) {was replaced byif (value === undefined) return;followed byif (false) {, which is the base behaviour (an absent value returns before any issue). The anchor went 1 to 0 and the blob b6b2f445 to 8c2839db. Result: 11 new pins red, 57 green. After the restore, the blob equals HEAD andgit diff HEADis empty. A first attempt was refused by the tool before anything ran, because its replacement contained the anchor; nothing was measured on that attempt.localproject at 22a14a1: 522 files passed, 15,420 tests passed. One file skipped by its own stale-dist condition (scripts/root-entry-type-nameability.pin.test.ts); after a rebuild at the same head it ran withOS_EXPECT_ROOT_NAMEABILITY=1: 2 passed.repoproject at 22a14a1: 34 files, 587 tests passed.pnpm --filter @objectstack/spec typecheckat 22a14a1: exit 0 (tsc, scripts typecheck,check:test-typecheckOK).Gates
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsat 22a14a1 derived 86 commands. All were run and reconciled with--ran: 84 exit 0, 2 NOT MEASURED, 0 unrun. The two NOT MEASURED arecheck:dual-build-cjs-loadsandcheck:type-check-debt, both exit 3 PREREQUISITE NOT MET because they need the whole-workspace build. They are left to CI.check:generatedis 15/15 up to date after a fresh build at that head.Generated files that moved:
packages/spec/src/migrations/registry.tsonly, viagen:migration-registry.Patch round at ab0104d:
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsderived 86 commands from a tree 53 commits behind origin/main 2c1011b. origin/main's selector over the same six paths adds one family,check:migration-registry, which was run too (exit 0). The--ranreconciliation reads 84 run, all exit 0, 2 NOT MEASURED (check:dual-build-cjs-loads,check:type-check-debt: exit 3 PREREQUISITE NOT MET, they need the whole-workspace build, left to CI), and 0 unrun.check-changeset-no-major --base origin/mainprints "This diff introduces nomajorbump."check-adr-0087-registration --base origin/mainreads[BREAKING+clause-②-narrowing] registered view-filter-rule-absent-value-refused. CI at ab0104d: 35 check runs, 32 success, 3 skipped (Console Pin Gate, Build Docs, Packed-tarball smoke), 0 failure.Scope held
view.zod.ts, onlycheckViewFilterRuleValueShapeand its docblock changed. TheViewFilterRuleSchemablock is untouched: its JSDoc and.describe()are still true. None of PR fix(spec): one row bound per view — retire the unpublished per-kind viewlimit#19809's regions is touched.FILTER_TEXT_CASESgains no row. There are no objectui or runtime (parseFilterAST) edits.origin/mainhas moved 8 commits past the branch point: spec(ui): declare the columns x hiddenFields x fieldOrder composition on the list view, with pins and a re-cited liveness row (ruling B) #19598 touches theListViewshape inview.zod.ts, and feat(spec)!: retire connector.connectionTimeoutMs — carried everywhere, applied nowhere #19657 touchesregistry.ts. A driver-lessmerge-treeof HEAD ontoorigin/main8cbc3c0 is clean. Main is not merged in.Acceptance notes
view-filter-rule-scalar-operator-array-refusedsays, in its replacement prose, "An omitted value is still an omitted value". That was true of its own arm; after this change an omitted value on a scalar operator is refused. Both entries sit in the uncut protocol-18 step. The new entry's leading comment names the reversal, and the sibling's text was left as it is (it is outside this card's file surface).checkViewFilterRuleTextComparand's docblock, carve-out 1, says an omitted comparand "is left to whatever judges absence". That stays true: the shape arm now judges it. Not edited.Generated by Claude Code