Skip to content

Commit aad9ad5

Browse files
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
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/lint': minor
4+
'@object-ui/app-shell': minor
5+
'@object-ui/plugin-detail': minor
6+
'@object-ui/i18n': minor
7+
---
8+
9+
feat(spec,lint,app-shell)!: a field group's `relatedList` names body tables only — a group naming a side card or a hidden list is refused, and Studio offers body tables only
10+
11+
Clause-②: no (narrowing)
12+
Metadata-protocol: narrowed
13+
14+
<!-- adr-0087: registered field-group-related-list-side-card-refused -->
15+
16+
**BREAKING** for one shape: an object's `fieldGroups[i].relatedList` that names an inbound relationship which is not a body table of the record page. Ruling B on #27478: whether a related list is a body table is decided once, by the relationship's own keys on the child's FK (ruling B on #27055), and a field group only positions one.
17+
18+
- **Before.** A group's `relatedList` naming any inbound relationship parsed, passed `defineStack` and published, but the record page draws only body tables at a group: a group naming a side card (`task` through a plain lookup, say) drew nothing at the group and the card stayed in the related column, silently. Studio's group inspector offered every inbound relationship.
19+
- **Now.** One predicate, `isBodyTableRelationship` (exported from `@objectstack/spec/data`), answers "is this inbound relationship a body table": its FK declares `relatedList: 'primary'`, or it is a `master_detail` FK that sets `inlineEdit` and declares no `relatedList`. A group whose `relatedList` names a relationship it does not count is refused where the child's fields are known: `defineStack` (`os validate`, `os build`) with a `STACK_CROSS_REFERENCE_INVALID` issue, and the object publish door (`PUT /api/v1/meta/object`, Studio, MCP) with `422 INVALID_METADATA`, rule `related-list-group-not-body` (`@objectstack/lint`, with an `os explain` entry). The sentence, written by `fieldGroupBodyTableRefusal` on both surfaces, names each FK that is not a body table (a side card, or hidden by `relatedList: false`) and the remedy. A bare child name names every FK of that child, so it is refused when any of them is not a body table, and the remedy points at `object.field` for the one that is. Children the name check leaves unjudged (another package's object) are left unjudged here too. `relatedLists` entries are not judged this way: they order body tables and side cards alike.
20+
- **The remedy.** Mark the relationship `relatedList: 'primary'` on the child's FK (or, on an inline-edited `master_detail`, remove an explicit `relatedList: true`), name only the body table as `object.field`, or remove the group's `relatedList`. There is no automatic conversion: which edit the author meant is not recorded. The upgrade guide entry is `field-group-related-list-side-card-refused`.
21+
- **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. The record page (`@object-ui/app-shell` `RecordDetailView`) used to draw it as a body table, because it kept its own copy of the rule that ignored `relatedList`. It now reads the spec predicate through `deriveRelatedLists`' `isPrimary`, so that list moves to the related column. `isMainContentList` (an internal helper of `@object-ui/app-shell`) is gone; its callers read `isBodyTableRelationship`.
22+
- **Studio.** The field-group inspector's related-table picker (`@object-ui/app-shell`) offers body tables only, refuses to write any other pick, and shows a stored value that is not a body table with a line saying why the page draws nothing for it (`@object-ui/i18n`, en and zh).
23+
- **Stored rows and built artifacts.** 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. The next save of that object through the metadata write door is refused (`422`, at `fieldGroups[i].relatedList`) until it is fixed, and so is a write of the child that turns a body table a stored group places into a side card. A built artifact is judged when it is built. 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.
24+
- **`@object-ui/plugin-detail`.** Documentation only: `bodyTablePlacement` and the `isBody` option name the spec predicate.

