fix(spec)!: refuse a padded grouping.fields[].field name at the producer instead of handing three renderers a lookup that always misses - #17498
Conversation
…ducer WIP checkpoint before the heavy verification run. Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH Co-authored-by: Claude <noreply@anthropic.com>
…ducer `GroupingFieldSchema.field` was a bare `z.string()`, so `' business_unit '` was valid authored metadata. objectui's projection harvester trims the name for `$select` while three renderers bucket rows by the raw name, so the server answers under `business_unit`, every per-row lookup reads `undefined`, and the view collapses into one `(empty)` group / `Uncategorized` lane holding every record — a silent wrong answer that reads as a true statement about the data. `field` now carries a non-padded pattern: the refusal lands at `grouping.fields[N].field` and names the offending spelling verbatim. Not a `.trim()` — a trimming schema makes `' a '` and `'a'` silently equivalent, the consumer-tolerance direction AGENTS.md #0.1 refuses. Deliberately not the snake_case machine-name grammar `/^[a-z_][a-z0-9_]*$/`: a grouping level is authored as a field reference and a dotted relationship path (`owner.name`) is an in-tree spelling of one. Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH Co-authored-by: Claude <noreply@anthropic.com>
…name Generated projection of the `grouping.fields[].field` describe() change. `gen:schema` + `gen:docs`; no hand edit. Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check5 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 135 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 844eaeaea7ccb65a6a48859ac87db8960c1a048c && git checkout 844eaeaea7ccb65a6a48859ac87db8960c1a048c
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 0ee32edef5909be073ed6476b7ae864640afc19f db549a42c1cebf2a00eb3410e4d97427f4496d50 && git checkout -B drift-repro 0ee32edef5909be073ed6476b7ae864640afc19f && git merge --no-ff db549a42c1cebf2a00eb3410e4d97427f4496d50
node scripts/docs-audit/affected-docs.mjs --json 0ee32edef5909be073ed6476b7ae864640afc19f |
Seat verdict — clause-② contract review at tier. PASS with advisories, zero must-fix.Head: Landing pre-checks
⛔ Not enqueued yet, and ④ is why. What the review settled
|
Post-hoc contract review ADOPTED —
|
| transcript | served-model census |
|---|---|
| this review round | claude-fable-5-1 ×138 — and no other model, zero turns |
control: an unrelated os-dev round this session |
claude-opus-5 ×109 |
CONTRACT_REVIEW_TIER read at scripts/pm/dispatch-gates.mjs:10176 → 'claude-fable-5-1'; comparison is exact, never a family or prefix floor. ⇒ at tier. The control is there so a census returning one value everywhere cannot be mistaken for an instrument that only ever returns that value.
The answer
No defect on origin/main attributable to this change. The breaking half is exactly the declared narrowing — the round proved trim-equivalence by exhaustion (14,641 four-char strings over an 11-symbol alphabet including NBSP / U+3000 / BOM: 0 divergences from s === s.trim()), with a lit control (/^\S*$/ on the same harness: 150 divergences) proving the harness can tell shapes apart. Migration story present and mechanically registered (check-adr-0087-registration.mjs exit 0, [BREAKING+bang] registered). Not yet in any released tag, so "customers may already depend on it" is not yet true of any installable release.
Two readings worth keeping for whoever next worries about this change:
- Stored padded views do not break boot.
packages/objectql/src/registry.ts:3407-3427— spec validation on register is "DIAGNOSTIC — deliberately not a gate ([P2] The spec contract stops at authored source — stored metadata is rehydrated unparsed, unconverted, and ungated #3903) … registered anyway"; the refusal lives on the write path (saveMetaItem→ 422). That matches the ADR entry's "refused on its next authoring-path save", and it is the difference between a narrowing that bricks an existing tenant and one that merely blocks re-saving. Clause-②: yeswas conservative, not forced. All three new identifiers (GROUPING_FIELD_RULING,GROUPING_FIELD_NON_PADDED_PATTERN,checkGroupingFieldName) are module-private; no new payload key;check-widening-tells.mjsexit 0. Per 「收窄仍是语义面,⛔ 不触条款②」 the mechanical limbs do not fire — but 「claim 拿不准 ⇒ 按yes」 sanctions the declaration, and the spec lane requires the at-tier review for semantic-surface cards regardless. The declaration was right in direction; the round that cleared it was not at tier. Those are two different defects and only the second one was real.
Follow-ups — filed, not left as prose
Per the seat's own rule (⛔ an acceptance note with no named carrier is not a disposition), each got a card or an explicit "承接者:无":
⚠️ Packed-tarball smoke (release candidate)has been failing onmainsince 00:30Z today — and the round caught the reading that makes it interesting: shaa9c6477904succeeded at 23:54Z and failed at 00:30Z on the same sha. An advisory gate red onmainis shared damage; filed.scripts/openapi-self-consistency.test.tsis red in any/tmp-rooted worktree in this container (pnpm's relative symlinks resolve through/tmp; the test symlinksnode_modulesinto anos.tmpdir()sandbox at:174-186). Every agent working out of the scratchpad hits this and must not read it as amaindefect. Control that proves it is not this PR's: the identical 8 failures sit in a log dated 2026-09-10T15:13Z, before this PR merged at 18:29Z, and 0 of the failing files mention view or grouping. Filed.- The
.describe()and the ADR entry say "whitespace" while the accept set is JS\S— NBSP, BOM and U+3000 are refused and undocumented. Filed to this lane. - A docs tension in the PM rulebook: this PR's body cites "objectui#8285 precedent ⇒ a spec narrowing declares yes", which is a per-card director ruling, while
references/lanes/spec.md:19says narrowing does not trip clause ②. Not a defect in this PR; filed to the skills lane.
The round's NOT MEASURED section is reproduced verbatim below and is not a formality — read it before treating this review as exhaustive.
All readings are in. Final report follows.
Post-hoc contract review — PR #17498 (fix(spec)!: refuse a padded grouping.fields[].field name at the producer)
Reviewer tier self-check (first finding). scripts/pm/dispatch-gates.mjs:10176 read at 2026-09-13T07:53:41Z: export const CONTRACT_REVIEW_TIER = 'claude-fable-5-1'; (docblock: comparison is EXACT, never a family/prefix floor). Harness-stamped served model in this transcript: claude-fable-5-1. Exact match — this review is at tier. (A model dispatch parameter was not used as the reading.)
Method facts. Merged squash commit f8e5790593e (parent 10da5c44e38, 2026-09-10 18:29Z; PR merged 18:55:32Z). PR head db549a42c1c. git merge-base head merge^ = 65ad77d5aea (07:54:43Z). git diff 65ad77d..db549a42 vs git diff f8e5790^..f8e5790: IDENTICAL ignoring index lines — 6 files, +323/−3. Today's origin/main = 225197cdbaf, 230 commits later; 13 of those touched the PR's files (incl. #17298, the co-tenant). Own detached worktree scratchpad/cr17498wt at 225197cdbaf, pnpm install --offline exit 0. Nothing written to GitHub; no branch touched; no stash; no kill.
Q1 — The breaking change, characterised exactly
Reading (tsx script against the worktree's view.zod.ts on origin/main, 07:57:28Z):
| input | before (bare z.string()) |
now |
|---|---|---|
"business_unit", "owner.name", "business unit", "Business_Unit", "部门", "" |
accept | ACCEPT, byte-identical (data.field === input true for all) |
" business_unit", "business_unit ", " business_unit ", "\tbusiness_unit", "business_unit\n", " " |
accept | REFUSE at path ["field"]; through ListViewSchema the path is grouping.fields.1.field (the offending element) |
| U+00A0 leading, U+FEFF leading, U+3000 trailing | accept | REFUSE (JS \S semantics — the accept set is exactly trim-equivalence, Unicode included) |
Trim-equivalence proven: 14,641 four-char strings over an 11-symbol alphabet (incl. NBSP/ideographic/BOM): 0 divergences from s === s.trim(); lit control /^\S*$/ on the same harness: 150 divergences. Message names the spelling and the remedy: … received " business_unit " … Write "business_unit". (ruled 2026-09-10.).
Which shipped shapes break: exactly stored list views whose grouping.fields[].field has leading/trailing whitespace. In-tree: git grep -P "field:\s*(['\"])(\s[^'\"]*|[^'\"]*\s)\1" origin/main -- packages apps examples content → 6 hits: 4 are the PR's own pins in view.test.ts, 1 is the negative fixture view-grouping-query.test.ts:507 ({ field: ' ' }, handed to compileListViewGroupQuery, never Zod-parsed), 2 are unrelated message strings (metadata-protocol/protocol.ts:13301, cli/test/metadata-type-schema-gate.test.ts:114). Lit control: line 507 matched by the same regex = 1. JSON "field": " …" = 0.
Migration story present: ADR-0087 semantic entry 18.ui-list-view-grouping-field-padded-refused (replacement = re-author, no .trim(); explicitly no lossless conversion, hand prescription); node scripts/check-adr-0087-registration.mjs --base 65ad77d --head db549a42 → exit 0 [BREAKING+bang] registered … (new here); changeset minor + **BREAKING** + FROM→TO table; check-changeset-no-major.mjs header (lines 5/47/55) confirms the launch-window minor convention. Boot is not broken by stored padded views: packages/objectql/src/registry.ts:3407-3427 — spec validation on register is "DIAGNOSTIC — deliberately not a gate (#3903) … registered anyway", refusal lives on the write path (saveMetaItem → 422) and reads carry _diagnostics. That matches the entry's "refused on its next authoring-path save". Not yet released: git tag --contains f8e5790593e → none; newest @objectstack/spec@17.4.0; the changeset is still pending among 271. So "customers may already depend" is not yet true of any installable release.
Verdict Q1: the breaking half is exactly the declared narrowing — no more, no less; migration story present and mechanically registered.
Q2 — Clause-② declaration
PR body column-0 line: Clause-②: yes — fixed **by the ruling**, not by the diff's size. (fixed spelling; check-clause2-carriers --pair 17498 exit 0 per the seat's record — not re-run by me).
Mechanical limbs, verbatim from the tree (07:59:09Z): .claude/skills/pm-dispatch/references/lanes/spec.md:19 「放宽接受集或扩大公开面的卡,不论多小,即条款②;收窄仍是语义面,不触条款②。」 and references/contract-review.md:13 「机械地板 claim 时可查树:新导出符号或已发布载荷上的新键恒 yes,锁契约复审档复核。」
Diff against them: new identifiers GROUPING_FIELD_RULING, GROUPING_FIELD_NON_PADDED_PATTERN, checkGroupingFieldName are all module-private (no export); api-surface/ui.json, declaration-map, export-origins not in the diff; no new payload key. node scripts/pm/check-widening-tells.mjs --declaration no --diff <pr diff> → exit 0 (3 files judged, no tell; 3 NOT MEASURED by construction: changeset, mdx, test). Self-test 269 cases pass.
Verdict Q2: neither limb forces yes; this is a narrowing, so clause ② does not fire mechanically. The yes is the card's conservative routing, which the rulebook sanctions (contract-review.md:6 「只定…保守方向,⛔ 非终审」, :9 「claim 拿不准 ⇒ 按 yes」; widening-tells never blocks yes) and the spec lane requires the at-tier review for semantic-surface cards regardless (core-rules.md:113). Declared correctly in direction. Note: the body and card cite "objectui#8285 precedent says a spec narrowing declares yes" — that is a per-card director ruling (batch #91 on objectui#8285), not the rulebook, and lanes/spec.md:19 says the opposite; a docs-only tension for the PM lane, not a defect here.
Q3 — Scope against the ruling
Ruling, verbatim from #17360: "C — refuse at the producer. GroupingFieldSchema.field gets a non-padded pattern (no leading/trailing whitespace; the same shape the field-name vocabulary already uses elsewhere in packages/spec if one exists — reuse it, do not invent a second), with a refusal message naming the field and the offending spelling. ⛔ Not .trim() … The harvester's trim on the objectui side stays as defence-in-depth … The sibling groupByField axis … is ⛔ not this card."
- Non-padded pattern, refusal by name + spelling: measured above ✓.
- "Reuse if one exists": on the merge base,
packages/spec/src(non-test, non-migrations) has no non-padded name vocabulary — 6 hits for\S$|^\S|.trim() ===|leading/trailing whitespace, all blank-checks or.describe()text; the only candidate (/^[a-z_][a-z0-9_]*$/) is a different accept set (refusesowner.name, whichpackages/lint/src/validate-list-view-field-refs.test.tsauthors). No second vocabulary invented ✓. - Not
.trim(): accepted values byte-identical, padded ones refused ✓. groupByFielduntouched: the onlygroupByFieldline in the diff is changeset prose;view.zod.ts:1031/1309/1432still barez.string()on main ✓.- Changeset names objectui#7347 unblocking on the installable release ✓;
minor+ BREAKING + ADR-0087 ✓. - Nothing beyond the ruling: 6 files; the one file outside the claim's declared face is
content/docs/references/ui/view.mdx, the regenerated projection of the.describe()(lanes/spec.md:21「产物随源走」) —check:docsexit 0 on today's main (222 generated files in sync).
Verdict Q3: in scope, every clause honoured, nothing extra shipped.
Q4 — Defect on main today?
Through os-verify-lock.sh (slot cr17498-posthoc, waited 226 s, held 331 s; the first turn ended while waiting — re-taken, the run had survived): turbo run test on 225197cdbaf:
| package | result |
|---|---|
@objectstack/lint |
102 files / 3758 passed, 5 skipped |
@objectstack/platform-objects |
40 files / 571 passed |
@objectstack/spec |
473 files passed, 1 failed, 1 skipped; 13500 passed / 8 failed |
@objectstack/rest |
cancelled by turbo on the spec failure (60 files ✓, 0 ×, no summary) → re-run alone: 191 files / 3196 passed, 1 skipped, vitest-exit=0 (08:15:35Z) |
The 8 spec failures are all scripts/openapi-self-consistency.test.ts — Cannot find module '/tmp/node_modules/.pnpm/tsx@4.23.12/…/cli.mjs': the test symlinks node_modules into an os.tmpdir() sandbox (lines 174–186) and pnpm's relative symlinks resolve through /tmp — a container artifact. Control: the identical 8 failures sit in this scratchpad's spec-full.log dated 2026-09-10T15:13Z, before the merge (18:29Z); 0 of the failing files mention view/grouping. Light gates on main: check:migration-registry 0 (206 semantic/168 retired-key/178 retired-def, current), check:spec-changes 0, check:authorable-surface 0, check:docs 0. Merge-queue full-suite run on the merge commit itself: run 34514657802, gh-readonly-queue/main/pr-17498-…, head f8e5790, success (lit control: 290 distinct pr- branches across the 300 runs scanned). PR-side: 43 check runs, all success/skipped.
Packed-tarball smoke: publish-smoke.yml, fired by workflow_run after Release on main. Failure streak from 2026-09-13T00:30Z (a9c6477904 succeeded at 23:54Z and failed at 00:30Z on the same sha), 8 fail/cancel through 07:38Z, in_progress on 225197cdba at 07:59Z. Successes on 09-12 21:21–23:54Z on descendants of #17498 (e.g. 9c44eed4cb). So it is not introduced by this PR; whether it is chronic-environmental is suggested by the same-sha flip but its cause is NOT MEASURED (logs not read).
Verdict Q4: no defect on main attributable to this change; the merged block is intact (probes A 2/1, B 2/2 — the os-regen mdx survived #17298 — C 1/1; block diff merged→main: 0 lines; entry and changeset byte-identical; pins #17360 2, BY NAME 1, IN_TREE_… 6).
Q5 — Cross-package blast radius
- Old describe text sites: exactly 2 files (
view.zod.ts,view.mdx), both carrying the new suffix (2/2, 1/1). - Snapshot fixtures: 0
__snapshots__/.snapfiles in the repo at all. authorable-surface/ui.jsonrowsui/GroupingField:field/order/collapsedunchanged;authorable-surface.base.jsonuntouched; gate exit 0. Liveness ledger row (2026-06-viewschema-property-liveness.md:15) unaffected. Strictness ledger untouched (no new key). No numeric pin on semantic-entry counts found (20[3-7]near "semantic": 0 hits in tests/scripts/audits).GroupingFieldSchemaconsumers repo-wide: 10 files, all in spec + generated/ledger projections; no other package parsesListViewSchemaat runtime (0 non-test parse sites outside spec).- objectui (
origin/main 69aa9c017, read-only): harvester trims (core/src/utils/__tests__/grouping-fields.test.ts:43-44),usableGroupingFieldskeeps the raw name (useGroupedData.ts:259-266), gallery readsitem[f.field]raw, kanban falls back toschema.grouping.fields[0].fieldraw — the prose's mechanism holds today.
Verdict Q5: nothing outside packages/spec reddens; lint/rest/platform-objects green on today's main.
Q6 — Published invariants without backing
Backed: compileListViewGroupQuery groupBy column (view-grouping-query.ts:105,123); grouping_field_blank at path ['grouping','fields',index,'field'] (:341-347; status type 501|400 at :305); 「短期不考虑渐进」 present in SKILL.md (1 hit); objectui trim/raw reads (above); load-path diagnostic vs save-path 422 (registry.ts:3407-3427).
Notes (none a defect): (a) .describe()/mdx say "whitespace" while the accept set is JS \S — NBSP/BOM/U+3000 are refused, undocumented (the earlier review's Advisory 2, still unaddressed); (b) the .describe() on main asserts a consumer behaviour ("buckets every row into one empty group") — true of objectui today, stale the day objectui trims; (c) the entry's "50 occurrences / 19 files / 906 literals" is a historical TS-parser count I could not reproduce with the same predicate (grep approximation on the merge base: 43 grouping: { in 16 files) — plausible, not a contract invariant; (d) "decision batch #110 item 5 / 「其他同意」" is chat provenance — not measurable from the tree.
Overall verdict: WOULD HAVE CLEARED WITH NOTES
An at-tier review would have passed this head; the guarantee is now supplied. Follow-ups, none blocking: (1) docs-only — reconcile the "objectui#8285 precedent ⇒ narrowing declares yes" wording with lanes/spec.md:19 (carrier: PM skill seat); (2) docs-only — name the Unicode whitespace class in the .describe()/entry (carrier: domain:spec); (3) tooling — openapi-self-consistency.test.ts is red in any /tmp-rooted worktree in this container (not a main defect; carrier: devx); (4) the publish-smoke streak since 00:30Z today is unrelated to this PR but uninvestigated.
NOT MEASURED, by name: publish-smoke failure cause; the TS-parser harvest counts (50/19, 906) and the prior review's 24,000-case run (I ran my own 14,641); the REST saveMetaItem 422 by actual request; ViewMetadataSchema's union membership of ListViewSchema (not found in my grep window); check:api-surface / export-origins / declaration-map / dual-build-cjs-loads / type-check-debt gates; today's main push-CI conclusion; objectui#7347's current state and its consumer regression test (blocked on release); packages other than the four run.
维护者速读: 这条破坏性收窄(分组字段名带首尾空白即拒)与裁决完全一致,迁移登记与 changeset 齐全,今日 main 上 spec/lint/rest/platform-objects 四个包套件全绿(spec 的 8 个红是本容器 /tmp 工作树的路径伪影,合并前就有);未发现缺陷。Clause-②: yes 属保守申报(收窄本身不触条款②),方向无误。
Generated by Claude Code
|
Served-tier: 138/138 Verdict record in the ruled shape — post-hoc,
|
| field | value |
|---|---|
| Served-tier | 138/138 claude-fable-5-1 — census of the harness-stamped served-model field over every assistant turn of the reviewer's transcript, taken 2026-09-13T08:19:28Z |
CONTRACT_REVIEW_TIER |
'claude-fable-5-1', read at scripts/pm/dispatch-gates.mjs:10176 |
| comparison | EXACT — equal |
| below-tier turns | 0 |
| control | a different transcript (an os-dev round this session) read claude-opus-5 ×109 — the probe can return non-fable, so the zero is a reading |
| verdict | WOULD HAVE CLEARED WITH NOTES — no defect on origin/main attributable to this change |
| follow-ups filed | #17960 (publish-smoke red on main) · #17961 (/tmp-rooted false red) · #17962 (Unicode whitespace undocumented) · #17963 (docs tension on narrowing vs clause ②) |
⛔ The reading is not the dispatch model parameter, and ⛔ not a bare model-name token grepped from the transcript body — that looser probe has a documented false-positive mode that grows with adoption of this discipline (objectui seat, comment 5651573578 on #17915). This census parses each transcript line as JSON, keeps records whose type is assistant, and reads the structured message.model key.
Generated by Claude Code
Fixes #17360
Clause-②: yes — fixed by the ruling, not by the diff's size. This is a spec narrowing, and the objectui#8285 precedent says a spec narrowing declares
yes.needs:contract-reviewrides on both carriers (already on the card; hung on this PR too), the changeset isminorwith a**BREAKING**note, and the ADR-0087 disposition is registered. It needs an at-tier verdict on the head that lands — ⛔ this PR is a draft and the at-tier verdict is thedomain:specseat's to commission, so nothing here flips ready, enqueues, or arms auto-merge.Implemented by an
os-devsubagent in sessionsession_01MkQhmuuJAVDjmeWNixwDDH, inheriting the claim and assignee from thedomain:specexecution seat'sClaim:comment on #17360 (no second claim posted, assignee untouched).The ruling
Ruling C on objectui#7347, maintainer verbatim 「其他同意」, decision batch #110 item 5: refuse at the producer.
GroupingFieldSchema.fieldgets a non-padded pattern with a refusal naming the field and the offending spelling.⛔ Not
.trim(). A trimming schema makes' a 'and'a'silently equivalent, which is the consumer-tolerance direction AGENTS.md #0.1 refuses. objectui's harvester trim stays as defence-in-depth; nothing is removed there. The siblinggroupByFieldaxis is explicitly not this card and is untouched.⭐ The "reuse the vocabulary if one exists" conditional — I measured it and took the FIRST branch, which is not what I inherited
The ruling asks for a non-padded pattern "the same shape the field-name vocabulary already uses elsewhere in
packages/specif one exists — reuse it, do not invent a second".The seat's hold notes measured that
FieldNameSchemawas retired under protocol 18, and concluded the conditional resolves to its second branch (define the pattern). I re-verified that againstorigin/mainand half of it is right, but the conclusion is not — and the correction comes from the retired-def's own text:So the branded schema is retired (confirmed: the retired-def entry and the semantic entry
18.branded-identifier-schemas-retiredare both onorigin/main), but a field-name vocabulary is alive and ubiquitous:/^[a-z_][a-z0-9_]*$/, spelled inline at 30+ sites includingdata/field.zod.ts:932— the fieldnameitself. The conditional therefore resolves to its first branch, and no protocol-18 retirement has to be re-opened to read it. ⛔ Nothing here revives the retired branded-identifier family — there is no 回翻 in this diff and the card did not have to be returned.And then I did not adopt that vocabulary — measured, with the evidence:
grouping.fields[].fieldis authored as a field reference, not a machine name, and the repo proves it:packages/lint/src/validate-list-view-field-refs.test.ts:537carriesgrouping: { fields: [{ field: 'owner.name' }] }, a dotted relationship path, in a test that asserts it produces no finding./^[a-z_][a-z0-9_]*$/refusesowner.name. Adopting it would have reddened an in-tree grouping fixture — which the acceptance criteria name as a finding to report, not a reason to widen — and would have been a different, larger narrowing than the one ruled.⇒ The pattern is non-padded only:
/^(?:\S|\S[\s\S]*\S)?$/. Exactly what the ruling asked for, nothing wider, nothing narrower. Stated in the source docblock so the next reader does not have to re-derive it.What changed
packages/spec/src/ui/view.zod.ts— anchored by content onexport const GroupingFieldSchema(it happened to still be at :853, but nothing here depends on that):GROUPING_FIELD_NON_PADDED_PATTERNand acheckGroupingFieldName(raw)refusal builder, following this file's own house idiom for a dynamic per-value message (checkSubmitRedirectUrl+.superRefine), which is what lets the message name the offending spelling as well as the key;fieldgains the.superRefine, and its.describe()states the rule.The refusal lands at
grouping.fields[N].field— the offending element's own key — and reads, for' business_unit ':Deliberately left alone: the empty string still parses. A blank name is already refused loudly one layer down by
compileListViewGroupQuery'sgrouping_field_blank(400, path['grouping','fields',N,'field']); this narrowing exists for the silent case, and refusing the blank here too would be scope the ruling did not ask for.Also: the ADR-0087 semantic entry
18.ui-list-view-grouping-field-padded-refused,registry.tsregenerated withgen:migration-registry(⛔ no hand edit — the insertion is at :10355, inside theos-generated semantic:18markers at :5452/:10565), the changeset, and the regeneratedcontent/docs/references/ui/view.mdx.⭐ Acceptance — the fixture enumeration, which is the item that bites
Harvested with the TypeScript parser, not a grep — the tool's own predicate rather than a re-implementation:
grouping: { fields: [ … ] }{ field, order?, collapsed? }literal inpackages/**Lit controls:
business_unitpresent in the harvest;prioritypresent (it is theGroupingFieldSchema.parsefixture that pass 1 structurally could not see, which is why pass 2 exists). Dark control:zz_no_such_grouping_fieldabsent.The one refused spelling is
' 'atpackages/spec/src/ui/view-grouping-query.test.ts:507. It is a negative fixture handed straight tocompileListViewGroupQuerywith no Zod parse anywhere on its path, pinning that samegrouping_field_blankrefusal — so it is not a fixture that has to parse, and the producer now refuses it one layer earlier for the same reason. Zero in-tree fixtures redden, and the pattern was not widened to fit anything.Pins added in
view.test.ts: the by-name refusal atgrouping.fields.1.field(asserted throughListViewSchemaso the path is the real one), the six whitespace shapes, a not-a-trim arm, the 20 harvested spellings as anit.eachtable, and a lit/dark control test that also carries the discriminator (' owner.name'is refused) — because the accepting arms alone would pass just as well against the old barez.string().Verification
Reverse verification (ablation). Predicted direction: red. Removed the
.superRefineand rebuilt nothing (the test resolves./view.zodrelatively — nodiston the path). On-disk proof before and after: injected-text count 2 to 0,git diff --stat HEADnon-empty. Result: 9 pins red, exactly the ruling's ones (× refuses a padded grouping field name BY NAME at grouping.fields[N].field, the six whitespace shapes, the not-a-trim arm, the lit/dark discriminator); the 20 accepting arms stayed green, which is the expected asymmetry. Restore leg viagit checkout HEAD -- PATHunder atrap … EXIT INT TERMwith an absolute path, proven byte-identical: worktree blob848f63fa33f3e63da434e59bcc51abcdbdfc8718equals the HEAD blob.@objectstack/specfull suite@objectstack/spectypecheck@objectstack/lint·@objectstack/rest·@objectstack/platform-objectseslint . --no-inline-configdispatch-gates.mjs)The downstream three are run because an accept-set narrowing changes the runtime face even though the exported byte shape does not; their first run was the
MODULE_NOT_FOUNDclass (unbuilt@objectstack/formula) and was re-run after building the closures rather than recorded as red.NOT MEASURED, called out separately from red — neither is a finding:
pnpm check:dual-build-cjs-loads— exit 3, PREREQUISITE NOT MET: 33 packages have nodist/. Needs a whole-monorepo build; declared to CI.pnpm check:type-check-debt— exit 3, PREREQUISITE NOT MET:@objectstack/metadata-coreunbuilt, and the gate refuses to measure a different world. Its coverage half (check:type-check-coverage) ran green.check:react-declaration-parity, run exactly as dispatched: exit 0, manifestsdui.manifest.jsonat the repository root (66910 bytes) — not thepackages/specpath a literal$PWDgives you, which the gate itself rejects with a loud "this gate did NOT run"..objectui-sharemains unmeasured (#17405), so this green is about parity against the committed manifest, not about the manifest being current.Two residuals I am not able to close from here and am therefore declaring rather than papering over: the gate derivation ran on a tree ~9 commits behind
origin/main, and one file it derives from (scripts/measure-reserved-identity-name-census.mjs) changed in that range, so CI's re-derivation on the true merge base is the authority on the family set; and the 45 artifact-roster families, the 11 wide-population families and the 6 path-scheduled CI jobs are outside the derived total by construction.The claim's batch-independence line reads "measured disjoint from … every open PR at claim time". Re-checked at write time: PR #17298 (
feat(spec)!: retire the type: 'page' list-view mount, open,mergeable_state: dirty) touches all three ofview.zod.ts,view.test.tsandregistry.ts.I did not widen my face and I did not reorder anything. The hunks are disjoint by a wide margin — #17298's earliest
view.zod.tshunk is at :1629 against my :853, its earliestview.test.tshunk at :3754 against my ~:2050, andregistry.tsis generated and regenerates — so this merges cleanly rather than needing the serial hold that #17447 got. Recording it because the claim's reading of it was different from mine, and that is the seat's to arbitrate, not mine.Generated by Claude Code