fix(spec): one row bound per view — retire the unpublished per-kind view limit - #19809
Conversation
…limit Remove the per-kind `limit` from the gallery, kanban and timeline view configs, with `rowLimitKey`, `DEFAULT_VIEW_ROW_LIMIT` and the pending changeset that would have published them. `KanbanConfigSchema` has one shape again, so `KanbanConfigParsed` goes and the schema is re-pinned isomorphic (ADR-0122 D3). The truncation obligation moves onto `pagination.pageSize`, the view's remaining row bound. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude <noreply@anthropic.com>
…tired view limit The `object-timeline` `timeline` door describe listed `limit` as a member of the block and called it accepted and defaulted; the block no longer has it. The `object-kanban` / `object-timeline` docblocks and the pending changeset recorded where the view-face per-kind `limit` and its applied default landed. All of it is removed by deletion; the flat `limit` keys, their describes and their declared precedence are unchanged. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude <noreply@anthropic.com>
…tion The build's authorable-surface gate refuses a baseline line that leaves without a proof. The three per-kind configs now name `limit` in their strictObject guidance, so an author who writes it is refused with the pointer to `pagination.pageSize` (proof 4), and the three baseline lines go with the key. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude <noreply@anthropic.com>
authorable-defaults, api-surface, export-origins and the reference docs, each through its own generator after a fresh build. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude <noreply@anthropic.com>
… block `TimelineConfigSchema` is also the nested `timeline` block of an `object-timeline` node, where the row bound is the node's flat `limit`, not `pagination.pageSize`. The prescription now names both. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check12 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. 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 527afc6b70c55abc90d632c9eee81d49e8bb06fe && git checkout 527afc6b70c55abc90d632c9eee81d49e8bb06fe
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin b76aad5f6fac71cbbcd4ea6bfdf21a59e2fd4d56 b7106a4781f3d59f9928d83dec7d6b1927f954f5 && git checkout -B drift-repro b76aad5f6fac71cbbcd4ea6bfdf21a59e2fd4d56 && git merge --no-ff b7106a4781f3d59f9928d83dec7d6b1927f954f5
node scripts/docs-audit/affected-docs.mjs --json b76aad5f6fac71cbbcd4ea6bfdf21a59e2fd4d56 |
Contract reviewServed-tier: Reviewed and posted 2026-09-23T08:07Z by the at-tier review subagent the ① Derived judgments
Sentences that ship or stand (each checked; none found false): the guidance string (true; carries no tracker number); ② Semver level
③ Boundary flags(1) Strict-object guidance added so the build's authorable-surface gate accepts the removal — SOUND, not a workaround. (2) Deleting the component-face describe clauses about the nested (3) Trimming and deleting the two pending release notes — SOUND and gate-consistent. Ruling D step 1 orders the 17393 note rewritten or deleted so no release carries the key; the route-record note's deleted section described the same key and its applied default, and its remaining text is verified true. The foreign-changeset rule (#17712) reds on Follow-ups the seat carries (not grounds): (a) CI at head — 46 runs, de-duplicated by name keeping the latest Model identifiers: swept the 5 commit messages (model-free trailer pair), all 80 added lines, and the PR title and body for every model-identifier spelling — 0 matches; control ( Blocking: none. Implemented-by: VERDICT: PASS |
落地状态 —— 达档复核 PASS;只差维护者对两条待发布说明的书面确认
⏳ 待办: 维护者在本 PR 上书面确认这两条更正(一句即可)。收到之后,本席转 ready 并开启 auto-merge。 Generated by Claude Code |
|
Heads-up for this PR's holder ( PR #19932 has landed on The re-run procedure is in PR #19932's body, section 「Landing order: this PR lands first, then #19809 re-runs the re-key on its own pin」, and the
⛔ Do not hand-resolve the pin block. Run the transform, then check the set with This PR landed first on the maintainer's instruction to follow it to merge: 「你应该跟进到合并啊」, in seat 1's live PM chat. This PR had been a draft since 2026-09-23T08:09Z. |
Two hand-written paths conflicted; both are resolved so each side's intent stands. Generated artifacts are regenerated in the next commit. packages/spec/src/type-alias-convention.pin.test.ts: not resolved by hand. Main re-keyed every isomorphic pin from the IsoNNN counter to a name derived from module path and schema. This branch's pre-merge copy of the file was re-keyed with that change's own rekey.mjs transform (controls: it reproduces ece9f71 from fdeeea0 byte for byte, and is idempotent on main's copy), then main's prose half was re-applied with git merge-file against ece9f71 / fc8eda2. The one conflicting hunk is the tail of the count history: both entries are kept, 789 -> 789 first, then 789 -> 790. The new pin is Iso_ui_view__KanbanConfigSchema, and the ui/view note names it and no longer says a numeral stays vacant. readIsomorphicPins: 790 = 790 against the branch's pre-transform file, set difference 0 both ways, 0 bodies changed, 0 names off the rule, sorted; against main, +1 (ui/view.zod.ts::KanbanConfigSchema). packages/spec/src/ui/view.zod.ts: this branch deletes the rowLimitKey helper and its docblock with the retired per-kind view limit; main re-measured one citation paragraph inside that docblock at the new console pin. The docblock describes a key that no longer exists, so it goes; the rest of main's changes to the file merged cleanly and stay. The branch's delta against main now equals its delta against the merge base, line for line, except that the deleted paragraph is main's re-measured text. Claude-Session: https://claude.ai/code/session_019c3Hi6ZMU1p6m6aA6Bz45d Co-authored-by: Claude <noreply@anthropic.com>
The os-regen driver merged seven generated paths without a text merge and kept this branch's side of each, dropping main's changes to them. This commit takes main's side (os-regen-merge.sh step 2, against the recorded pre-merge base fae8703) and regenerates on the merged tree: spec build (gen:schema), gen:api-surface, gen:export-origins, gen:docs. check:generated: 15 of 15 artifacts current. authorable-surface/ui.json is the one path this branch had edited by hand: its three GalleryConfig / KanbanConfig / TimelineConfig `limit` lines were deleted deliberately, since the generator refuses a bare deletion. That committed deletion is re-applied onto main's side with git apply (context verified), and the build's check (c) proof 4 accepts each of the three: the key is refused as unrecognized and the refusal carries the guidance prescription. For every one of the seven paths, this commit's delta equals main's delta since the merge base, and the branch's delta against main equals the PR's delta against the base. Claude-Session: https://claude.ai/code/session_019c3Hi6ZMU1p6m6aA6Bz45d Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: 139/139 Isolated at-tier reviewer subagent, run by the Reviewed 2026-09-24 by the isolated at-tier review subagent of the ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
Maintainer confirmation — the DELIBERATE CORRECTION of two pending notes, 2026-09-24T23:05Z
Provenance.
What it confirms, at head
The at-tier PASS The red. Landing. Not in this act. The allow-listed Generated by Claude Code |
Two hand-written conflicts, both keeping both intents: - packages/spec/src/ui/view.zod.ts: main re-measured the citations of the per-kind view `limit` docblock at the new console pin; this branch deletes that docblock and its helper with the key. Kept the branch side; main's re-measured text goes with the key it describes. Every other main change to the file auto-merged. - packages/spec/src/ui/component.zod.ts: main re-measured the anchors of the object-kanban "third door" paragraph (the view-face `kanban.limit` spread); this branch deletes that paragraph with the key. Kept the branch side, and annotated main's re-measure list in the same docblock: the seven anchors it records for that paragraph are no longer cited by the block. Generated artifacts are regenerated in the next commit. Claude-Session: https://claude.ai/code/session_019c3Hi6ZMU1p6m6aA6Bz45d Co-authored-by: Claude <noreply@anthropic.com>
The os-regen driver kept the branch side of content/docs/references/data/object.mdx and content/docs/references/ui/view.mdx, dropping main's rows (the currency `scale` retirement, the form `options` describe, the console-pin `span` citation). os-regen-merge.sh step 2 restored main's side; gen:docs then re-derived the branch's change on top. Every regen path now differs from main by exactly the branch's own delta against its previous base. Claude-Session: https://claude.ai/code/session_019c3Hi6ZMU1p6m6aA6Bz45d Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: 104/104 Isolated at-tier reviewer subagent, run by the Reviewed 2026-09-25 by the isolated adversarial reviewer subagent of session ① Derived judgments
② Semver levelUnchanged from the PASSed record: ③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
…tstack-ai#19841) Fixes objectstack-ai#19814 Clause-②: no ## What changes `pagination.pageSize` is the row bound every view type carries, and maintainer ruling D on objectstack-ai#19228 makes it a view's one row bound. The per-kind `kanban.limit`, `gallery.limit` and `timeline.limit` that the ruling retires are still declared on `main` until PR objectstack-ai#19809 lands. The Studio view form (`packages/spec/src/ui/view.form.ts`) offered `pagination` only inside `table_options`, whose condition is `data.type == 'grid' || data.type == null` and whose description is `Grid-only display options.`. An author editing any other view type could not see or set that bound in the form. - `pagination` moves out of `table_options` into its own section, `pagination` (label `Pagination`, collapsible, collapsed), placed right after `table_options`. The new section has **no** `visibleWhen`. - `table_options` keeps `resizable`, `compactToolbar`, `rowHeight` and `selection` under its unchanged grid condition. - **Why a new section and not one of the other two options.** Widening `table_options`' condition would show the four grid-only fields to every view type. Moving `pagination` into `columns_filters` would put a field that is neither a column nor a filter under the label `Columns & filters`. The file already gives each concern its own section. The new description claims only what the schema guarantees: `Page size and page-size options — every view type accepts them, not only grids.` - No schema change. `view.zod.ts`, `component.zod.ts` and every per-kind `limit` are untouched; they belong to PR objectstack-ai#19809. ## Premise, measured on `origin/main` `c1dfa5241b` - The view `type` enum is on the list-view shape (`ListViewSchema.shape.type`). The container `ViewSchema` has no `type` key, and its `list` member resolves to the same nine values: grid, kanban, gallery, calendar, timeline, gantt, map, chart, tree. - `pagination` is a member of the single list-view shape, so every kind has it. Per kind, `ListViewSchema.safeParse({ type, columns: ['name'], pagination: { pageSize: 50 } })` parsed 9/9 and kept `{ pageSize: 50 }`. As a control, `pagination: { pageSize: 50, zzBogus: 1 }` was refused 9/9 with `unrecognized_keys` at `['pagination']`, which shows the block is validated and not stripped. - At base, `view.form.ts` has no per-kind `limit` entry. Nothing PR objectstack-ai#19809 retires is in the form, so this change does not depend on a shape that PR has not landed. ## Tests `packages/spec/src/ui/view-form-pagination.test.ts`, 58 cases: - The kind list is read at runtime from `ListViewSchema`'s `type` enum. A floor names the four types ruling D covers, so an empty derivation cannot pass. - For every kind, and for a view with no `type` yet, `pagination` is offered in exactly one visible section. The form offers it exactly once. - For every kind, the schema accepts a `pagination` block and keeps it. - The four grid-only fields are visible for `grid` and for an unset type, and hidden from each of the eight non-grid kinds. - Section predicates are read by a small reader limited to the one grammar this form uses: disjunctions of `data.type == '...'` and `data.type == null`. Any other term throws and names the predicate, so the pin fails loudly on a predicate it cannot read. **Firing control** (a one-time run, not a kept test). With the fix committed, `packages/spec/src/ui/view.form.ts` was restored to its base blob `bea0c5ab92` with `git restore --source` set to the base commit, and the pin was run. Result: `Tests 8 failed | 50 passed (58)`. The eight failures are exactly the "is visible for type" cases for kanban, gallery, calendar, timeline, gantt, map, chart and tree. The file was then restored from `HEAD` under a trap; its blob was re-checked equal to HEAD's (`bf6c3a7f93`) and `git status` was clean. ## Generated artifacts One repo generator reads `view.form.ts`: `pnpm i18n:extract`. It reaches the form through `METADATA_FORM_REGISTRY` and writes `packages/platform-objects/src/apps/translations/*.metadata-forms.generated.ts`. A search of the tree for the form's section text finds it only in the source and in `en.metadata-forms.generated.ts`. The `viewForm` export keeps its name, and `check:api-surface` reports the surface unchanged. - `17bb7c1603` is the generator output, unedited. It adds the new section's label and description to all four bundles, fills the three translated locales from the source, and adds two `metadataForms.view.sections.pagination.*` rows per locale to the source-hash tables. - `72185706b2` writes the zh-CN, ja-JP and es-ES translations. These leaf values are the only edits these files allow. Each label reuses the locale's existing label for the `pagination` field. A re-run of the extractor then dropped the source-hash rows by itself, and `pnpm check:i18n` reports the bundles in sync at head. - `2bfeee4a23`: `object-lifecycle-panel-echo-decisions.test.ts` pins the per-locale count of translated `.label` leaves across the metadata-form catalog. The count moves from 583 to 584, which is the one section label this PR translates in each locale. ## Changeset - `@objectstack/spec` ships `dist` in its `files[]`. `viewForm` is exported from `./ui` and reaches `./system` through `METADATA_FORM_REGISTRY`. After a build, the new description string is in 6 dist files (`ui/index.{js,mjs}`, `system/index.{js,mjs}`, `browser/system/index.{js,mjs}`). The positive control `Grid-only display options` is in the same 6 files. - `@objectstack/platform-objects` also ships `dist` in its `files[]`. After its rebuild the new string is in 6 dist files (`index`, `plugin` and `metadata-translations/index`, each `.js` and `.mjs`); the control is in the same 6. - Both packages ship the change, so `.changeset/19814-view-form-pagination-all-kinds.md` bumps both as `patch`. AGENTS.md Post-Task step 3: a fix in a released package takes a patch changeset, and `skip-changeset` is only for a diff that publishes nothing. `Clause-②: no`, because no accept set moves. ## Local verification - **Gate families** at head `0395fd696d` (re-run after round 2; first measured at `2bfeee4a23`, same result): `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derived 84 commands. All 84 were run on `2bfeee4a23` and all exited 0. `--ran`, fed the recorded exit codes, reports: "84 derived famil(ies) accounted for — 84 run, 0 NOT-MEASURED (a DERIVED zero — all 84 recorded an exit code and none of them is 3)". On the first pass, `check:i18n`, `check:type-check-debt` and `check:dual-build-cjs-loads` exited 3 (build prerequisite not met). They were measured after `turbo run build --filter='./packages/*' --filter='./packages/*/*'`. - **`@objectstack/spec`**, run at `fe2ae4cf09` (the spec package has no byte change since): - `vitest run --project local`: 522 files, 15405 passed, 1 todo. - `typecheck`: exit 0. The test layer holds 53 files / 255 errors in the ledger, and the new test is inside the `tsconfig.test.json` program. - **`@objectstack/platform-objects`** at `2bfeee4a23`: vitest 54 files, 883 passed. `typecheck` exit 0. - **Narrowed lint.** This is not the repo-wide `pnpm lint`, which CI owns. `eslint --no-inline-config --format json` over the 7 touched `.ts` files returned 7 file results, 0 errors, 0 warnings, and none of the files was ignored. The repo's one `eslint.config.mjs` enables no type-aware linting (its note at `:326-328`), so this diff cannot change the lint result of any file it does not touch. - **Left to CI**: the 7 families whose values come from the workflow (`check-issue-citations`, shard attestations, test completeness), the 11 declared-wide families, and `pnpm lint`. ## Acceptance notes - The renderer side is not this card. At the objectui pin `87af769e9`, `plugin-list/src/ListView.tsx` reads `schema.pagination?.pageSize` into the `$top` of every list fetch (`:1307-1312`, `:2356`). For non-grid views it shows a rows-per-page selector when `pageSizeOptions` is set (`:4866`). The one exception is a gantt view with an `api` data provider, which does its own fetch (`:1950-1955`). This PR does not change the plugin-view route's hard-coded `$top: 100`, which is recorded in review record `5791323483` on PR objectstack-ai#19809. - The form still has no section for the `map` and `tree` blocks that the list-view shape declares. That falls under the top-level "declared but not offered" class, which `metadata-form-declared-rows.pin.test.ts` counts as its own census, not under this card. --- _Generated by [Claude Code](https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr)_ --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…tor at authoring time (objectstack-ai#19861) Fixes objectstack-ai#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 objectstack-ai#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 objectstack-ai#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 b6b2f44 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 - In `view.zod.ts`, only `checkViewFilterRuleValueShape` and its docblock changed. The `ViewFilterRuleSchema` block is untouched: its JSDoc and `.describe()` are still true. None of PR objectstack-ai#19809's regions is touched. - `FILTER_TEXT_CASES` gains no row. There are no objectui or runtime (`parseFilterAST`) edits. - `origin/main` has moved 8 commits past the branch point: objectstack-ai#19598 touches the `ListView` shape in `view.zod.ts`, and objectstack-ai#19657 touches `registry.ts`. A driver-less `merge-tree` of HEAD onto `origin/main` 8cbc3c0 is clean. Main is not merged in. ## 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](https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
objectstack-ai#19932) Fixes objectstack-ai#19665 Clause-②: no ## What this changes `packages/spec/src/type-alias-convention.pin.test.ts` keyed its isomorphic pins on a dense, hand-kept counter (`Iso0` … `Iso881`). Two branches off one base each took "the next free number" for a different schema, at insertion points far apart, and git merged them cleanly into two declarations of one name: `Iso871` once already, then `Iso877` / `Iso878` on objectstack-ai#19600, where only `check:test-typecheck` caught it. Each pin is now named for the pair that is already unique to it, its module path and its schema export name: - The name is `Iso_`, then the module path below `packages/spec/src` without `.zod.ts`, with each kebab segment camelCased and the segments joined by `_` (so `ai/knowledge-document.zod.ts` becomes `ai_knowledgeDocument`), then `__`, then the schema export name. For example: `Iso_shared_epoch__EpochMs`. - The block is sorted by that name in code-unit order (the order `LC_ALL=C sort` gives), with one heading per module. Each note about a module's pins sits under that module's heading and names the pins it covers. The schema alone now decides both a new pin's name and where it goes. There are four commits: 1. `ece9f71d2c`: the mechanical half. It is the transform below, run on `fdeeea0cc9`, byte for byte. That includes the runtime companion's pin counter, whose regex now matches the name family (`Iso` followed by word characters) instead of `Iso` followed by digits. 2. `fc8eda2d62`: prose only. - The naming rule and the retired counter are written at the head of the list. - The two cohort blocks (phase 2 and the objectstack-ai#4593 backfill) are filed there too. - Notes that pointed at a pin by its position or its numeral now name the pin. - "Positional and stay vacant" is removed. - The count history gets a `789 -> 789` entry. 3. `ea78da8908`: a merge of `main` at `2c1011b01b`, to re-measure this head on current `main`. `main` has not touched the pin file since the merge base: its blob is `5620656d04` on both `fdeeea0cc9` and `2c1011b01b`. So the merge leaves the file exactly as `fc8eda2d62` had it, and the re-key was not re-run. 4. `4a724cfbf3`: `packages/spec/src/shared/duration.test.ts:11` cited the `EpochMs` pin as `Iso868`. It now cites `Iso_shared_epoch__EpochMs`. This commit changes comment text only. The diff against `main` is two files: the pin file, and that one comment line in `duration.test.ts`. No assertion is added and none is weakened. The exemption set is unchanged, and nothing is regenerated from a corpus measurement. `scripts/check-spec-parsed-alias.mjs` is untouched, because its reader never reads the numeral. ## Acceptance: the assertion set is identical, member for member Both sides are read with the gate's own `readIsomorphicPins`: before is `fdeeea0cc9` and after is `fc8eda2d62`. | reading | before | after | |---|---|---| | `readIsomorphicPins` entries | 789 | 789 | | pin declarations | 789 | 789 | | set difference, either direction | | **0** | | pin bodies changed (the text right of ` = `, keyed on `path.ts::Schema`) | | **0** | | names off the rule / duplicate names | | 0 / 0 | | pin names in sorted order | no | yes | Controls, to show the instrument can fire: - Deleting one pin line gives a set difference of 1. - Swapping one pin's `z.infer` operand to another schema keeps the set at 789 and reads 1 body changed. The module series is untouched: there are 174 `import type * as M…` lines before and after, and no import line is in the diff. ## The collision is gone: merge probe with a firing control The probe ran `git merge-tree --write-tree` in a throwaway `git clone --bare --shared` with no merge driver registered. The clone was removed afterwards. In each case, two branches off one base each add one pin for a different schema: | scheme and base | the two insertions | merge-tree | duplicate pin names | |---|---|---|---| | OLD, `fdeeea0cc9`: both take `Iso882`, one in `api/analytics`, one in `ui/view` | 1259 lines apart | exit 0, clean, 0 markers | **`Iso882`** (the objectstack-ai#19600 shape) | | NEW, `fc8eda2d62`: `Iso_api_analytics__ProbeAlphaSchema` and `Iso_ui_view__ProbeBetaSchema`, each at its sorted place | 1174 lines apart | exit 0, clean, 0 markers | none, and the merged names are still sorted | | NEW, same module, with an existing pin between the two names | 6 lines apart | exit 0, clean | none | | NEW, same module, with the two names sort-adjacent | same line | exit 1, CONFLICT | none | The last row is the case that remains. Two new names with no existing pin between them insert at the same place, and git stops with a conflict; the resolution is to keep both lines. That failure is loud, and it cannot merge into a duplicate. The module series (`M…`) is **left untouched, and it is not a measured collision**: no one has yet seen two PRs that each import a new module. I ran one simulation of its mechanism only. Two branches that each append `import type * as M189` after `M188` conflict at the tail of the import block (merge-tree exit 1). Keeping both lines when resolving would give tsc a duplicate `M189`. So in this simulation its failure mode is a conflict, not a silent clean merge. ## Landing order: this PR lands first, then objectstack-ai#19809 re-runs the re-key on its own pin On the maintainer's instruction, this PR lands before objectstack-ai#19809. objectstack-ai#19809 also edits this file: it re-pins `KanbanConfigSchema` as `Iso882` and raises the count to 790. After this PR has landed, objectstack-ai#19809 does this on its own branch: 1. Merge `main`. The pin file conflicts. 2. Take objectstack-ai#19809's own copy of the file, still on the old names, as `F`: run `git show H:packages/spec/src/type-alias-convention.pin.test.ts > F`, where `H` is objectstack-ai#19809's head before the merge. Then run the transform below on `F`. It renames and sorts every pin, including the new one, which becomes `Iso_ui_view__KanbanConfigSchema`. - This step is exact while objectstack-ai#19809's pin set is `main`'s set plus its own pin. At `ff13eece89` it is: 790 pins, which are `main`'s 789 plus `ui/view.zod.ts::KanbanConfigSchema`. 3. Re-apply this PR's prose half as a three-way file merge: `git merge-file -p F BASE1 HEAD1 > out`. `BASE1` is this file at `ece9f71d2c`. `HEAD1` is this file at `fc8eda2d62`, which is the same bytes `main` holds once this PR lands. 4. By hand: - In the one conflicting hunk, at the tail of the count history, keep both entries: this PR's `789 -> 789` entry first, then objectstack-ai#19809's entry restated as `789 -> 790`, with its `toHaveLength(790)`. In that entry, name the pin `Iso_ui_view__KanbanConfigSchema` instead of `Iso882`. - In objectstack-ai#19809's `ui/view` note, name the pin `Iso_ui_view__KanbanConfigSchema` instead of `Iso882`, and drop "Iso829 stays vacant". 5. Check acceptance: compare `readIsomorphicPins` on `F` before the transform with the result. The sets must be equal, the bodies unchanged, the names on the rule, and the block sorted. I dry-ran steps 2 and 3 against objectstack-ai#19809's head `ff13eece89`: 790 = 790, set difference 0 both ways, 0 bodies changed, 0 names off the rule, sorted, and `Iso_ui_view__KanbanConfigSchema` declared once. The prose merge conflicts at that one hunk only. The transform is `node rekey.mjs FILE`. It rewrites the file in place and is idempotent: running it on `fc8eda2d62` changes nothing. It is written without a less-than character so this body keeps it intact. Running exactly this text on `fdeeea0cc9` reproduces `ece9f71d2c` byte for byte. ```js // rekey.mjs FILE: re-key and sort the isomorphic pin block of // packages/spec/src/type-alias-convention.pin.test.ts, in place. Idempotent. // Refuses (exit 1) on any line it cannot place. Spelled without a less-than // character so it survives a GitHub body intact: LT stands for one. import { readFileSync, writeFileSync } from 'node:fs'; const LT = String.fromCharCode(60); const file = process.argv[2]; const L = readFileSync(file, 'utf8').split('\n'); const die = (m) => { console.error(`rekey: ${m}`); process.exit(1); }; // Module alias -> module path, read the way the gate reads it. const mods = new Map(); for (const l of L) { const m = l.match(/^import type \* as (M\d+) from '\.\/(.+?)\.js';$/); if (m) mods.set(m[1], m[2]); } // Stable name: path below packages/spec/src minus `.zod`, kebab segments // camelCased, joined by `_`; then `__`; then the schema export name. const keyOf = (path) => { if (!path.endsWith('.zod')) die(`module ${path} is not a .zod module`); return path.slice(0, -'.zod'.length).split('/').map((s) => { if (!/^[a-z][a-z0-9]*(?:-[a-z][a-z0-9]*)*$/.test(s)) die(`segment "${s}" of ${path} is not lowercase kebab`); return s.replace(/-([a-z])/g, (_, c) => c.toUpperCase()); }).join('_'); }; // Region: after the rule closing the "N isomorphic aliases" banner, up to the // rule opening "Representative spot-checks". const bannerAt = L.findIndex((l) => /^\/\/ \d+ isomorphic aliases:/.test(l)); if (bannerAt === -1) die('count banner not found'); const start = L.findIndex((l, i) => i > bannerAt && l.startsWith('// ----')) + 1; const spotAt = L.findIndex((l) => l === '// Representative spot-checks on the phase-2 FLIP.'); if (start === 0 || spotAt === -1 || !L[spotAt - 1].startsWith('// ----')) die('region bounds not found'); const end = spotAt - 1; const HEADING = /^\/\/ (?:\[#\d+\] )?([a-z0-9-]+(?:\/[a-z0-9-]+)*)\.zod\.ts(?:$|[ ,;:(—-])/; const PIN = new RegExp(`^export type (Iso\\w+) = (Assert${LT}Eq${LT} z\\.input${LT} typeof (M\\d+)\\.(\\w+) >, z\\.infer${LT} typeof \\3\\.\\4 > >>;)$`); const preamble = []; const sections = new Map(); // key -> { heading, notes[], pins: Map(name -> line) } const names = new Set(); let cur = null; for (let i = start; i !== end; i++) { const l = L[i]; if (l === '') continue; if (l.startsWith('// ----')) { // an inner banner moves, verbatim, to the preamble const endRule = L.findIndex((x, j) => j > i && x.startsWith('// ----')); if (endRule === -1 || endRule >= end) die(`unclosed banner at line ${i + 1}`); if (preamble.length) preamble.push('//'); preamble.push(...L.slice(i + 1, endRule)); i = endRule; continue; } const h = l.match(HEADING); if (h) { const key = keyOf(`${h[1]}.zod`); if (!sections.has(key)) sections.set(key, { heading: l, notes: [], pins: new Map() }); else if (l !== `// ${h[1]}.zod.ts`) die(`second heading for ${h[1]} carries text (line ${i + 1})`); cur = sections.get(key); continue; } const p = l.match(PIN); if (p) { const path = mods.get(p[3]); if (!path) die(`line ${i + 1}: ${p[3]} imports no module`); const key = keyOf(path); if (!cur || cur !== sections.get(key)) { console.error(`rekey: line ${i + 1} pins ${path}.ts outside its section; moved there`); if (!sections.has(key)) sections.set(key, { heading: `// ${path}.ts`, notes: [], pins: new Map() }); } const name = `Iso_${key}__${p[4]}`; if (names.has(name)) die(`duplicate pin ${name} (line ${i + 1})`); names.add(name); sections.get(key).pins.set(name, `export type ${name} = ${p[2]}`); continue; } if (l.startsWith('//')) { if (!cur) preamble.push(l); else cur.notes.push(l); continue; } die(`line ${i + 1} is neither a heading, a pin, a comment nor blank: ${l.slice(0, 80)}`); } // Code-unit order; every name is ASCII, so byte order is the same order. const byCodeUnit = (a, b) => Buffer.compare(Buffer.from(a), Buffer.from(b)); const out = ['']; if (preamble.length) out.push(...preamble, ''); for (const key of [...sections.keys()].sort((a, b) => byCodeUnit(`Iso_${a}__`, `Iso_${b}__`))) { const s = sections.get(key); out.push(s.heading, ...s.notes, ...[...s.pins.keys()].sort(byCodeUnit).map((n) => s.pins.get(n)), ''); } const next = [...L.slice(0, start), ...out, ...L.slice(end)]; // The runtime companion counts pins by declaration: the name family, not a numeral. const text = next.join('\n').replace( `self.match(/^export type Iso\\d+ = Assert${LT}/gm)`, `self.match(/^export type Iso\\w+ = Assert${LT}/gm)`, ); writeFileSync(file, text); console.log(`rekey: ${names.size} pins in ${sections.size} module sections`); ``` ## Verification Round 2, on `4a724cfbf3`, after the merge of `main` at `2c1011b01b`: - The pin file is byte-identical to `fc8eda2d62`'s (blob `8ae4405bd5`). `readIsomorphicPins` on `main`'s file and on this head's file reads 789 = 789, with a set difference of 0 both ways. Control: dropping one pin line reads 788, difference 1. - `pnpm --filter @objectstack/spec check:test-typecheck`: OK (53 files / 255 errors / 142 signatures held). - `node scripts/check-spec-parsed-alias.mjs`: "1459 bare z.input aliases, 789 pinned isomorphic, 670 paired with an XParsed. OK". Its `--self-test`: 18 assertions passed. - `pnpm --filter @objectstack/spec run typecheck`: exit 0. - `vitest run --project local` on `src/type-alias-convention.pin.test.ts` and `src/shared/duration.test.ts`: 2 files, 14 tests passed. The whole spec `local` project: 530 files, 15617 passed, 1 todo. - `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derived 77 commands, and all 77 were run. The `--ran` reconciliation reads 77 run, 0 NOT MEASURED, 0 unrun. Four gates first exited 3 for unbuilt prerequisites (`check:doc-formula-expressions`, `check:lean-entry-closure`, `check:dual-build-cjs-loads`, `check:type-check-debt`), and each exited 0 after its build. Round 1, on `fc8eda2d62`. The pin file's bytes have not changed since, so these still describe it: - Non-vacuity, from the committed state: `node scripts/ablation-replace.mjs` renamed `Iso_ui_view__TreeConfigSchema` to the existing name `Iso_ui_view__RowHeightSchema`, and `check:test-typecheck` turned red ("2 type error(s) in a file the ledger does not cover"). The file was then restored: blob == HEAD and `git diff HEAD` is empty. - The set identity against `fdeeea0cc9` and the merge probe are in the sections above. ## Changeset None. The file is a test. `@objectstack/spec`'s `files` ships `src/**/*.zod.ts` and `dist` but never `*.test.ts`, so nothing published changes. `check-empty-changeset.mjs` refuses a new empty-frontmatter changeset, so the repo's disposition for this diff is the `skip-changeset` label. The seat applied that label after the PR opened. The one red `Check Changeset` run on `fc8eda2d62` came from the `opened` event, before the label; on `4a724cfbf3` that check is `skipped`. ## Acceptance notes - On the base, two `api/errors.zod.ts` pins (`FieldErrorCode` and `FieldErrorSchema`) sat under the `api/error-code-ledger.zod.ts` heading. The sorted layout files them under their own module. - `packages/spec/src/shared/duration.test.ts:11` cited this file's `Iso868` in the present tense. That pin is `EpochMs`, now `Iso_shared_epoch__EpochMs`, and `4a724cfbf3` updates the citation. The other `Iso`-plus-digits hits under `packages/spec/src`, outside this file, are `ui/component.zod.ts` lines 2052, 2523 and 2929 and `ui/i18n.zod.ts` line 198. They name `Iso818`, `Iso819`, `Iso839` and `Iso759`, pins that were deleted before this PR, so they are history and stay as they are. - A check that the block stays sorted, and that each name matches its derivation, would be a new assertion, so none is added. The candidate, for the maintainer: in the runtime companion, assert that the `Iso` names are in code-unit order and that each one equals the rule applied to its `Mn` path and schema. --- _Generated by [Claude Code](https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr)_ --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…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>
Part of #19228
Clause-②: no
Rewritten short by the dispatching seat (2026-09-23T08:08Z). The developer's measurements are in its report on #19228; the at-tier review record is
5791323483on this PR.What changes
Ruling D (#19228,
5789634193): a view carries one row bound,pagination.pageSize. The per-kind viewlimitadded for #17393 was never published (npmlatestis 17.4.0; the key is absent from its tarball), so it is removed before a release carries it.GalleryConfigSchema/KanbanConfigSchema/TimelineConfigSchemaloselimit;rowLimitKey,DEFAULT_VIEW_ROW_LIMITandKanbanConfigParsedgo with it (Kanban has one shape again, re-pinned per ADR-0122).limitin those blocks is refused as an unknown key, and the refusal names both alternatives:pagination.pageSizeon a view, or the flatlimiton a page component node.pagination.pageSize's description carries the truncation obligation thatlimit's description carried.object-kanban/object-timelinecomponentlimitkeys and their precedence are unchanged; only text describing the removed view-level key was deleted..changeset/17393-view-row-ceiling.mdis deleted, and the pending.changeset/19228-view-row-limit-route-record.mdloses its section about the view-levellimit, so no release announces a key that does not ship. Apatchchangeset covers the description change.Net: 16 files, +86 / −528.
Check Changesetis red on purposeThis PR corrects two PENDING release notes instead of adding one, which
check-empty-changeset.mjsnames the DELIBERATE CORRECTION class. The workflow (pr-automation.yml, route 0) says to leave the check red and get the correction confirmed in writing on the PR;Check Changesetis not a required context. The two notes and what changed under them are listed above. Maintainer confirmation requested on this PR.Not in this PR
objectui still spreads a view's kanban / gallery / timeline block onto the rendered node; reading
pagination.pageSizeas the fetch ceiling and showing the truncation signal there is objectui work. objectui#7390 is closed, so that work has no open card; triage is asked to route it (#19228).维护者速读
limit,从没发布过;按您的裁决 D,改为每个视图只用pagination.pageSize一个上限。Check Changeset因此按规定保持红色,需要您在本 PR 上书面确认一句。🤖 Generated with Claude Code
https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1