‎content/docs/references/data/object.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -315,7 +315,7 @@ const result = ApiMethod.parse(data);
315315
| **label** | `string` | ✅ | Group display label |
316316
| **icon** | `string` | optional | Icon name (Lucide/Material) for the group header |
317317
| **description** | `string` | optional | Optional description shown under the group header |
318-
| **relatedList** | `string` | optional | Places a related list's body table at this position in the record's details column instead of a section of fields: names one inbound relationship, as a relatedLists entry does (child object, or object.field when that child has more than one relationship to this object). A group with relatedList holds no fields: a field that names it as its group is refused. The name is judged as a relatedLists entry is: refused when its child is in the stack with no inbound lookup / master_detail field, or exists nowhere (by os validate / os build, which leave a sys_ / cloud_ / ai_ name to the runtime, and by a metadata write that names no package); a write into a package stores it, and the record page places nothing for it. relatedList: false on the relationship still hides the table. |
318+
| **relatedList** | `string` | optional | Places a related list's body table at this position in the record's details column instead of a section of fields: names one inbound relationship, as a relatedLists entry does (child object, or object.field when that child has more than one relationship to this object). A group with relatedList holds no fields: a field that names it as its group is refused. The name is judged as a relatedLists entry is: refused when its child is in the stack with no inbound lookup / master_detail field, or exists nowhere (by os validate / os build, which leave a sys_ / cloud_ / ai_ name to the runtime, and by a metadata write that names no package); a write into a package stores it, and the record page places nothing for it. 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, name only the body table as object.field when a bare child name also names a side card, or remove the group's relatedList). |
319319
| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Section visibility predicate (CEL) — the whole group (header included) is shown only when TRUE, else hidden (fail-closed). e.g. P`record.type == 'invoice'` |
320320
| **collapse** | `Enum<'none' \| 'expanded' \| 'collapsed'>` | optional (default: `"none"`) | [ADR-0085] Section collapse behaviour: 'none' (always open, no toggle), 'expanded' (collapsible, starts open), 'collapsed' (collapsible, starts closed). |
321321
| **defaultExpanded** | `boolean` | optional | [DEPRECATED → collapse] true → 'expanded', false → 'collapsed'. |
@@ -701,7 +701,7 @@ External datasource binding (ADR-0015)
701701
| **label** | `string` | ✅ | Group display label |
702702
| **icon** | `string` | optional | Icon name (Lucide/Material) for the group header |
703703
| **description** | `string` | optional | Optional description shown under the group header |
704-
| **relatedList** | `string` | optional | Places a related list's body table at this position in the record's details column instead of a section of fields: names one inbound relationship, as a relatedLists entry does (child object, or object.field when that child has more than one relationship to this object). A group with relatedList holds no fields: a field that names it as its group is refused. The name is judged as a relatedLists entry is: refused when its child is in the stack with no inbound lookup / master_detail field, or exists nowhere (by os validate / os build, which leave a sys_ / cloud_ / ai_ name to the runtime, and by a metadata write that names no package); a write into a package stores it, and the record page places nothing for it. relatedList: false on the relationship still hides the table. |
704+
| **relatedList** | `string` | optional | Places a related list's body table at this position in the record's details column instead of a section of fields: names one inbound relationship, as a relatedLists entry does (child object, or object.field when that child has more than one relationship to this object). A group with relatedList holds no fields: a field that names it as its group is refused. The name is judged as a relatedLists entry is: refused when its child is in the stack with no inbound lookup / master_detail field, or exists nowhere (by os validate / os build, which leave a sys_ / cloud_ / ai_ name to the runtime, and by a metadata write that names no package); a write into a package stores it, and the record page places nothing for it. 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, name only the body table as object.field when a bare child name also names a side card, or remove the group's relatedList). |
705705
| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Section visibility predicate (CEL) — the whole group (header included) is shown only when TRUE, else hidden (fail-closed). e.g. P`record.type == 'invoice'` |
706706
| **collapse** | `Enum<'none' \| 'expanded' \| 'collapsed'>` | optional (default: `"none"`) | [ADR-0085] Section collapse behaviour: 'none' (always open, no toggle), 'expanded' (collapsible, starts open), 'collapsed' (collapsible, starts closed). |
707707
| **defaultExpanded** | `boolean` | optional | [DEPRECATED → collapse] true → 'expanded', false → 'collapsed'. |

