Repository navigation
Commit aad9ad5
feat(spec,lint,app-shell)!: a field group's relatedList names body tables only (#27592)
domain:spec#3 · os-support-ai · session_01WGufzgbJhFoXYsLSe5WSPP
<!-- claude-projects-attribution: {"github_login":"os-support-ai"} -->
_Requested by **Yibo Wang** · [project
thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwB4nF3VoKUasRdU6J4QcFgy4)_
Fixes #27478
Clause-②: no (narrowing)
Metadata-protocol: narrowed
Skills-docs: none — no file under `skills/**` teaches
`fieldGroups[].relatedList`;
`skills/objectstack-data/rules/relationships.md` and
`skills/objectstack-ui/SKILL.md` teach only the FK's own `relatedList`,
whose meaning does not change.
**Seat ruling on #27478: option B (narrowing).** Whether a related list
is a body table is decided once, by the relationship's own keys on the
child's FK (`relatedList: 'primary'`, or a `master_detail` that sets
`inlineEdit` and declares no `relatedList`), per ruling B on #27055. A
field group's `relatedList` only positions a body table; it never
promotes a side card. Option A would give body-ness two sources that can
disagree (the FK saying side card, the group saying body). So a group
naming a relationship that is not a body table is refused, with a
prescription, and Studio offers only body tables.
**Landing order.** PR #27352 rewrites the same
`fieldGroups[i].relatedList` describe and docblock. Per the seat, #27352
landed first and this PR writes its text on top of #27352's wording,
replacing only its "`relatedList: false` still hides the table"
sentence. No other #27352 line is touched.
## 维护者速读
- 改了什么:新增 `@objectstack/spec` 的唯一判定
`isBodyTableRelationship`(这个子关系是不是记录页的主表);field group 的 `relatedList`
指向侧栏卡片或被隐藏的关系时,`defineStack` 和对象写入口(lint 规则
`related-list-group-not-body`)都会拒绝并给出改法;Studio 只列主表;记录页、Studio
和拒绝读同一个判定。
- 为什么改:以前这种分组能保存、能发布,但记录页在分组位置什么都不画,静默失效;Studio 还会主动给出这种选项。
- 风险与回滚:BREAKING 只针对一种写法(分组指向非主表),仓库内示例、平台对象、dogfood 和 HotCRM
都没有这种写法;已存行照常加载,下次保存会 422。另有一格行为变化:`master_detail` + `inlineEdit` +
`relatedList: true` 现在是侧栏卡片(与该键的说明一致)。回滚即还原本 PR。
-
先看:`packages/spec/src/data/related-list-body-table.ts`(判定与拒绝句),`packages/spec/src/stack.zod.ts`(`collectRelatedListNameErrors`
内的调用),`ui/packages/app-shell/src/views/studio-design/ObjectGroupInspector.tsx`。
- 两分钟验证:`pnpm --filter @objectstack/spec exec vitest run
src/data/related-list-body-table.test.ts
src/stack-related-lists-order.test.ts`,应全绿。
- 需要你做的:无,席位负责落地。
## What changes
### Measured first, on `origin/main` 273137d
An object whose field group names a relationship that is not a body
table parsed, passed `defineStack` and the object write door, and drew
nothing at the group. A probe over six child shapes (a plain `lookup`,
`lookup` with `relatedList: true`, `lookup` with `relatedList: false`, a
`master_detail` with no `inlineEdit`, and two body-table controls)
answered the same for all six: `ObjectSchema.safeParse: true`,
`defineStack: accepted`, `door errors: []`. `buildDefaultTabs`
(plugin-detail) drew `details(basic,terms)` with
`record:related_list(task)` left in Related, so a group naming `task`
placed nothing.
The record page also had two copies of the rule. app-shell
`isMainContentList` (behind `deriveRelatedLists`' `isPrimary` and
Studio's relationship inspector) counted `'primary'`, or a
`master_detail` with `inlineEdit` and no `relatedList`.
`RecordDetailView`'s `isBody` was `isPrimary` OR `master_detail` +
`inlineEdit`, which ignored `relatedList: true`.
### One predicate
`isBodyTableRelationship(field)` in
`packages/spec/src/data/related-list-body-table.ts`, exported from
`@objectstack/spec/data`. Its readers:
- the record page: `deriveRelatedLists` sets `isPrimary` from it
(`isMainContentList` is deleted), and `RecordDetailView`'s `isBody` is
now `isPrimary`, so plugin-detail's `bodyTablePlacement` receives the
spec verdict;
- Studio: `RelatedTablePlacement` (`ObjectGroupInspector.tsx`) offers
only relationships with `isPrimary`, and the relationship inspector
reads an absent `relatedList` through it;
- the refusal, at `defineStack` and at the door.
`fieldGroupBodyTableRefusal` writes the one refusal sentence both
surfaces print.
### The refusal
- `defineStack` (`os validate`, `os build`):
`collectRelatedListNameErrors` pushes the sentence for
`fieldGroups[i].relatedList`, as a `STACK_CROSS_REFERENCE_INVALID`
issue.
- The object write door (`PUT /api/v1/meta/object`, Studio, MCP):
`validate-related-list-refs` reports `related-list-group-not-body`
(error) at `objects.<name>.fieldGroups[i].relatedList`, so the write
answers `422 INVALID_METADATA`. It runs under the closure boundary the
rule already documents: a child outside the judged collection is left
unjudged, as the name check leaves it. `os explain
related-list-group-not-body` has an entry.
- A bare child name names every FK of that child, so it is refused when
any of them is not a body table; the remedy then points at
`object.field` for the one that is.
- `relatedLists` entries are not judged this way: they order body tables
and side cards alike.
Example sentence (from the spec test):
> Object 'lead' fieldGroups[2].relatedList 'task' names a related list
that is not a body table: 'task.lead_id' is a side card. A field group's
relatedList only positions a body table (a FK declaring relatedList:
'primary', or a master_detail FK that sets inlineEdit and declares no
relatedList), so the record page draws nothing at the group for any
other list. Set relatedList: 'primary' on that FK, or remove the group's
relatedList.
### Describe and docblock
Written on top of #27352's wording (merged as ad3a640). Only the last
sentence of the describe and of the docblock change.
Before (`fieldGroups[i].relatedList` describe, last sentence):
> relatedList: false on the relationship still hides the table.
After:
> It names a body table only (a FK declaring relatedList: 'primary', or
a master_detail FK that sets inlineEdit and declares no relatedList): a
group naming a side card or a list hidden by relatedList: false draws
nothing, so it is refused where the child's fields are known (os
validate / os build and the metadata write door), naming the FK and the
remedy (set relatedList: 'primary' on it, or remove the group's
relatedList).
The docblock says the same, names `isBodyTableRelationship` and the rule
`related-list-group-not-body`, and keeps "Body tables no group places
keep the record page's default position."
`content/docs/references/data/object.mdx` is regenerated. The phrases
are pinned in `related-list-body-table.test.ts`, beside #27352's pins in
`record-page-describes.test.ts`, which stay green.
`field.zod.ts`'s "A field group's `relatedList` can place it among the
field sections" (on `relatedList: 'primary'`) is true as written once
this lands, so it is not edited (and it is #27352's line).
### One record page cell moves
A `master_detail` FK that sets `inlineEdit` and declares `relatedList:
true` is a side card, as `relatedList`'s description says ("write true
to keep it a side card") and as Studio's relationship inspector writes
it. `RecordDetailView` used to draw it as a body table. It now moves to
the related column. The seat confirmed this reading. Pinned by
`RecordDetailView.bodyTablePredicate-27478.test.tsx`.
### ADR-0087 and stored rows
- Disposition: `registered field-group-related-list-side-card-refused`,
a D3 semantic entry
(`packages/spec/src/migrations/entries/semantic/18.field-group-related-list-side-card-refused.ts`)
with no D2 conversion, because which remedy the author meant (promote
the FK, or drop the placement) is not recorded.
- Stored rows: the object parse does not judge this, so a stored
`sys_metadata` object row carrying such a group is not rewritten and
still loads and registers unchanged. Its record page draws what it drew
before, except the one cell above. Its next save through the metadata
write door is refused `422` at `fieldGroups[i].relatedList` until fixed,
and so is a write of the child that turns a body table a stored group
places into a side card (pinned in
`runtime-gate.object-writes.test.ts`).
- Census at the change: no example, platform object, dogfood fixture or
HotCRM object (hotcrm `47a0666`) declares a field group `relatedList` at
all. Deployed metadata was not measured.
- `Metadata-protocol: narrowed`: a value an author could write is now
refused (AGENTS.md: "a value refused ... is `narrowed`"), so every
package this diff moves takes `minor` (`@objectstack/spec`,
`@objectstack/lint`, `@object-ui/app-shell`, `@object-ui/plugin-detail`,
`@object-ui/i18n`), and the BREAKING banner stays.
### Liveness
- `object.json` `fieldGroups.relatedList`: planned → live (readers
`bodyTablePlacement`, `collectRelatedListNameErrors`,
`validateRelatedListRefs`).
- `field.json` `relatedList`: evidence re-cited to `deriveRelatedLists`
and `isBodyTableRelationship`.
## Why
A group naming a side card saved, published and silently drew nothing,
and Studio offered it. Under ruling B the FK is the only source of
body-ness, so the group is refused rather than honoured.
## Verification
All on head `78c79004` unless marked (head `31b2326c` changes only the
changeset's levels and declaration line), under `os-verify-lock`.
- New or changed tests:
- `packages/spec/src/data/related-list-body-table.test.ts`: the
predicate's verdict table (14 shapes plus null and undefined), the exact
refusal sentence, and the describe pins.
- `packages/spec/src/stack-related-lists-order.test.ts`: `defineStack`
refuses a group naming a side card, an inline-edited `master_detail`
with `relatedList: true`, a plain `master_detail` and a hidden list, and
a mixed bare name; body-table controls pass.
- `packages/lint/src/validate-related-list-refs.test.ts` and
`runtime-gate.object-writes.test.ts`: the door refuses a group naming a
side card and publishes it once the FK is primary; a child write that
makes a placed body table a side card is refused; the lint sentence
equals the `defineStack` sentence word for word.
-
`packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts`:
`PUT` answers `422` with rule `related-list-group-not-body` at
`objects.rl_quote.fieldGroups[0].relatedList`; the control saves.
-
`ui/packages/app-shell/src/views/RecordDetailView.bodyTablePredicate-27478.test.tsx`
(mounted page): an inline-edited `master_detail` with `relatedList:
true` is a side card; with no `relatedList` it is drawn at its group in
the details column.
-
`ui/packages/app-shell/src/views/studio-design/ObjectGroupInspector.relatedListPlacement-27054.test.tsx`:
Studio offers None plus body tables only, and a stored side-card value
shows the not-a-body-table line.
-
`ui/packages/plugin-detail/src/synth/__tests__/buildDefaultPageSchema.bodyTables-27055.test.ts`:
a group naming a side card promotes nothing.
- Reverse legs (`scripts/ablation-replace.mjs`, each restored to HEAD
with `git diff HEAD` empty, on `55a063ec`):
- `RecordDetailView` `isBody` put back to the old inline copy: the
mounted test went 1 failed, 1 passed (the control stayed green);
- the `defineStack` push deleted: `stack-related-lists-order` went 4
failed, 19 passed;
- the lint finding never pushed: lint went 4 failed, 33 passed.
- Suites: `@objectstack/spec` 839 files, 23799 tests passed;
`@objectstack/lint` 177 files, 7607 passed;
`@objectstack/metadata-protocol` 279 files passed, 4 skipped; touched ui
files 3 files, 22 tests passed; ui `scripts/` 169 files passed, 2
skipped. `check:generated` 14 of 14 up to date.
- Build: `turbo run build --concurrency=2`, 115 of 115 tasks.
- Gates: `dispatch-gates --commands` derives 113; 113 run, all exit 0;
`--ran` reconciles 113 derived, 113 run, 0 NOT-MEASURED, 0 UNRUN.
- Dogfood (title has `!` and the narrowing arm): full
`@objectstack/dogfood test` exit 0, 350 files passed, 3 skipped (2817
tests passed, 20 skipped).
- `check-changeset-no-major` with this body as the event: exit 0 on
`31b2326c` (level axis: `no (narrowing)` and `narrowed`, no moved
package graded `patch`); `check-adr-0087-registration` exit 0
## Human Minimum
- [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic.
- [x] 2. Every name I added uses the casing for its kind.
- [x] 3. No formatter ran; `pnpm lint` passes.
- [x] 4. One issue, one branch off an up-to-date `main`, one pull
request.
- [x] 5. A change that publishes carries a changeset, and both
declaration lines are filled in here and in the changeset.
- [x] 6. `packages/spec` touched: its generated artifacts are
regenerated and committed.
- [x] 7. No hand edits to `content/docs/references/`,
`packages/*/CHANGELOG.md` or `content/docs/releases/`.
- [x] 8. Title, body and comments are in English.
- [x] The *Before Submitting* list in CONTRIBUTING.md is done.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01WGufzgpxjnsDPHLokR644U
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 6bc7643 commit aad9ad5
31 files changed
Lines changed: 888 additions & 84 deletions
File tree
- .changeset
- content/docs/references/data
- packages
- lint/src
- metadata-protocol/src
- spec
- api-surface
- export-origins
- liveness
- state-counts
- src
- data
- migrations/entries/semantic
- ui/packages
- app-shell/src
- utils
- views
- metadata-admin/inspectors
- studio-design
- i18n/src/namespaces/studio
- plugin-detail/src/synth
- __tests__
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
315 | 315 | | |
316 | 316 | | |
317 | 317 | | |
318 | | - | |
| 318 | + | |
319 | 319 | | |
320 | 320 | | |
321 | 321 | | |
| |||
701 | 701 | | |
702 | 702 | | |
703 | 703 | | |
704 | | - | |
| 704 | + | |
705 | 705 | | |
706 | 706 | | |
707 | 707 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
586 | 586 | | |
587 | 587 | | |
588 | 588 | | |
589 | | - | |
| 589 | + | |
| 590 | + | |
590 | 591 | | |
591 | 592 | | |
592 | 593 | | |
| 594 | + | |
593 | 595 | | |
594 | 596 | | |
595 | 597 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
405 | 405 | | |
406 | 406 | | |
407 | 407 | | |
| 408 | + | |
| 409 | + | |
| 410 | + | |
| 411 | + | |
408 | 412 | | |
409 | 413 | | |
410 | 414 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
3877 | 3877 | | |
3878 | 3878 | | |
3879 | 3879 | | |
| 3880 | + | |
| 3881 | + | |
| 3882 | + | |
| 3883 | + | |
| 3884 | + | |
| 3885 | + | |
| 3886 | + | |
| 3887 | + | |
| 3888 | + | |
| 3889 | + | |
| 3890 | + | |
| 3891 | + | |
| 3892 | + | |
| 3893 | + | |
| 3894 | + | |
| 3895 | + | |
| 3896 | + | |
| 3897 | + | |
| 3898 | + | |
| 3899 | + | |
| 3900 | + | |
3880 | 3901 | | |
3881 | 3902 | | |
3882 | 3903 | | |
| |||
5338 | 5359 | | |
5339 | 5360 | | |
5340 | 5361 | | |
| 5362 | + | |
5341 | 5363 | | |
5342 | 5364 | | |
5343 | 5365 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
597 | 597 | | |
598 | 598 | | |
599 | 599 | | |
| 600 | + | |
600 | 601 | | |
601 | 602 | | |
602 | 603 | | |
| |||
625 | 626 | | |
626 | 627 | | |
627 | 628 | | |
628 | | - | |
| 629 | + | |
| 630 | + | |
| 631 | + | |
| 632 | + | |
| 633 | + | |
| 634 | + | |
| 635 | + | |
| 636 | + | |
| 637 | + | |
| 638 | + | |
| 639 | + | |
| 640 | + | |
| 641 | + | |
| 642 | + | |
| 643 | + | |
| 644 | + | |
| 645 | + | |
| 646 | + | |
| 647 | + | |
| 648 | + | |
| 649 | + | |
| 650 | + | |
| 651 | + | |
| 652 | + | |
| 653 | + | |
| 654 | + | |
| 655 | + | |
| 656 | + | |
| 657 | + | |
| 658 | + | |
629 | 659 | | |
630 | 660 | | |
631 | 661 | | |
| |||
0 commit comments