Skip to content

fix(spec): refuse an absent value on a value-taking view filter operator at authoring time - #19861

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-19751-filter-rule-absent-value
Sep 24, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-19751-filter-rule-absent-value

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #19751

Clause-②: no (narrowing)

What changes

checkViewFilterRuleValueShape (the value-shape refinement of ViewFilterRuleSchema, packages/spec/src/ui/view.zod.ts) now refuses a rule with NO value on every operator that takes one. Its scalar arm returned early on value === undefined for every operator, so { field: 'name', operator: 'icontains' } parsed green, while the key's published description says every operator outside in / not_in / between and the four unary operators takes a scalar, and the query path refuses the lowered rule with 400 INVALID_FILTER.

  • The four unary operators (is_empty, is_not_empty, is_null, is_not_null) are answered first and stay valueless, with or without a value.
  • in / not_in / between keep their own arms, which already refused an absent value.
  • The key stays .optional() on the shape; the coupling lives in the refinement, like the other arms.
  • The value .describe() is unchanged (it already declares this contract), so no generated reference page moves.
  • The code comment above the scalar arm, which named an absent value as a carve-out "the query path itself makes", now says the opposite and why. The docblock's "mirrors the query path" list and its runtime-wording section name the new arm.

Refusal text, one issue at the rule's value path:

Filter comparand for operator "icontains" on field "name" is undefined. The rule carries no value, and "icontains" compares the field against one — write the value to compare against, or, if the rule means the field has no value, use an operator that takes none ("is_empty" / "is_not_empty" / "is_null" / "is_not_null"), which reads its direction from its name. This is refused at authoring time because the query path refuses it too (400 INVALID_FILTER).

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 where path, 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 own VIEW_FILTER_VALUELESS_OPERATORS.

Producer reading (step 1): objectui at the pinned .objectui-sha 87af769e9a3ee28ace099fdd653d3ebd79fe82e2

Read with git show SHA:PATH from a local clone at that sha, not from a working tree.

Does the console ever save a value-taking rule with no value? No.

writer file at the pinned sha what happens to a half-filled row
foldFilterGroupToSpecRules, the one fold every view-filter writer shares packages/app-shell/src/views/viewFilterFold.ts a row whose operator takes a value is dropped when isFilterValueComplete(operator, value) is false (if (takesValue && isMissingValue(...)) continue)
isFilterValueComplete packages/components/src/custom/filter-builder.tsx false for value == null (also '', [], a half-filled pair), so an absent value is always incomplete
FilterBuilderField / FilterBuilderWidget: the filter-builder widget that view.form.ts names for filter and page.form.ts for filterBy, plus the per-tab filter editor packages/app-shell/src/views/metadata-admin/widgets.tsx calls the fold on every change; the runtime ViewConfigPanel hosts the same inspector (ViewConfigPanel.tsx, ViewVariantInspector)
list toolbar packages/app-shell/src/views/ObjectView.tsx no automatic write at all (its docblock: "There is deliberately NO persistViewFilter"); explicit saves go through the fold
drill-down "Save as view", foldUrlFilterTriplesToSpecRules packages/app-shell/src/views/ObjectDataPage.tsx ViewFilterRuleSchema.safeParse per rule, refused rules dropped; the URL triples (drillUrlFilters.ts, parseUrlFilterTriples) skip an empty param and always carry a value

One 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.

layer file reading
console lowering: viewFilterRuleToNode, behind toFilterNode / mergeFilterNodes (plugin-list buildEffectiveFilter, plugin-view ObjectView, ObjectGrid, RelatedList, LineItemsPanel) objectui packages/core/src/utils/filter-converter.ts a rule without value lowers to the 2-tuple [field, operator] and nothing skips it; its own comment records the runtime throwing INVALID_FILTER / 400 for ['name','icontains']
REST lookup-picker route: lowerViewFilterRule this repo, packages/rest/src/view-filter-rule-lowering.ts the same 2-tuple; the module forwards and never drops
query normalizer this repo, packages/metadata-protocol/src/protocol.ts isFilterAST, then parseFilterAST, which throws

Measured on this tree's spec source (4112752): isFilterAST(['and', ['name','equals'], ['status','equals','open']]) is true, and parseFilterAST of it throws INVALID_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/main 4112752 by script. The operator list is VIEW_FILTER_OPERATORS read 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).

