Skip to content

spec(ui): declare the columns x hiddenFields x fieldOrder composition on the list view, with pins and a re-cited liveness row (ruling B) - #19598

Merged
os-justin merged 5 commits into
mainfrom
claude/issue-15184-field-order-composition
Sep 23, 2026
Merged

os-justin merged 5 commits into
mainfrom
claude/issue-15184-field-order-composition

Conversation

@os-warren

@os-warren os-warren commented Sep 21, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #15184

Clause-②: no

Fix round — contract review 5791391832 (FAIL on cfc103a20a), now at e76041c2b4

Revised by domain:spec seat 2, session session_01UDXER3sdqfeVYpEWZs5mZx. Both fail bases were re-derived before fixing and both reproduced; the folded-in note did too.

  1. The hover claim is dropped. "… and into the TSDoc an author hovers" is gone from §1 below, from the changeset and from the pin file's header. Re-measured with the TypeScript language service over packages/spec at the merged tree: quick-info on fieldOrder carries 0 documentation characters at defineView, at ListViewSchema.parse and on the ListView type member, and no hover at any of those sites carries a .describe() lead string. The instrument is lit: columns and hiddenFields show their JSDoc (447 and 1859 characters). The built declaration files (94 dist/*.d.ts / *.d.mts) contain 0 of the three describe lead strings and 0 docblock phrases (Field Visibility, orders what survives, declares no projection), against 70 lines naming fieldOrder. The json-schema/ and generated-docs legs stay; both were re-read (json-schema/ui/ListView.json carries the describe text, and json-schema is in files[]).
  2. The projection clause now states the columns: [] boundary. "A field omitted here is not displayed" and "Nothing downstream may re-add a field this list omits" are gone. In their place: hiddenFields and fieldOrder cannot add a field columns omits, and an empty columns declares no projection, so neither of them applies, and which columns show is left to the renderer. The boundary is pinned in the declaration half, twice (the columns description and the docblock's projection step), and the door half pins that all four doors admit columns: [] verbatim. ⛔ [] is not refused at the door; that would be a narrowing, and it is not this card's call.
  3. "Runs exactly these three steps" is corrected by naming the gate. The docblock now says the effectiveFields memo runs the three steps in this order, with one more between steps 1 and 2: it drops the columns field-level security denies the current user read on.

Two sentences not named by the review were narrowed while fixing base 2, because they carry the same over-reach. The docblock's opening "three keys … decide which fields a list view shows" became "together build one field list": at the pin the kanban branch takes cardFields ahead of the composed list (ListView.tsx:2977), and the gallery branch passes its own titleField, defaulting to 'name' (:3058). And "an author who wants a field gone edits columns …" was deleted, since emptying columns brings the object's default columns back in the ListView grid.

The boundary, measured at objectui .objectui-sha 87af769e9a3e (git show from the local objectui clone):

  • packages/plugin-list/src/ListView.tsx:2828-2831: the hasAuthoredColumns memo is Array.isArray(schema.columns) && schema.columns.length > 0. The comment at :2795 says "The author declared none (columns absent, or [])".
  • :2880-2882, the grid branch: an unauthored projection passes { fields: undefined, columns: undefined }.
  • packages/plugin-grid/src/ObjectGrid.tsx:3228: "Default columns priority (when schema doesn't specify columns)". In that file 0 lines name hiddenFields and 0 lines name fieldOrder, against 71 lines naming columns.
  • The hide-fields popover's field list (ListView.tsx, allFields) is built from schema.columns too, so with [] it offers nothing to hide.

Ruling B is what this PR executes — the card body is superseded

The card body executes the 2026-09-04 ruling "retire at the spec" and declares Clause-②: yes. That direction is dead. Ruling B (comment 5635654931, director seat summon #22, maintainer decision batch #115, verbatim 「同意」, 2026-09-11) supersedes it, verbatim:

Ruling B (the 2026-09-04 ruling's own fallback, now the ruling): ListViewSchema.fieldOrder stays. The contract states, in the .describe() / docblock of the three keys under the Field Visibility and Ordering per View comment in packages/spec/src/ui/view.zod.ts, the composition objectui already applies: columns is the projection, hiddenFields subtracts from it, fieldOrder orders what survives (entries absent from fieldOrder sort last); pins assert the composition at the schema doors. In the same PR the liveness row packages/spec/liveness/view.json /props/list/children/fieldOrder is re-cited (its first evidence path packages/react/src/spec-bridge/bridges/list-view.ts no longer exists; the ListView.tsx line numbers drifted to ~:2424-2434) with verifiedAt refreshed. ⛔ Not A: it deletes a channel measured live end to end and reds objectui's relay-rung census pin.

Nothing is retired, tombstoned or deprecated here. No retiredKey(), no ADR-0087 entry, no packages/lint validator change.

1. The declaration — packages/spec/src/ui/view.zod.ts

The three keys now state their own role in the composition, in the .describe() text that ships (into json-schema/ and into the generated content/docs/references/**), plus one docblock above the block that states the application order once:

  • columns is the projection: the candidate set AND the baseline order. hiddenFields and fieldOrder cannot add a field omitted here. An empty columns declares no projection, so neither of them applies, and which columns show is left to the renderer (objectui's ListView grid derives the object's default columns).
  • hiddenFields subtracts from that projection, before any ordering runs. A name columns never projected subtracts nothing.
  • fieldOrder orders what survives and never adds a field. A surviving column absent from fieldOrder sorts last, after every listed one, keeping its columns-relative order; a name listed there that did not survive orders nothing.

This is a declaration of the ORDER OF APPLICATION, not a precedence rule between rival spellings — columns and fieldOrder never contradict each other, because one selects and the other sorts. That is why the retirement premise was false.

The accept set does not move (Clause-②: no): the schema declaration is unchanged apart from the description strings —

  columns: z.union([z.array(z.string()), z.array(ListColumnSchema)]).describe(…)
  hiddenFields: z.array(z.string()).optional().describe(…)
  fieldOrder: z.array(z.string()).optional().describe(…)

No key is added, removed, narrowed or widened, and no parse verdict changes anywhere. Re-measured this round, base origin/main 6eaa0f4a81 against head e76041c2b4: 22 inputs went through the four doors (ListViewSchema, ObjectListViewSchema, defineView, the registered view schema). The inputs include columns: [] alone, [] with both other keys, all-empty arrays, a kanban with [], missing columns, wrong types, an unknown key and a case variant. Both trees gave 48 accept / 40 reject. The probe output is byte-identical (sha256 6e4f279566c5c0e0… at both) in verdicts, parsed values and issue codes. ListViewSchema.shape is identical: 50 keys, columns required, the other two optional. Lit control in the same run: the descriptions differ for exactly columns, hiddenFields and fieldOrder, and not for rowColor or rowActions.

2. The pins — packages/spec/src/ui/view-field-order-composition.pin.test.ts (21 cases)

Two halves, deliberately independent:

  • Declaration half reads the three descriptions off the LIVE schema (not the source text) and requires each one to name all three keys and state its own role, plus the "sorts LAST" rule an author cannot guess. One further case pins the block docblock's three steps in application order. Two more pin the empty-columns boundary: once in the columns description, once on the docblock's projection step.
  • Door half parses a document carrying all three keys through the four doors a list view really arrives by — ListViewSchema, ObjectListViewSchema, defineView and the registered view metadata schema — and asserts the arrays come back VERBATIM, with no unrecognized-key report. Three more cases pin that the spec DECLARES the composition and does not PERFORM it: no cross-validation of fieldOrder or hiddenFields against columns (reference integrity is @objectstack/lint's job, against the object's real field map), and columns comes back whole. One more pins that every door admits columns: [] verbatim, which makes the boundary the declaration states reachable. A later ruling that refuses [] retires that case and the boundary sentence together.

The pin asserts nothing about how a renderer sorts. That belongs to objectui and is carried by the ledger row below.

Ablation — seven legs at e76041c2b4, each mutation proven on disk and each restore proven byte-identical

Every leg ran through scripts/ablation-replace.mjs: literal anchor, hit count declared, blob hash before and after, and git diff HEAD empty on restore. The legs ran against the committed tree, under a driver carrying its own EXIT/INT/TERM restore. Control: 21 of 21 green.

leg mutation result
A fieldOrder's .describe() reverted to its pre-ruling one-liner 3 of 21 red, all in the declaration half; the door half stayed green
B the whole fieldOrder key DELETED from the shape (the superseded retirement) 12 of 21 red
C the docblock's ordering step reworded to drop "orders what survives" 1 of 21 red, the application-order case alone
D the empty-list boundary removed from the columns description 1 of 21 red, the new columns boundary case
E the boundary removed from the docblock's projection step 1 of 21 red, the new docblock boundary case
F both columns arrays given .min(1), so the doors refuse [] 1 of 21 red, the new door case
G "hiddenFields and fieldOrder cannot add a field omitted here" removed 1 of 21 red, the (retitled) projection case

Restore evidence, identical on every leg: blob after restore 5a0b5cf9d298 equals the blob at HEAD, git diff HEAD is empty, and git status is clean. Leg B's first attempt was a no-op: plant mode with an empty replacement, which the tool refused before running any test. It was re-run in --delete mode, and that run is the reading above.

3. The ledger — packages/spec/liveness/view.json, /props/list/children/fieldOrder

Re-measured against the pin THIS tree carries, .objectui-sha = 87af769e9a3e (⛔ not objectui HEAD, and ⛔ not ruling B's own 2026-09-11 coordinates, which had drifted again). verifiedAt: 2026-09-21.

  • evidence now cites the applying reader: objectui packages/plugin-list/src/ListView.tsx#effectiveFields:2750-2784 — it takes schema.columns as the candidate set, subtracts the hidden-field set at :2766-2771, then at :2774-2781 builds an order map from schema.fieldOrder and sorts the survivors by it. A survivor the map does not name scores Infinity, which is the mechanism behind "sorts last". :2784 lists schema.fieldOrder as a memo dependency.
  • producer is new, and it is the half a consumer pointer alone cannot prove: objectui packages/plugin-view/src/ObjectView.tsx#renderContent:2284 relays a named view's fieldOrder onto the list node that memo reads. Without a producer the read could be permanently undefined — the Seed.env shape the ledger README records.
  • Both halves of the old citation had rotted. Its first path is gone: git ls-tree -r at objectui @87af769e returns 0 files under packages/react/src/spec-bridge/, while packages/react/ itself returns 160 — the lit control, so the zero belongs to the directory and not to the instrument. Its second rotted IN RANGE: :1499-1500 at @11c1e71e, ~:2424-2434 when ruling B was written, :2774-2781 today. Both pointers now carry symbol anchors, which is the form that moves with the consumer.
  • One measured gap is recorded in the row's note as objectui's to close and explicitly NOT a defect in this key: the OTHER relay, objectui packages/app-shell/src/views/ObjectView.tsx, carries hiddenFields at :2480 but still records fieldOrder as a declared absence (objectui#7516) in its relay-rung census. The plugin-view rung cited under producer is the live one.

packages/spec/liveness/state-counts.md and liveness/README.md were DECLARED but not cleared (held at claim time by PR #19594, merged since). Neither is in this diff. On the merged tree, check:liveness reports state-counts.md current: the same 40 rows. The row's status stays live, so no count moved.

4. Changeset — patch, as ruling B fixed

@objectstack/spec ships src/**/*.zod.ts and liveness/ in its files[], so both edits publish. Clause-②: no plus patch is the declared pair; no arm of the (widening)/(narrowing) pair applies, because the accept set does not move.

5. Generated artifacts

content/docs/references/{ui/view,api/protocol,data/object}.mdx and skills/objectstack-ui/references/react-blocks.md are the mechanical output of gen:docs and gen:react-blocks. Every changed row is one of the three descriptions rendered where the generators already render it — no hand edits, and nothing unrelated moved.

This round merged origin/main (6eaa0f4a81) through scripts/pm/os-regen-merge.sh. The driver deferred the three content/docs/references/** files and step 2 took main's side. The step-3 commit was then refused as stale, so that commit (47bc43df13) carries the three files regenerated on the merged tree (gen:schema, gen:docs, gen:react-blocks). Its only delta over main's side is the three describe rows this branch already carried: 27 rows in 9 tables. Nothing was text-merged. The fix commit regenerated them again. react-blocks.md did not move this round, because its columns cell is truncated before the changed sentence.

About the one governed path. skills/objectstack-ui/references/react-blocks.md is the spec-react-blocks row of GENERATED_SURFACE_EXCEPTIONS: recomputable byte-exact by pnpm --filter @objectstack/spec gen:react-blocks, whose trusted generator tree is packages/spec/scripts/ — which this diff does not touch, so the #11084 co-edit fence does not trip. Size readings, since a published skill package is in the diff: the changed file is 115 lines before and 115 after (one row rewritten, net 0), and the package's SKILL.md total is 309 before and 309 after. No skill prose was authored here.

Verification — this round, at e76041c2b4 (merged tree)

All exit codes were captured BEFORE any pipe. Heavy runs went through scripts/pm/os-verify-lock.sh, and each printed VERDICT command-exit 0.

what result
pnpm --filter @objectstack/spec build (with DTS) exit 0; check-dts-emitted 34/34
pnpm --filter @objectstack/spec test exit 0: 520 files passed, 15259 tests passed, 1 todo (15260)
pnpm --filter @objectstack/spec typecheck exit 0; the pin file is in the tsconfig.test.json program (1 of 491 test files) with 0 debt entries
pnpm --filter @objectstack/spec check:generated exit 0: all 15 generated artifacts up to date, measured against the DTS build above
check:liveness · check:objectui-pin-citations · check:docs · check:react-blocks · check:nul-bytes exit 0 each
the pin alone exit 0: 21 passed
eslint, narrowed to this diff's lintable files exit 0: 2 files, 0 errors, 0 warnings (see below)
node scripts/pm/dispatch-gates.mjs --ran exit 0: 113 derived, 111 run green, 0 UNRUN, 2 NOT MEASURED
node scripts/pm/check-governed-merges.mjs --pr 19598 (installed tree) exit 0: NOT governed. react-blocks.md is lifted as a pure regeneration; 8 paths, +433/-36

The 2 NOT MEASURED are pnpm check:dual-build-cjs-loads and pnpm check:type-check-debt. Both exit 3 (PREREQUISITE NOT MET) and both need a whole-monorepo dist. ⛔ Neither is a pass and neither is a finding; CI builds the closure before running them. Thirteen gates first answered on a missing dist: twelve with exit 3, and check:generated with exit 1 through its check:api-surface member. They were cleared by the spec DTS build and by a targeted turbo run build of @objectstack/lint, @objectstack/formula, @objectstack/client-react and @objectstack/objectql, then re-run green.

Lint was narrowed, not run repo-wide. Three readings back the narrowing. ① Population: all eight changed paths were handed to eslint. Its own config lints the two .ts files (print-config resolves rules for both), and it supplies no configuration for the other six (.md, .mdx, .json): each answers "File ignored because no matching configuration was supplied". ② Count: the --format json output lists 8 files. The 2 linted ones carry 0 errors and 0 warnings; the other 6 carry only that one ignore warning each. ③ Invariance: no parserOptions.project and no projectService are set, so linting is not type-aware and this diff cannot move a verdict on an untouched file. The repo-wide pnpm lint is CI's Lint & Repo Gates.

⚠️ One reading a gate CANNOT give. check:objectui-pin-citations is green (46 asserting citations match 87af769e9 on the merged tree), but it scans packages/spec/src/** only. The ledger's @87af769e citation lives in liveness/view.json, outside that scan, and check:liveness attributes cross-repo paths without resolving them. The objectui coordinates in this body and in the row's note therefore rest on measurements taken by hand at the pin, with the lit controls stated.

Acceptance notes

  • Noted, not filed — the sibling hiddenFields ledger row has the same rotted citation. /props/list/children/hiddenFields still cites objectui packages/react/src/spec-bridge/bridges/list-view.ts:123 (the directory that greps to zero above) and ListView.tsx:763 / :1491-1494 / :1509, all measured at @11c1e71e and all drifted at the current pin — the applying lines are :1465-1466 and :2766-2771. It is left untouched deliberately: ruling B names ONE row, and widening past an explicit ruling is not this PR's call. It is reported to the seat for filing rather than fixed here. Whoever next re-cites a view.json list row will touch this file.
  • Reported to the seat for filing — the registered object-view renderer's grid path applies neither hiddenFields nor fieldOrder. At objectui 87af769e, packages/plugin-view/src/ObjectView.tsx relays both keys only inside the host-composition renderListView branch (:2268, :2284). Without a host, a grid view renders ObjectGrid directly (:2335-2344) from gridSchema (:1909), and neither that memo nor ObjectGrid.tsx reads either key. On that path a named grid view with columns: [] also sends fields: []: viewColumnFieldNames is presence-preserving, and schemaFields is then shown as-is (ObjectGrid.tsx:3245-3246). This is objectui's to close. It changes no sentence here, because the boundary text names ListView.

维护者速读(草稿)

改了什么 —— ListViewSchema 上 columns / hiddenFields / fieldOrder 三个键的说明文字,现在把它们本来就构成的组合关系写进契约:columns 投影,hiddenFields 做减法,fieldOrder 给剩下的排序(没被列进 fieldOrder 的排在最后)。配一份把这条组合钉在四个 schema 入口上的 pin 测试,外加 liveness/view.json 里 fieldOrder 那一行的证据重测。复核轮补上两处:说明文字不再声称会出现在编辑器悬停提示里(实测不会);columns 为空数组时不算投影,另两个键都不生效,由渲染器决定显示哪些列(objectui 的 ListView 表格改用对象的默认列),这条边界也钉进了 pin。

为什么改 —— 2026-09-04 的裁决要把 fieldOrder 退役,前提是它和 columns 是同一件事的两种拼法、且没有契约规定谁赢。这个前提经实测不成立:两者根本不冲突,一个选字段一个排顺序,objectui 一直就是三步连着做的。2026-09-11 决策批次 #115 的裁决 B 改为保留该键,并要求把这条组合写进契约 —— 本 PR 就是执行它。

风险与代价(含回滚) —— 受理集合不动,没有任何键被加、删、收紧或放宽,解析结果零变化,packages/lint 的四个校验器没碰。代价只有三处说明文字变长,以及随之重新生成的参考文档与 react-blocks 契约。回滚 = 直接 revert 本 PR,没有数据迁移、没有 tombstone、没有下游需要跟进的动作。

席位意见 —— 本轮修正已交付;达档复核按维护者「契约复审不要启动了,交给下一任」交给下一任席位,复核通过前不入队。

你要做的 —— 本 PR 唯一触到的受管路径是 skills/objectstack-ui/references/react-blocks.md,它是 gen:react-blocks 的产物行,队列腿可按字节重算认证、无需您点头。若认证因故没生效,则需要您对该一行(115 行文件中被重写的 1 行、净增 0 行)给一次批准;除此之外无需您做任何事。


Generated by Claude Code

… on the list view

`ListViewSchema`'s three field-selection keys now state the composition they
already form, in the `.describe()` text that ships to authors: `columns`
projects, `hiddenFields` subtracts from that projection, and `fieldOrder`
orders what survives, with a surviving column absent from `fieldOrder` sorting
last.

No accept set moves. No key is added, removed, narrowed or widened, and the
four `@objectstack/lint` list-view validators are untouched.

`packages/spec/liveness/view.json`'s `/props/list/children/fieldOrder` row is
re-cited at objectui @87af769e with `verifiedAt: 2026-09-21`: its first
evidence path no longer exists and its second had drifted in range, so both
pointers now carry symbol anchors and the relay rung is split out into
`producer`.

`view-field-order-composition.pin.test.ts` holds the declaration and the accept
set together across the four list-view doors.

Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx
Co-authored-by: Claude <noreply@anthropic.com>
…t for the composition text

Mechanical output of `pnpm --filter @objectstack/spec gen:docs` and
`gen:react-blocks` after the three `.describe()` strings moved. No hand edits:
every changed row is one of the three keys' descriptions, rendered where the
generators already render them.

`skills/objectstack-ui/references/react-blocks.md` is the `gen:react-blocks`
output row in the generated-surface register, recomputable byte-exact from
`packages/spec/scripts/build-react-blocks-contract.ts`, which this diff does
not touch. 115 lines before and after; the package's SKILL.md total is
unchanged at 309.

Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. ⚠️ 1 changed file(s) yielded no anchor (packages/spec/liveness/view.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/spec/liveness/view.json) — pages documenting those are invisible to this run
  • 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 6eaa0f4a81a0146250f083df2b9d000b75dbec61 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 7ed15cc8f5de30e9b5b0964181c14c30e93c1eae — the merge of head e76041c2b472a401dd1fec67cb124de2da2fc9cd into base 6eaa0f4a81a0146250f083df2b9d000b75dbec61, 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 7ed15cc8f5de30e9b5b0964181c14c30e93c1eae && git checkout 7ed15cc8f5de30e9b5b0964181c14c30e93c1eae
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 6eaa0f4a81a0146250f083df2b9d000b75dbec61 e76041c2b472a401dd1fec67cb124de2da2fc9cd && git checkout -B drift-repro 6eaa0f4a81a0146250f083df2b9d000b75dbec61 && git merge --no-ff e76041c2b472a401dd1fec67cb124de2da2fc9cd

node scripts/docs-audit/affected-docs.mjs --json 6eaa0f4a81a0146250f083df2b9d000b75dbec61

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

@github-actions github-actions Bot added documentation Improvements or additions to documentation protocol:ui tests tooling labels Sep 21, 2026
The os-regen driver deferred content/docs/references/{api/protocol,data/object,ui/view}.mdx;
os-regen-merge.sh took main's side and its step-3 commit was refused as stale. This commit is
that side regenerated from the merged tree (gen:schema, gen:docs, gen:react-blocks): the only
delta over main's side is the three list-view describe rows this branch already carried.

Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx
Co-authored-by: Claude <noreply@anthropic.com>
…rom the composition text

Fix round for the contract review of cfc103a.

- columns: an empty list declares no projection, so neither hiddenFields nor
  fieldOrder applies; objectui's ListView grid then derives the object's default
  columns. Replaces "a field omitted here is not displayed" and "nothing
  downstream may re-add a field this list omits".
- composition docblock: the same boundary on step 1; the effectiveFields memo
  runs the three steps with a field-level-security read filter between steps 1
  and 2 (was "exactly these three steps"); the opening sentence and the
  "author who wants a field gone" advice narrowed or dropped.
- pin header and changeset: the describe text ships into json-schema/ and the
  generated docs; the TSDoc-hover leg is dropped.
- pins: the columns boundary, the docblock boundary, and every door admitting
  columns: [] verbatim (18 -> 21 cases).
- regenerated content/docs/references/{api/protocol,data/object,ui/view}.mdx.

Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: e76041c2b472a401dd1fec67cb124de2da2fc9cd

Reviewed and posted 2026-09-23T10:59Z by the at-tier review subagent the domain:spec#5 seat spawned — read: card #15184 and its 15 comments, the PR body/commits/diff against merge-base 6eaa0f4a81, the head's 42 check-runs, objectui at .objectui-sha 87af769e via git show; ran: the pin (control + 7 ablations), an own accept-set probe base vs head, a TS quick-info probe, the governed predicate; NOT MEASURED: the derived gate family (relayed from CI at head), the DTS build, the json-schema/ build.

① Derived judgments

Surface (8 files, +433/−36). Declared: packages/spec/src/ui/view.zod.ts — three .describe() strings and two JSDoc blocks, nothing else in the shape; view-field-order-composition.pin.test.ts (new, 21 cases); liveness/view.json (one row); the changeset. Generator outputs: three content/docs/references/**.mdx (27 rows in 9 tables, each row one of the three descriptions) and skills/objectstack-ui/references/react-blocks.md (1 row; 115→115 lines, SKILL.md 309→309; byte-identical between cfc103a20a and head because its cell truncates before the changed text). packages/lint and packages/spec/scripts/ untouched; no export added/removed/renamed; no retiredKey(), no ADR-0087 entry. Judged: matches ruling B, nothing outside it.

Accept set — Clause-②: no holds (ran). Own probe, 23 inputs × 4 doors (ListViewSchema, ObjectListViewSchema, defineView, registered view), base vs head: 52 accept / 40 reject at both; every verdict, parsed value and issue-code set identical; ListViewSchema.shape 50 keys, columns required, the other two optional, at both. Lit control: descriptions differ for exactly columns / hiddenFields / fieldOrder, not rowColor / rowActions. All four doors admit columns: [] verbatim with the other two keys intact. Judged: no widening, no narrowing.

Governed (ran). The diff hits skills/** (a Tier H register row) on one path. node scripts/pm/check-governed-merges.mjs --pr 19598 from an installed worktree: exit 0, "0 of 8 paths hit the register after 1 generated-artifact lift" — react-blocks.md is a PURE REGENERATION byte-equal to gen:react-blocks recomputed on this tree; the head's Governed Surface Queue Guard check-run is success (empty output). The seat's "not a governed surface" is true only through the spec-react-blocks lift, and the trusted generator tree (packages/spec/scripts/) is untouched here. Judged: not governed.

Declared composition vs objectui @87af769e (read). plugin-list/src/ListView.tsx effectiveFields memo :2751-2784: seed schema.columns :2754 → FLS read filter :2757-2763 → hidden subtraction :2766-2771 → order map + sort :2774-2781; :2784 lists schema.fieldOrder as a dependency. Each shipped step checked: "cannot add a field omitted here" — the memo only filters and sorts, true; hiddenFields subtracts before ordering and a never-projected name subtracts nothing — true (hiddenFields is state seeded from schema.hiddenFields, :1465-1475); unlisted survivors sort LAST keeping columns-relative order, a listed non-survivor orders nothing — true (?? Infinity; Infinity − Infinity is NaN, which the sort treats as +0, and the sort is stable); the FLS step "between steps 1 and 2" — true. columns: [] boundary: hasAuthoredColumns :2828-2831 is length > 0; the grid branch :2880-2882 sends { fields: undefined, columns: undefined }; plugin-grid/src/ObjectGrid.tsx:3228 derives default columns and names hiddenFields 0× and fieldOrder 0× (control: columns 71×); the hide popover's allFields :3802-3816 is built from schema.columns. So neither key applies and the grid derives the object's default columns — true for the ListView grid, which is what the text names. Kanban :2977 (cardFields first) and gallery :3058 (titleField defaulting to 'name') justify the narrowed opener "together build one field list". Ledger row: producer plugin-view/src/ObjectView.tsx:2284 (inside renderContent, :2147) relays currentNamedViewConfig?.fieldOrder, view-sourced only; packages/react/src/spec-bridge/ 0 files vs packages/react/ 160; app-shell ObjectView.tsx:2480 carries hiddenFields and its census test :480 records fieldOrder as known-gap (objectui#7516); producer is a documented ledger field (README, 16 ledgers). All true at the pin.

Pins are real (ran; control 21/21; every restore blob 5a0b5cf9d298 = HEAD, tree clean after). D boundary dropped from the columns describe → 1 red; A fieldOrder describe reverted → 3 red; F .min(1) on both columns arrays so the doors refuse [] → 1 red (the door case); G "cannot add" sentence dropped → 1 red; E docblock boundary dropped → 1 red; B fieldOrder key deleted → 12 red; C docblock step reworded → 1 red. The PR body's seven legs reproduce exactly.

Every sentence that ships. Describes, both JSDoc blocks, the changeset, the generated rows and the pin header trace to the readings above. Both former fail bases are gone: no hover claim survives anywhere in the diff, and the [] boundary is stated in the columns describe, its JSDoc, docblock step 1 and the changeset. Quick-info probe (ran, TS language service over packages/spec at head): documentation chars for fieldOrder 0 / 0 / 0 at defineView, parse and the type member; columns 447, hiddenFields 1859; no site carries a describe lead — the PR body's numbers reproduce. Relayed, NOT MEASURED here because they are the derived gate family and CI carries them at head: spec test 520 files / 15259 tests (Test Core), check:generated 15/15 with check:docs / check:react-blocks / check:liveness (TypeScript Type Check, Lint & Repo Gates, Spec property liveness), dispatch-gates --ran 113/111/2, the narrowed lint, check:objectui-pin-citations 46. NOT MEASURED: json-schema/ui/ListView.json (gitignored build output; json-schema is in files[] and .describe() is what the generator emits as description) and "0 describe strings across 94 dist declaration files" (no DTS build here; a .describe() is a runtime value and cannot reach a .d.ts).

Model identifiers. Swept every added diff line, the four commit messages, the changeset, the generated docs and the 21,484-byte PR body for every model-identifier spelling (product, family, tier and provider names, and the bracketed context suffix): 0, apart from the mandated attribution footer (1 line) and the mandated model-free commit trailer pair, both exempt forms AGENTS.md names; lit control on synthetic input 1/1.

CI at head (42 check-runs, de-duplicated to 35 names by latest started_at). 31 success, 4 skipped, 0 failure; all seven required contexts success (Lint & Repo Gates, TypeScript Type Check, Test Core, Dogfood Regression Gate, Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard). Skips: Auto Label and Check PR Size — their latest run is the edited-event run after the seat's body write, and both jobs' if: exclude edited (their earlier synchronize runs at this head were success); Console Pin Gate — needs.filter.outputs.console is false, no console or pin path in the diff; Packed-tarball smoke (opt-in) — the opt-in label needs:pack-smoke is absent.

② Semver level

'@objectstack/spec': patch, Clause-②: no, no (widening) / (narrowing) arm. Consistent: the accept set is byte-identical base vs head (own probe); what changes is published description text (src/**/*.zod.ts, json-schema) and one liveness/ row, both in files[], so a changeset is owed and patch is the level — the pair ruling B fixed. Judged: correct.

③ Boundary flags

Blocking: none.

Non-blocking:

  1. objectui gap, reported for filing, not yet filed (PR body Acceptance note 2; dev report 5792450002 "to file, class c"; seat 2 filed nothing). Re-read at the pin: plugin-view/src/ObjectView.tsx relays hiddenFields / fieldOrder only inside if (renderListView) (:2172; :2268, :2284); the direct ObjectGrid path :2335-2344 renders from gridSchema (:1909-1977, 0 reads of either key) and ObjectGrid.tsx reads neither; viewColumnFieldNames keeps [] (:225-228), so a named grid view with columns: [] renders zero columns there (:3245-3246). A distinct seam from objectui#7516 (the app-shell relay's missing rung), so an objectui card is owed by the domain:spec#5 seat. It falsifies no shipped sentence: the boundary text names ListView.
  2. Sibling hiddenFields ledger row rot (spec-bridge/bridges/list-view.ts:123 and drifted ListView.tsx lines) — reported for filing, deliberately left under ruling B's one-row scope; no gate reds on it. Seat to file.
  3. Answered: the first dev round's A/B/C question (ruling B, 5635654931); the dev's PR-body-write conflict (the seat wrote the body; one footer line read back); the prior record's governed-reading and File surface: notes (re-declared in 5791411218; predicate exit 0 above); the card's stale Blocked-by: #19671 (the card body now carries no Blocked-by: line and an UNBLOCKED banner); C9 (Release/Claim chain 5790817662 → 5793391854; "The card this PR closes must claim this branch" is success at head).

Implemented-by: claude/issue-15184-field-order-composition
Reviewed-by: session_01Sfe5YjBLwB9J3y8fvm2xq1 — at-tier review subagent spawned by the domain:spec#5 seat

VERDICT: PASS

@os-justin
os-justin marked this pull request as ready for review September 23, 2026 11:00
@os-justin
os-justin enabled auto-merge September 23, 2026 11:00
@os-justin
os-justin added this pull request to the merge queue Sep 23, 2026
Merged via the queue into main with commit 9dcdb77 Sep 23, 2026
44 checks passed
@os-justin
os-justin deleted the claude/issue-15184-field-order-composition branch September 23, 2026 11:28
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
… the removal, and a gate keeps them there (objectstack-ai#19655)

Fixes objectstack-ai#18048

Clause-②: no

ADR-0049 tombstones, refusal strings and doc pages across fifteen files
dated a removal to the next npm major of `@objectstack/spec` — a bare
**18** attached to the package name. There is no npm 18, and under
ADR-0087's level ruling (Amended 2026-09-13) there will not be one as
the carrier: *"A tombstone names the npm release it ships in, ⛔ never
the protocol major […] a retirement shipping `minor` lands in
`17.x.y`"*. A sibling repository had already hung a cleanup schedule on
"the PR that pushes the spec package to its next major" — an event that
will never come.

This is the class's **second** appearance. Ten sites of it were
corrected by hand in July with no gate, and that card closed
`completed`. So the prose fix is half of this PR; the other half is
`pnpm check:future-spec-major`.

## What replaces the bad number, and why it is two answers

Determined **per site** from `packages/spec/CHANGELOG.md`, never pasted.
The sites split cleanly, and the two halves take different spellings
because different things are known about them.

**Already shipped ⇒ the release that actually carries it — `17.3.0`**
(published on npm; `npm view @objectstack/spec versions` ends `…17.2.0,
17.3.0, 17.4.0`). Eighteen hand sites plus four generated rows:

| retirement | changeset in `CHANGELOG.md` | release |
|:--|:--|:--|
| `PluginHealthCheck.autoRestart` / `maxRestartAttempts` /
`restartBackoff` | `b72db01` | `17.3.0` |
| `HotReloadConfig.stateStrategy: 'disk'`/`'distributed'`,
`distributedConfig` | `4635f3e` | `17.3.0` |
| `HotReloadConfig.watchPatterns` | `ee3595c` | `17.3.0` |
| form-view `options[].default` | `c459da6` | `17.3.0` |

This is the version an upgrading reader greps in `CHANGELOG.md` after
the tombstone error, which is exactly what the changeset-to-CHANGELOG
contract exists to make findable.

**Not shipped yet ⇒ the bare published major, `17`.** Eleven hand sites
plus three generated rows, covering three removals that are still
unreleased changesets on this branch:

| retirement | pending changeset |
|:--|:--|
| the seven cron-typed positions (export schedules,
`ScheduleState.cronExpression`, `DataSyncConfig.schedule`,
`CacheWarmup.schedule`, backup / DR-test schedules) |
`.changeset/cron-typed-positions-retired.md` |
| `CacheWarmup.strategy: 'scheduled'` |
`.changeset/17157-cache-warmup-scheduled-strategy-retired.md` |
| `PluginStartupResult.startTime` / `plugin` / `health` |
`.changeset/16059-startup-orchestrator-shipped-shape.md` |

For these the carrier minor is **unknown at authoring time**, so any
digit is a guess — the same guess that produced this defect. Bare `17`
is the one spelling that is true, exists, and cannot go stale:

1. **It asserts exactly what is known.** Each of those three changesets
declares `minor`, and ADR-0087 guarantees a pre-GA retirement ships
`minor`, so the carrier is necessarily some `17.x.y`. The major is a
fact; the minor is not.
2. **It names a version that exists.** Bare `17` is the published major.
3. **It is the house form.** 588 bare-`17` citations sit in the tree,
measured with this PR's own matcher.
4. **It is what the ledger prescribes.** `docs/v17-docs-sweep.md` run 3
fixed "17 passages dating v17 removals to 18 → **17**" — bare, not a
guessed minor — and that row is this class's detection fingerprint.
5. **It cannot seed a fourth recurrence.** A guessed `17.5.0` goes stale
the moment the minor count moves; bare `17` does not.
6. **The neighbours already read that way.**
`startup-orchestrator.zod.ts`'s own surviving `duration` → `durationMs`
tombstone says bare `@objectstack/spec 17`, three lines from the three
this PR rewrites.

⛔ **No protocol-major reference is altered.** `toMajor: 18`, `step18`
and the `PROTOCOL_VERSION` ladder are correct and untouched — nothing
here matches a number that is not directly attached to the package name,
so "retired in protocol major 18" is out of reach by construction.
Untouched-control: `git grep -c 'toMajor: 18'` and `git grep -c
'step18'` read the same before and after.

## The gate — `pnpm check:future-spec-major`

`scripts/check-future-spec-major.mjs`, registered in the root
`package.json` and wired into `lint.yml` (the gate step and the
`--self-test` invocation `check:self-test-wired` requires; that gate
reads 0 findings over 223 scripts).

**The class is derived at runtime**, never hardcoded: "an
`@objectstack/spec` version whose major is above the major in
`packages/spec/package.json`". A gate hardcoding the literal goes
dormant the day the tree reaches that major, and the next stale
tombstone will name the one after.

**It joins the joints before matching**, because a single-line matcher
reads a false zero on live sites. `packages/core/src/health-monitor.ts`
splits the phrase as `"… in @objectstack/spec " + '18 (objectstack-ai#12032, …'`;
there are JSDoc `*` continuations and plain wraps too. **And it reads
the backticked package name** — `` `@objectstack/spec` 18 `` in the same
file and in `content/docs/protocol/kernel/lifecycle.mdx` — which is a
second, independent way to read zero.

**The gap is bounded to a sentence continuation.** Exactly one space on
the same line, or a line break crossed through recognised joint syntax
(a closing quote, a `+`, a JSDoc `*`, indentation). A run of two or more
spaces or a tab is column alignment, never prose, so
`scripts/partition-test-shards.mjs`'s `@objectstack/spec 414 files
496.4s` is not a finding. That bound has its own self-test fixture,
carrying that row verbatim.

**Two exemption mechanisms, and neither is a path allowlist.** Three
sites cite the bad number in order to forbid it, and a gate that cannot
tell a tombstone from a quotation of a bad tombstone reds on the ruling
that forbids the bug.

- **R1, structural.** A citation wholly inside a Markdown
**double**-backtick code span is the phrase quoted as a PATTERN, which
is what a fingerprint row is — so `docs/v17-docs-sweep.md`'s table row
needs no ledger entry, and neither will the next fingerprint row. A
**single**-backtick span is not exempt; that is the live tombstone form.
- **R2, a witnessed self-invalidating ledger.** Each entry names a file,
the major it excuses, a `witness` regex that must still match the
quoting sentence, a pinned `covers` count, and a written `why` the
failure text prints. A path allowlist says "this file may say anything";
this says "this file may say exactly this, and must still say it". Three
refusals follow, each with its own battery: a **new** citation in an
exempt file matches no witness and reds; a witness that matches nothing
reds (editing the quotation away kills its exemption instead of leaving
a hole); a `covers` count that moves reds.

**The detector is excluded from its own corpus** — it documents the
class in its header and holds the adversarial fixtures in its self-test,
so it cites the bad form by necessity. ⛔ That would be a hole, so it
comes with a compensating control in the opposite direction: the file
MUST still carry at least `SELF_FIXTURE_FLOOR` citations above the
published major. Gut the fixtures — the one edit the exclusion would
otherwise hide — and the gate reds on itself.

**The self-test is the only instrument watching the rule**, because this
gate's defect class *is* its matching rule: green means the finding set
is empty, weakening can only shrink that set, and the empty set is the
fixed point of shrinking. 29 batteries with a pinned roster, a floor and
the module-flag handshake, including the wrapped fixture, the backticked
fixture, a fenced-code fixture, the column-aligned bound, a legitimate
bare-17 fixture that must pass, a derived-class fixture (the same corpus
goes green once the manifest says 18), and an empty-corpus refusal.

## Census, under the gate's own instrument

Re-runnable at this head:

```
node scripts/check-future-spec-major.mjs --census
```

| reading | before (`34545d6c6c`) | after |
|:--|--:|--:|
| live sites above the published major | 39 / 17 files | **0** |
| bare `18` remaining (all exempt quotations) | 39 / 17 files | 3 / 2
files |
| bare `17` control | 588 / 174 files | 602 / 176 files |
| dotted `17.x` control | 489 / 106 files | 511 / 113 files |

The bare-17 control moving is the fix landing, not over-matching:
fourteen of this PR's rewrites add a bare-`17` citation, and the dotted
bucket gains the twenty-two that now say `17.3.0`. Over an unchanged
file the matcher's verdict does not move.

## Verification

- `pnpm --filter @objectstack/spec test` — 511 files / 14933 tests pass.
`pnpm --filter @objectstack/core test` — 51 files / 1321 tests pass.
Both matter here: `health-monitor.test.ts` and `hot-reload.test.ts` pin
those guidance strings **by content**.
- `pnpm --filter @objectstack/spec --filter @objectstack/core typecheck`
— green.
- `pnpm --filter @objectstack/spec check:generated` — all 15 generated
artifacts up to date; `check:docs` green. The seven sites in
`content/docs/references/kernel/{plugin-lifecycle-advanced,startup-orchestrator}.mdx`
were **regenerated**, never hand-edited: the zod docblocks and
`retiredKey()` prescriptions moved and `gen:docs` projected them.
- `node scripts/pm/dispatch-gates.mjs --ran` — 151 derived families
accounted for, 148 run, 3 NOT MEASURED, 0 unrun. The three are named in
the report: two exit 3 (`PREREQUISITE NOT MET`, both needing a
whole-tree `pnpm build`) and `check:pm-dispatch-gates`, whose own
self-test is a recorded 597 s in `lint.yml` against this container's
~600 s foreground cap.
- **ESLint, narrowed and the narrowing proved.** Population read from
eslint's own config: 6992 tracked files with lintable extensions, none
globally ignored per its `isPathIgnored`. Linted: 12 files, the count
read from `--format json`, 0 errors / 0 warnings. Invariance:
`eslint.config.mjs` never enables type-aware linting for any file — zero
`parserOptions.project` and zero `projectService` in it, which its own
header states and measured with a positive control — so every verdict
depends only on that file's bytes plus the shared config. This PR
changes neither the config nor a byte of the other 6980 files, so no
untouched file's verdict can move. Quoted from `68ff8a1082`.

## Clause-②, measured rather than argued

Every affected string is a docblock, a doc page, or a `retiredKey()` /
`guidance` **message**. The key is refused before and after, so no
accept/reject result moves for any input. The control that decides it:
the phrase has **0 hits** across `packages/*/api-surface` and
`packages/*/export-origins`, so no published declaration baseline
carries these sentences and none moves. `node
scripts/check-changeset-no-major.mjs --base origin/main` is green; the
`--pair` C6 row is in the report.

Changeset grades `@objectstack/spec` and `@objectstack/core`, both
`patch`. `packages/core` is in scope although the card is anchored
`domain:spec`: the triage ruling makes the gate tree-wide, and a
tree-wide gate cannot be green on `main` while 11 live sites remain in
`packages/core` — six of them runtime refusal strings an operator reads.

## Acceptance notes

- **One bounded in-place fix, declared.**
`packages/spec/src/kernel/plugin-lifecycle-advanced.zod.ts` carried a
docblock reading "the watch-placeholder key retired in **18**" — the
same stale number, the same defect class, but with no package name
attached, so it is outside the card's stated class and outside this
gate's matcher by construction. It is now `17.3.0`, matching the
prescription three lines below it that this PR also rewrites. Evidence:
`git diff` on that file shows the two edits adjacent; the key's removal
is `ee3595c` in the `17.3.0` section of `packages/spec/CHANGELOG.md`.
- **Deliberately not changed.** `startup-orchestrator.zod.ts`'s
`duration` → `durationMs` tombstone says bare `@objectstack/spec 17` for
a rename that shipped in `17.4.0`. Bare `17` is correct and legitimate —
less specific than it could be, not wrong — so it is not in this card's
class and is left alone.
- **Two measurement readings are declared in the ledger, not
rewritten.** `packages/spec/src/migrations/{registry.ts,
entries/semantic/…}` record "controls objectstack 12966 and
`@objectstack/spec` 4997 on the same corpus" and `docs/adr/0021-…md`
records a `6526/6526` pass ratio. Those numbers are hit counts, not
versions, and they sit exactly one space after the package name, so no
whitespace rule can distinguish them from a citation. Rather than narrow
the matcher — the move that produces a false zero — they carry witnessed
`kind: 'measurement'` ledger entries.
- **Serial note.** objectstack-ai#19598 (draft) touches
`packages/spec/src/ui/view.zod.ts` at hunks well clear of the site here,
which is on line 2780. Re-derived on this branch after merging
`origin/main`: no textual conflict.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

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

Projects

None yet

3 participants