‎packages/lint/src/index.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -586,10 +586,12 @@ export type {
586586
// [#27069] Related-list names: each `relatedLists` entry and field-group
587587
// `relatedList` must match an inbound lookup / master_detail relationship.
588588
// `error`, the verdict `defineStack` gives, and on the runtime publish door,
589-
// which runs no `defineStack`.
589+
// which runs no `defineStack`. [#27478] A field group's `relatedList` must
590+
// name body tables only (`related-list-group-not-body`).
590591
export {
591592
validateRelatedListRefs,
592593
RELATED_LIST_REF_UNKNOWN,
594+
RELATED_LIST_GROUP_NOT_BODY,
593595
} from './validate-related-list-refs.js';
594596
export type {
595597
RelatedListRefFinding,

‎packages/lint/src/reference-integrity-suite.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -405,6 +405,10 @@ export const REFERENCE_INTEGRITY_RULES: readonly ReferenceIntegrityRule[] = [
405405
// artifact's scope. MEASURED over the shipped object corpus: the two keys
406406
// are new and no shipped object declares either, so 0 findings; the door
407407
// controls in `runtime-gate.object-writes.test.ts` are the lit probe.
408+
// [#27478] The same member refuses a field group naming a relationship that
409+
// is not a body table (`related-list-group-not-body`), on the same children;
410+
// a write of the child that turns a placed body table into a side card is
411+
// refused too, the finding being new on the stored parent. Same census: 0.
408412
{
409413
name: 'validateRelatedListRefs',
410414
runtimeTypes: ['flow', 'object'],

‎packages/lint/src/rule-explanations.ts‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3877,6 +3877,27 @@ const RELATED_LIST_REF_UNKNOWN_EXPLANATION: RuleExplanation = {
38773877
],
38783878
};
38793879

3880+
const RELATED_LIST_GROUP_NOT_BODY_EXPLANATION: RuleExplanation = {
3881+
rule: 'related-list-group-not-body',
3882+
covers: 'which related list a field group may place',
3883+
paragraphs: [
3884+
'A field group\'s `relatedList` places one related list\'s table among the field sections of the ' +
3885+
'record\'s details column. It only POSITIONS a list that is already a body table; it does not make ' +
3886+
'one. Whether a list is a body table is decided once, by the relationship\'s own FK field on the ' +
3887+
'child: `relatedList: \'primary\'`, or a `master_detail` FK that sets `inlineEdit` and declares no ' +
3888+
'`relatedList`. Every other list is a side card in the related column (`relatedList: true` or ' +
3889+
'absent), or hidden (`relatedList: false`).',
3890+
'A group naming a side card or a hidden list draws nothing at the group, silently, so it is an ' +
3891+
'error, the verdict `defineStack` gives on `os validate` and `os build` and the runtime publish ' +
3892+
'gate gives too. A bare child name names every FK of that child, so it is refused when any of ' +
3893+
'them is not a body table. The fix is on the relationship (mark it `relatedList: \'primary\'`), ' +
3894+
'in the name (`object.field` for the one FK that is a body table), or removing the group\'s ' +
3895+
'`relatedList`.',
3896+
'Judged on the children this rule can see, the same ones `related-list-ref-unknown` judges: a ' +
3897+
'child outside the stack (another package\'s object) is not judged here.',
3898+
],
3899+
};
3900+
38803901
// ── Object-name references (`validate-object-references.ts`) ────────────────
38813902

38823903
/** The sites judged — both object-reference ids. */
@@ -5338,6 +5359,7 @@ export const RULE_EXPLANATIONS: Readonly<Record<string, RuleExplanation>> = Obje
53385359
[NAV_TARGET_UNRESOLVED_EXPLANATION.rule]: NAV_TARGET_UNRESOLVED_EXPLANATION,
53395360
[OBJECT_FIELD_REF_UNKNOWN_EXPLANATION.rule]: OBJECT_FIELD_REF_UNKNOWN_EXPLANATION,
53405361
[RELATED_LIST_REF_UNKNOWN_EXPLANATION.rule]: RELATED_LIST_REF_UNKNOWN_EXPLANATION,
5362+
[RELATED_LIST_GROUP_NOT_BODY_EXPLANATION.rule]: RELATED_LIST_GROUP_NOT_BODY_EXPLANATION,
53415363
[OBJECT_REFERENCE_UNKNOWN_EXPLANATION.rule]: OBJECT_REFERENCE_UNKNOWN_EXPLANATION,
53425364
[OBJECT_REFERENCE_UNREGISTERED_PLATFORM_EXPLANATION.rule]: OBJECT_REFERENCE_UNREGISTERED_PLATFORM_EXPLANATION,
53435365
[CAPABILITY_REFERENCE_UNKNOWN_EXPLANATION.rule]: CAPABILITY_REFERENCE_UNKNOWN_EXPLANATION,

‎packages/lint/src/runtime-gate.object-writes.test.ts‎

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -597,6 +597,7 @@ describe('the object door judges related-list names against the live universe (#
597597
fields: { title: { type: 'text' }, ...fields },
598598
});
599599
const withFk = childWith({ request_id: { type: 'lookup', reference: 'leave_request' } });
600+
const withBodyFk = childWith({ request_id: { type: 'lookup', reference: 'leave_request', relatedList: 'primary' } });
600601
const withoutFk = childWith({});
601602
const gateWith = (item: unknown, child: unknown) =>
602603
runRuntimeAuthoringRules({ type: 'object', item, context: { objects: [...STORED, child] } });
@@ -625,7 +626,36 @@ describe('the object door judges related-list names against the live universe (#
625626
expect(refused.errors.map((e) => [e.rule, e.path])).toEqual([
626627
['related-list-ref-unknown', 'objects.leave_request.fieldGroups[0].relatedList'],
627628
]);
628-
expect(gateWith(cleanObject({ fieldGroups: groups }), withFk).errors).toEqual([]);
629+
expect(gateWith(cleanObject({ fieldGroups: groups }), withBodyFk).errors).toEqual([]);
630+
});
631+
632+
// [#27478] A group only positions a BODY TABLE: the same group naming the
633+
// stored child through a plain lookup (a side card) is refused; its control
634+
// differs by the FK's `relatedList: 'primary'` alone.
635+
it('REFUSES a field group naming a side card, and publishes it once the FK is primary', () => {
636+
const groups = [{ key: 'lines', label: 'Lines', relatedList: 'leave_task' }];
637+
const refused = gateWith(cleanObject({ fieldGroups: groups }), withFk);
638+
expect(refused.errors.map((e) => [e.rule, e.path])).toEqual([
639+
['related-list-group-not-body', 'objects.leave_request.fieldGroups[0].relatedList'],
640+
]);
641+
expect(refused.errors[0].message).toContain("'leave_task.request_id' is a side card");
642+
expect(gateWith(cleanObject({ fieldGroups: groups }), withBodyFk).errors).toEqual([]);
643+
});
644+
645+
// [#27478] The other half of the same verdict: a write of the CHILD that
646+
// turns a body table a stored group places into a side card is refused too
647+
// (the finding is new on the stored parent). Rewriting the child while it
648+
// stays a body table publishes.
649+
it('REFUSES a child write that makes a placed body table a side card; keeping it primary publishes', () => {
650+
const parent = cleanObject({ fieldGroups: [{ key: 'lines', label: 'Lines', relatedList: 'leave_task' }] });
651+
const gateChild = (child: unknown) =>
652+
runRuntimeAuthoringRules({ type: 'object', item: child, context: { objects: [parent, withBodyFk] } });
653+
const refused = gateChild(childWith({ request_id: { type: 'lookup', reference: 'leave_request', relatedList: true } }));
654+
expect(refused.errors.map((e) => [e.rule, e.path])).toEqual([
655+
['related-list-group-not-body', 'objects.leave_request.fieldGroups[0].relatedList'],
656+
]);
657+
expect(gateChild(childWith({ request_id: { type: 'lookup', reference: 'leave_request', relatedList: 'primary' }, note: { type: 'text' } })).errors)
658+
.toEqual([]);
629659
});
630660

631661
it('REFUSES a child that exists nowhere: with no closure in force the context is the whole collection', () => {

0 commit comments

Comments
 (0)