operators ViewFilterRuleSchema, value omitted, before this change parseFilterAST([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) ACCEPT throws INVALID_FILTER / 400, "Filter comparand at where.name (or where.name.$op) is undefined"
in, not_in refused by the list arm throws, "requires an ARRAY of values"
between refused by the range arm throws, "requires a [min, max] value array"
is_empty, is_not_empty, is_null, is_not_null accept { "$null": true } / { "$null": false }

After this change the 13 are refused. The other rows are unchanged.

ADR-0087 reading (step 4)

  • This narrows a published accept set. The repo's rule for that during the launch window is in the header of 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.mjs then requires a disposition on the declared-breaking changeset.
  • The disposition is registered, not not-required. The author has a hand prescription (write the value, switch to a unary operator, or delete an unfinished row), and no-migration-prescription is 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.
  • Precedents: view-filter-rule-scalar-operator-array-refused (the sibling arm of this same check) and filter-preset-ordering-comparand-refused (a shape that never executed usefully) both registered a semantic entry under protocol major 18.
  • Added: packages/spec/src/migrations/entries/semantic/18.view-filter-rule-absent-value-refused.ts. packages/spec/src/migrations/registry.ts was regenerated by pnpm --filter @objectstack/spec gen:migration-registry and not hand-edited; check:migration-registry is green. No D2 conversion: there is no value to infer.
  • check-adr-0087-registration --base origin/main reads the changeset as [BREAKING+clause-②-narrowing] registered view-filter-rule-absent-value-refused (new here).
  • spec-changes.json and docs/protocol-upgrade-guide.md did not move. The protocol-18 step stays inert until the protocol major reaches 18, and check:spec-changes / check:upgrade-guide are green without regeneration.

Changeset (step 7)

.changeset/19751-view-filter-rule-absent-value-refused.md, minor on @objectstack/spec. files[] ships dist and src/**/*.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 as minor: the header of scripts/check-changeset-no-major.mjs says "During the launch window we ship breaking changes as minor", and that the BREAKING banner and the ADR-0087 disposition carry the break, not the level. Both precedents above shipped minor with the same banner. The first round graded this patch; the at-tier contract review (record 5808364674) failed that, and the patch-round commit ab0104d changes the frontmatter to minor and rewrites the banner sentence to state the convention ("Shipped as minor under 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 of check-changeset-no-major.mjs judges the level instead of standing down. Measured offline with --event on this body, it refuses the first round's patch head 22a14a1 (exit 1) and passes minor at ab0104d (exit 0).

Fixtures, examples and pins (step 5)

  • An AST scan of every tracked .ts / .tsx / .mts / .js / .mjs / .json outside content/docs/references/ (1,739 files mention operator) found 311 object literals with field plus a string-literal value-taking operator (aliases folded). 20 of them have no value key, and none is a view filter rule in a shipped example or seed:
    • 7 are QA assertions (expectedValue, a different schema) in examples/app-showcase/qa/platform-smoke.test.json;
    • 5 are QA assertions in packages/core/src/qa/runner.test.ts and packages/spec/src/qa/testing.test.ts;
    • 1 is a skill trigger condition in packages/spec/src/ai/skill-trigger-condition-value-shape.test.ts;
    • 4 are a structural walk with no schema in packages/metadata-protocol/src/protocol.graft-normalized-operators.test.ts;
    • 3 are in view-filter-rule-value-shape.test.ts.
      Markdown (.md / .mdx) has no match. No fixture was an authoring mistake, so no fixture was edited.
  • Pins that pinned the removed carve-out and moved with it:
    • packages/spec/src/ui/view-filter-rule-value-shape.test.ts: equals + omitted and greater_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. ⚠️ This file is outside the claim's declared file surface. It is a view-filter-rule test that lives in src/data/, not beside view.zod.ts, and it had to move with the carve-out it pinned.
  • Carriers named in the docblock (ListView.filter, a tab filter, Page.filterBy, a related-list filter, a lookup picker filter, plus ObjectGridProps.defaultFilters) are all z.array(ViewFilterRuleSchema). The full spec suite is green, and a new pin drives the refusal through ListView.filter at filter.1.value.

Tests

  • New pins, with operator lists derived at runtime: value-taking is VIEW_FILTER_OPERATORS minus the four valueless operators. The valueless set is module-private in view.zod.ts and 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 local on view-filter-rule-value-shape.test.ts and filter-icontains-parse-door.test.ts: 104 passed.
  • Firing control at 325052f, through scripts/ablation-replace.mjs: the anchor if (value === undefined) { was replaced by if (value === undefined) return; followed by if (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 and git diff HEAD is empty. A first attempt was refused by the tool before anything ran, because its replacement contained the anchor; nothing was measured on that attempt.
  • Full spec local project 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 with OS_EXPECT_ROOT_NAMEABILITY=1: 2 passed.
  • Spec repo project at 22a14a1: 34 files, 587 tests passed.
  • pnpm --filter @objectstack/spec typecheck at 22a14a1: exit 0 (tsc, scripts typecheck, check:test-typecheck OK).

Gates

node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands at 22a14a1 derived 86 commands. All were run and reconciled with --ran: 84 exit 0, 2 NOT MEASURED, 0 unrun. The two NOT MEASURED are check:dual-build-cjs-loads and check:type-check-debt, both exit 3 PREREQUISITE NOT MET because they need the whole-workspace build. They are left to CI. check:generated is 15/15 up to date after a fresh build at that head.

Generated files that moved: packages/spec/src/migrations/registry.ts only, via gen:migration-registry.

Patch round at ab0104d: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands derived 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 --ran reconciliation 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/main prints "This diff introduces no major bump." check-adr-0087-registration --base origin/main reads [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

Acceptance notes

  • The sibling entry view-filter-rule-scalar-operator-array-refused says, 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

…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>
@github-actions

github-actions Bot commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

2 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
  • 4 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.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

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 2c1011b01bc071c545f72f2761647b8d9ab56375 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 8a983b94d1df26cec68edd780f828452c2df5c7b — the merge of head ab0104d0020f8f88ecc62828ed857827e5c28873 into base 2c1011b01bc071c545f72f2761647b8d9ab56375, 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 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

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: 47/47 CONTRACT_REVIEW_TIER
Head-sha: 22a14a10384056741dda7a2d8e2626901bf8fd56

Isolated at-tier reviewer subagent, run by the domain:spec seat-4 session on the maintainer's instruction in that session (「帮我处理」, with the landing route chosen there); every one of its 47 transcript turns served at the tier the constant names. Adopted by the seat 2026-09-24T05:38Z. The record below is the reviewer's, unedited except the two header lines.

① Derived judgments

Ruling 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 value on value-taking operators at authoring time, with a prescription in the file's wording style. The unary operators stay valueless." Surface: view.zod.ts region checkViewFilterRuleValueShape + the ViewFilterRuleSchema block, tests beside it, regenerated files, an ADR-0087 entry if required, .changeset/.

Refs: git rev-parse origin/main = c164186; git rev-parse refs/review/pr-19861 = 22a14a1; merge-base 4112752; two commits (325052f, 22a14a1); six files, matching pulls/19861/files.

  1. Scalar arm, accept-set change. Base packages/spec/src/ui/view.zod.ts:744 at 4112752 reads if (value === undefined) return; ahead of the valueless-operator check at :745. PR ref view.zod.ts:758-778: the valueless check is answered first, then if (value === undefined) adds one custom issue at path ['value'] and returns. VIEW_FILTER_OPERATORS at PR ref :254-264 lists 20 operators; minus in/not_in, between, and the four in VIEW_FILTER_VALUELESS_OPERATORS (:538-540) leaves the 13 scalar operators. Effect: 13 operators with value omitted or explicitly undefined: ACCEPT to REFUSE. Right against the ruling.
  2. Unary four (is_empty/is_not_empty/is_null/is_not_null): absent value still accepted, answered before the new arm. Right ("stay valueless"). Pinned both directions at PR-ref test view-filter-rule-value-shape.test.ts:354-366.
  3. in/not_in/between: no change; their arms already refused absence (pinned :315-324 with their own wording).
  4. value: null on a scalar operator: unchanged, still accepted (null is not undefined; falls through to the array check). Not in the card; correct not to touch.
  5. One issue per rule: checkViewFilterRuleTextComparand at PR ref :846 keeps if (value === undefined) return;, so icontains + absent yields one issue (sweep at test :293-303). Its docblock :820-823 "an omitted comparand is left to whatever judges absence" remains true.
  6. Prescription wording (PR ref :770-778): leads with the runtime's sentence "Filter comparand for operator X on field Y is undefined.", names operator and field, prescribes writing the value or one of the four valueless operators (joined from VIEW_FILTER_VALUELESS_OPERATORS), closes with "400 INVALID_FILTER"; no tracker id (pinned :349-352). In the file's style, like the list/range arms.
  7. Runtime untouched: no parseFilterAST, no packages/rest lowering, no FILTER_TEXT_CASES row, no objectui edit — honours the card's ⛔ on growing the conformance table.
  8. Step 1 (producer) verified, not trusted. git show 4112752ec3:.objectui-sha and git show refs/review/pr-19861:.objectui-sha both print 87af769e9a3ee28ace099fdd653d3ebd79fe82e2 (the PR body's sha). At that sha in the objectui repo: packages/app-shell/src/views/viewFilterFold.ts:198-200 takesValue && isMissingValue(...) continue; packages/components/src/custom/filter-builder.tsx:952-965 isFilterValueComplete returns false on value == null; packages/app-shell/src/views/ObjectDataPage.tsx:141 ViewFilterRuleSchema.safeParse per rule. Clean, so step 2 was authorised. See ③ for main's newer pin.
  9. ADR-0087: new semantic entry packages/spec/src/migrations/entries/semantic/18.view-filter-rule-absent-value-refused.ts, id view-filter-rule-absent-value-refused, surface/replacement/reason/acceptanceCriteria present, no D2 conversion (nothing to infer). registry.ts diff inserts the entry before the sibling view-filter-rule-scalar-operator-array-refused with the leading comment carried, which is what packages/spec/scripts/build-migration-registry.ts:253 (sort by major then id) and :179-183 (leading // run copied) produce. Step-18 entries do not project: git show origin/main:packages/spec/spec-changes.json | grep scalar-operator-array-refused and the same over docs/protocol-upgrade-guide.md print nothing, so no regeneration was owed.
  10. Tests: the two accepted-side pins at base view-filter-rule-value-shape.test.ts:190-191 moved to the refused side; new block :270-379 sweeps every value-taking operator, the alias path, explicit undefined, and a carrier (ListView.filter at filter.1.value).
  11. Fixtures: git grep at origin/main over examples/, packages/, apps/ (non-test), content/docs, docs, *.md/*.mdx for single-line scalar-operator rule literals with no value prints nothing. CI at head: commits/22a14a1038…/check-runs total_count 35, 32 success, 3 skipped (Console Pin Gate, Build Docs, Packed-tarball opt-in), 0 failure; Check Changeset (the job that runs check-adr-0087-registration.mjs, pr-automation.yml:939-940 at origin/main) success.
  12. Required and missing: nothing under the ruling. Beyond the claim: one test file, ③.

② Semver level

Decision: minor. The changeset says patch.

Rule text applied:

  • AGENTS.md at origin/main :1084-1085: "The declaration is Clause-②: yes|no plus at most one arm from the closed pair (widening)/(narrowing): yes takes at least minor, (narrowing) is BREAKING".
  • scripts/check-changeset-no-major.mjs at origin/main, header: "During the launch window we ship breaking changes as minor" and "Until then it is NOT the carrier ... a breaking change ships as minor, so the bump level tells a consumer nothing about whether the release breaks them. The mandatory information carriers for breaking-ness in the meantime are the BREAKING banner ... and the ADR-0087 migration-ledger disposition".

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 — .optional() at base view.zod.ts:894-895, unchanged, so z.input carries value?: for every operator; (b) the repo's own accept-set pin, base view-filter-rule-value-shape.test.ts:188-191, under "what stays accepted ... never stricter than the query path": "value is optional and absence is not a shape. The scalar arm must not turn an omitted comparand into a refusal"; (c) the base code comment view.zod.ts:741-744 recording absence as a carve-out "the query path itself makes"; (d) the pending changeset .changeset/19514-view-filter-rule-scalar-arm-and-icontains-comparand.md:27 at origin/main: "an omitted value still parses (value is optional)", and :35 "a view rule's value is optional, so an absent comparand is left unjudged there"; (e) the shipped ledger prose registry.ts:13340 (sibling entry, in dist through MIGRATIONS_BY_MAJOR): "An omitted value is still an omitted value"; (f) every generated reference table lists value as optional (content/docs/references/ui/view.mdx:320, page.mdx:44, component.mdx:192). Against them stands only the .describe() shape sentence "every other operator takes a scalar" and the JSDoc parenthetical at :888 "(optional for unary operators like is_empty, is_null)" — text that coexisted with (a)-(f) and never said an absent value is refused. "Already declared invalid" needs unambiguous published text; this is not that. The accept set of a published authoring surface narrows: BREAKING, carried in the launch window as minor + BREAKING banner + ADR-0087 marker.

The PR's own changeset agrees on the direction — **BREAKING** (line 13) and Clause-②: no (narrowing) (line 46), which the gate reads as [BREAKING+clause-②-narrowing] (readClause2Line: arm token (narrowing) per scripts/pm/clause2-line.mjs:286-288; signals at check-adr-0087-registration.mjs:632-640) — and only the level disagrees with the convention. The dev's reading of WHICH LEVEL (pr-automation.yml:753-760: "a fix( that changes no public surface stays patch") does not reach this act: the accept set of an exported, published schema is the public surface, and that paragraph grades widenings and names the breaking-ness carriers; it nowhere licenses patch for a declared BREAKING change. Precedent on this same check, file and key: .changeset/19514-...md:2 "@objectstack/spec": minor, body "Shipped as minor under the repo's launch-window convention for accept-set narrowings".

Marker vs scripts/check-adr-0087-registration.mjs: the changeset's HTML-comment marker adr-0087: registered view-filter-rule-absent-value-refused (line 48) matches readDisposition's registered form, names one id that resolves at HEAD and is new in the diff (entry file added), so the registered category's own check passes. Exactly one marker. Clause-②: no (narrowing) is declared (value no, arm narrowing), not the malformed no (widening) pair. Summary fix(spec): without !: the banner is the carrier; fine.

Blocking: .changeset/19751-view-filter-rule-absent-value-refused.md:2 "@objectstack/spec": patch contradicts this finding; must read minor. Line 13's sentence "The level is patch because nothing widens; during the launch window breaking-ness is carried by this banner ..." ships verbatim into CHANGELOG.md and would state the convention falsely; rewrite it (the 19514 wording is the template). The PR body's "⚠️ Level" paragraph then follows.

③ Boundary flags

  • Dev-declared deviation 1 — file outside the claim: packages/spec/src/data/filter-icontains-parse-door.test.ts (claim: view.zod.ts region + tests beside it). Its base pin :204-209 asserted rule().success true for icontains + absent, exactly the removed carve-out, and would be red; the edit keeps the door's own half (refused once, in the absent-value arm's words, never the table's). Non-blocking.
  • Dev-declared deviation 2 — PR body Clause-②: no (the claim's, verbatim) while the changeset carries no (narrowing). The arm is optional by grammar (clause2-line.mjs:80-85); the ADR-0087 gate reads the changeset's line, and the level axis reads the PR body's bare no and stands down. Non-blocking, but the PR-level declaration understates the direction the changeset declares.
  • Prose about to ship false: pending changeset .changeset/19514-...md:27,35 at origin/main still tells consumers an omitted value "still parses (value is optional)" and an absent comparand "is left unjudged". It is unreleased, so it compiles into the same CHANGELOG.md release as this changeset and contradicts it there. .changeset/ is inside the claim's surface; one sentence in that pending file, or a sentence here naming that the carve-out finding(spec): ViewFilterRuleSchema accepts two shapes every consumer refuses, and ObjectGridProps.defaultFilters is z.unknown() so nothing gates it at all — the protocol half of objectui#9050's ruling C′ #19514's changeset recorded is reversed, closes it. Non-blocking (not yet shipped).
  • Shipped ledger prose now stale: registry.ts:13340 sibling entry "An omitted value is still an omitted value". The new entry's leading comment names the reversal; this matches the repo's own precedent (the sibling recorded its reversal of view-filter-rule-value-shaped-by-operator as a new entry, not an amendment). Non-blocking.
  • Pin moved under the reading: origin/main:.objectui-sha is 62597c588072636e9c30ea35b3d89b1e46fd765d (bumped by chore(objectui): bump the console pin to 62597c588072 (carries the fail-closed quick_actions requiredPermissions fix) with 46 re-measured citations, the FormField.span describe re-point and the lockstep re-record #19832 after the branch point); the PR's producer reading names 87af769e9a3e. Re-read at 62597c58: viewFilterFold.ts:198-200, filter-builder.tsx:952-965, ObjectDataPage.tsx:141 are line-identical. The pin is weight-bearing and the reading holds at both. Non-blocking.
  • .describe() untouched: it still states only the shape ("takes a scalar") and never that an absent value is refused; generated reference pages keep value as optional. Not false (the unary carve-out is real), but enforcement is now stricter than the describe's explicit words, and the card's surface included the value key and docblock. Non-blocking.
  • Console Pin Gate skipped by path filter (ci.yml:2337); no export removed or retyped (.optional() unchanged), so the pinned sibling's build is not at stake. Non-blocking.
  • PR is draft; pulls/19861 reads mergeable_state: clean; no review threads or review comments exist; the one issue comment is the docs-drift bot.

Implemented-by: claude/issue-19751-filter-rule-absent-value
Reviewed-by: session_019c3Hi6ZMU1p6m6aA6Bz45d

VERDICT: FAIL

Blocking items:

  1. Changeset level patch at .changeset/19751-view-filter-rule-absent-value-refused.md:2 contradicts ② (minor): an accept-set narrowing declared BREAKING + (narrowing) ships as minor under the launch-window convention (check-changeset-no-major.mjs header; AGENTS.md :1085; precedent .changeset/19514-...md:2). Change the frontmatter to minor and rewrite line 13's "The level is patch because nothing widens" sentence so the shipped CHANGELOG text does not misstate the convention; update the PR body's Level paragraph to match.

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>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: 99/99 CONTRACT_REVIEW_TIER
Head-sha: ab0104d0020f8f88ecc62828ed857827e5c28873

Isolated at-tier reviewer subagent, run by the domain:spec seat-4 session; every one of its 99 transcript turns served at the tier the constant names. Re-review after the takeover patch round (the maintainer's 「你接手派补丁轮」); it supersedes the FAIL record 5808364674. Adopted by the seat 2026-09-24T14:10Z. The record below is the reviewer's, unedited except the two header lines.

① Derived judgments

Ruling applied (card #19751): triage 5786917202 graded class (b) Bug, "the fix pulls the accept set back to what the describe already declares", Clause-② no; dispatch 5793158767 ordered step 1 (measure the producer, stop if it saves a valueless row) and step 2 (refuse an absent value on value-taking operators at authoring time, unary operators stay valueless); takeover claim 5814327629 (updated 2026-09-24T13:55:52Z) restates the original surface and confines the patch round to .changeset/19751-view-filter-rule-absent-value-refused.md, with Clause-②: no (narrowing).

Refs: git rev-parse origin/main = 2c1011b; refs/review/pr-19861 = ab0104d; merge-base 4112752; three commits (325052f, 22a14a1, ab0104d); six files, matching pulls/19861/files. git diff --numstat 22a14a1038 ab0104d002 = the changeset only, +4/-2, so every package byte is the one the prior record judged; re-verified rather than trusted:

  1. Scalar arm, accept-set change. PR ref packages/spec/src/ui/view.zod.ts:769 answers the four valueless operators first, :770-781 then adds one custom issue at path ['value'] on value === undefined and returns; base :744 at 4112752 read if (value === undefined) return;. VIEW_FILTER_OPERATORS (:254-264) is 20; minus in/not_in, between and the four in VIEW_FILTER_VALUELESS_OPERATORS (:538-540) leaves the 13 scalar operators: ACCEPT to REFUSE. Right against the ruling.
  2. Unary four stay valueless (answered before the arm); pinned both directions at view-filter-rule-value-shape.test.ts:356-368 (accepted exactly when in the valueless set).
  3. in/not_in/between: unchanged, their own arms and wording (test :307-327).
  4. value: null on a scalar operator: still accepted (null is not undefined; falls to the array check at :782). Changeset line 23 states this truthfully.
  5. One issue per rule: checkViewFilterRuleTextComparand keeps if (value === undefined) return; at :846; the sweep at test :295-305 asserts exactly one value-path issue for every value-taking operator.
  6. Prescription (:772-778): the runtime's leading sentence with operator and field, both repairs, the valueless names joined from the private set, closes with 400 INVALID_FILTER; no tracker id (test :351-354). In the file's style like the list and range arms.
  7. Runtime, packages/rest lowering, FILTER_TEXT_CASES, objectui and .objectui-sha untouched. Honours the card's ⛔ on growing the table.
  8. Step 1 (producer) verified at BOTH pins, 87af769e9a3e (branch and merge-base .objectui-sha) and 62597c5880 (current origin/main:.objectui-sha): objectui packages/app-shell/src/views/viewFilterFold.ts:198-200 drops a value-taking row when isMissingValue is true and :205 sets rule.value only when defined; :108-113 isMissingValue is the negation of isFilterValueComplete; packages/components/src/custom/filter-builder.tsx:952-965 returns false on value == null, '', []; packages/app-shell/src/views/ObjectDataPage.tsx:140-141 omits an undefined value and safeParses each rule. Line-identical at both shas. Adversarial extra: objectui's VALUELESS_FILTER_OPERATORS (viewFilterFold.ts:84-88) also holds the builder spellings exists/notExists, which have no alias in the spec's table (view.zod.ts:327-330); such a row folds to an operator the z.enum already refused before this PR, so it is not a working flow this change breaks.
  9. ADR-0087: new entry packages/spec/src/migrations/entries/semantic/18.view-filter-rule-absent-value-refused.ts, id view-filter-rule-absent-value-refused, surface / replacement / reason / acceptanceCriteria present, no D2 conversion. registry.ts inserts it before the sibling view-filter-rule-scalar-operator-array-refused with the leading comment carried, which is what packages/spec/scripts/build-migration-registry.ts:253 (sort by major, then id) and :179-183/:261 (leading // run copied) produce; the builder is unchanged between the merge-base and origin/main. Neither id projects into packages/spec/spec-changes.json or docs/protocol-upgrade-guide.md at origin/main (git grep -c on both prints nothing), so no regeneration was owed.
  10. Tests: the two accepted-side pins moved (test :203-210 comment records it); new block :272-381 sweeps the 13, the alias path, explicit undefined, and a carrier (ListView.filter at filter.1.value). packages/spec/src/data/filter-icontains-parse-door.test.ts:204-215 now asserts absence is refused once, in the arm's words and never the table's.
  11. Fixtures at current origin/main 2c1011b (53 commits past the branch point): a git grep for a single-line { field, operator: scalar } literal with no value over examples/, apps/, packages/*/src, content/docs, docs, md/mdx/json/yml (non-test) prints nothing; the end-of-line operator: 'x' form (value on a later line) prints nothing; every one of the scalar-operator rule literals in examples/ and content/docs carries value. Nothing that landed on main emits the shape.
  12. CI at head: commits/ab0104d002/check-runs total_count 42: 37 success, 5 skipped (Auto Label, Build Docs, Check PR Size, Console Pin Gate, Packed-tarball smoke opt-in; the first and third also have a success row), 0 failure. Check Changeset ran twice on this head: 107639840188 (started 12:51:43Z, after the push) and 107664890606 (started 13:56:03Z, after the 13:55:55Z body edit; pr-automation.yml:8 subscribes edited), both success. Required contexts all success.
  13. Required and missing under the ruling: nothing.

② Semver level

Decision: minor. The changeset agrees: .changeset/19751-view-filter-rule-absent-value-refused.md:2 "@objectstack/spec": minor (patch diff 22a14a1..ab0104d changes patch to minor).

Rule text: AGENTS.md at origin/main :1084-1085 ("(narrowing) is BREAKING"); scripts/check-changeset-no-major.mjs header :46-48 "During the launch window we ship breaking changes as minor" and :66-72 "Until then it is NOT the carrier ... a breaking change ships as minor ... The mandatory information carriers ... are the BREAKING banner ... and the ADR-0087 migration-ledger disposition". Precedent on the same check and key: .changeset/19514-view-filter-rule-scalar-arm-and-icontains-comparand.md:2 minor, :7 "Shipped as minor under the repo's launch-window convention for accept-set narrowings".

  • BREAKING banner: line 7 opens **BREAKING**, the signal breakingDeclaration reads (check-adr-0087-registration.mjs:631).
  • Clause-②: no (narrowing) line: changeset line 42 Clause-②: no (narrowing) — no key is added ...; scripts/pm/clause2-line.mjs readArmToken (:281-290) takes the exact (narrowing token and leaves trailing prose alone, so it reads declared, value no, arm narrowing (signal clause-②-narrowing, adr gate :640); not the malformed no (widening). PR body line 3 reads Clause-②: no (narrowing) line-leading, and claim 5814327629 (amended 13:55:52Z) reads the same, verbatim: the dev's open question resolved as option B.
  • ADR-0087 marker vs scripts/check-adr-0087-registration.mjs: the HTML-comment marker on line 44 reads adr-0087: registered view-filter-rule-absent-value-refused; readDisposition (:1923-1935) accepts registered plus one id; exactly one marker in the body; the id resolves to the entry file added in this diff (fresh). Summary fix(spec): without !: the banner carries.
  • Level axis: with the arm present the axis no longer stands down (check-changeset-no-major.mjs:1640-1646: a bare no stands down UNLESS it carries the narrowing arm; :1608 offends on patch for a package whose packages/**/src/** moved; self-test :2962 a declared narrowing graded minor is clean). @objectstack/spec src moved and is graded minor, so the verdict is clean; the 13:56:03Z Check Changeset run on this head, which read the edited body, concluded success. That confirms the dev's offline --event reading (patch head exit 1, this head exit 0) without any gate re-run here.
  • Rewritten convention sentence (line 7): "Shipped as minor under the repo's launch-window convention for accept-set narrowings: during the launch window a breaking change ships as minor, so the level alone does not signal the break — this banner and the ADR-0087 disposition below carry it." TRUE against header :46-48 and :66-72. The false "The level is patch because nothing widens" sentence is gone.
  • Added finding(spec): ViewFilterRuleSchema accepts two shapes every consumer refuses, and ObjectGridProps.defaultFilters is z.unknown() so nothing gates it at all — the protocol half of objectui#9050's ruling C′ #19514 sentence (line 17): "its statements that an omitted value still parses (value is optional) and that an absent icontains comparand is left unjudged on a view rule no longer hold ... refused once, with the message above." TRUE: origin/main:.changeset/19514-...md:27 "an omitted value still parses (value is optional)" and :35 (in the icontains section) "a view rule's value is optional, so an absent comparand is left unjudged there"; "once" is pinned by test :295-305 and the parse-door pin; the message block sits at lines 13-15. The 19514 file is untouched: git diff origin/main refs/review/pr-19861 -- .changeset/19514-...md prints nothing, it is absent from pulls/19861/files, and it is still pending in origin/main:.changeset/.
  • files[] at PR ref ships dist and src/**/*.zod.ts, so the narrowing publishes.

③ Boundary flags

Prior record's non-blocking notes, status at this head:

  • Deviation 1 (filter-icontains-parse-door.test.ts outside the claim's file list): unchanged; the takeover claim restates the surface as "the view-filter-rule tests" and this is one, living in src/data/. Non-blocking, stands.
  • Deviation 2 (PR body bare no under a changeset no (narrowing)): CLOSED. Claim amended, body copied verbatim, level axis armed and green on the post-edit run.
  • Pending finding(spec): ViewFilterRuleSchema accepts two shapes every consumer refuses, and ObjectGridProps.defaultFilters is z.unknown() so nothing gates it at all — the protocol half of objectui#9050's ruling C′ #19514 prose contradiction: CLOSED by line 17 of this changeset; the 19514 file not edited, as the record allowed.
  • Shipped ledger prose (registry.ts sibling "An omitted value is still an omitted value"): unchanged; the new entry's leading comment names the reversal (registry diff :152-157), matching the sibling's own precedent. Non-blocking, stands.
  • Pin moved (origin/main:.objectui-sha 62597c5880 vs the PR's 87af769e9a3e): re-read at 62597c5880 above, the three producer sites are line-identical. Non-blocking, holds.
  • .describe() and the ViewFilterRuleSchema block untouched (no hunk of the four, :573, :645, :690, :737, reaches the value key at :926-936); the describe already says every other operator takes a scalar; generated pages keep optional. Non-blocking, stands.
  • Console Pin Gate skipped by path filter; no export moved. Non-blocking, stands.

New at this head, all non-blocking:

Implemented-by: claude/issue-19751-filter-rule-absent-value
Reviewed-by: session_019c3Hi6ZMU1p6m6aA6Bz45d

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 24, 2026 14:13
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 24, 2026
Merged via the queue into main with commit cc6dfd9 Sep 24, 2026
44 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-19751-filter-rule-absent-value branch September 24, 2026 14:44
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

1 participant