Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .changeset/27478-field-group-related-list-body-tables.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
'@objectstack/spec': minor
'@objectstack/lint': minor
'@object-ui/app-shell': minor
'@object-ui/plugin-detail': minor
'@object-ui/i18n': minor
---

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

Clause-②: no (narrowing)
Metadata-protocol: narrowed

<!-- adr-0087: registered field-group-related-list-side-card-refused -->

**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.

- **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.
- **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.
- **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`.
- **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`.
- **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).
- **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.
- **`@object-ui/plugin-detail`.** Documentation only: `bodyTablePlacement` and the `isBody` option name the spec predicate.
4 changes: 2 additions & 2 deletions content/docs/references/data/object.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -315,7 +315,7 @@ const result = ApiMethod.parse(data);
| **label** | `string` | ✅ | Group display label |
| **icon** | `string` | optional | Icon name (Lucide/Material) for the group header |
| **description** | `string` | optional | Optional description shown under the group header |
| **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. |
| **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). |
| **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'` |
| **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). |
| **defaultExpanded** | `boolean` | optional | [DEPRECATED → collapse] true → 'expanded', false → 'collapsed'. |
Expand Down Expand Up @@ -701,7 +701,7 @@ External datasource binding (ADR-0015)
| **label** | `string` | ✅ | Group display label |
| **icon** | `string` | optional | Icon name (Lucide/Material) for the group header |
| **description** | `string` | optional | Optional description shown under the group header |
| **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. |
| **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). |
| **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'` |
| **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). |
| **defaultExpanded** | `boolean` | optional | [DEPRECATED → collapse] true → 'expanded', false → 'collapsed'. |
Expand Down
4 changes: 3 additions & 1 deletion packages/lint/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -586,10 +586,12 @@ export type {
// [#27069] Related-list names: each `relatedLists` entry and field-group
// `relatedList` must match an inbound lookup / master_detail relationship.
// `error`, the verdict `defineStack` gives, and on the runtime publish door,
// which runs no `defineStack`.
// which runs no `defineStack`. [#27478] A field group's `relatedList` must
// name body tables only (`related-list-group-not-body`).
export {
validateRelatedListRefs,
RELATED_LIST_REF_UNKNOWN,
RELATED_LIST_GROUP_NOT_BODY,
} from './validate-related-list-refs.js';
export type {
RelatedListRefFinding,
Expand Down
4 changes: 4 additions & 0 deletions packages/lint/src/reference-integrity-suite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,10 @@ export const REFERENCE_INTEGRITY_RULES: readonly ReferenceIntegrityRule[] = [
// artifact's scope. MEASURED over the shipped object corpus: the two keys
// are new and no shipped object declares either, so 0 findings; the door
// controls in `runtime-gate.object-writes.test.ts` are the lit probe.
// [#27478] The same member refuses a field group naming a relationship that
// is not a body table (`related-list-group-not-body`), on the same children;
// a write of the child that turns a placed body table into a side card is
// refused too, the finding being new on the stored parent. Same census: 0.
{
name: 'validateRelatedListRefs',
runtimeTypes: ['flow', 'object'],
Expand Down
22 changes: 22 additions & 0 deletions packages/lint/src/rule-explanations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3865,6 +3865,27 @@ const RELATED_LIST_REF_UNKNOWN_EXPLANATION: RuleExplanation = {
],
};

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

// ── Object-name references (`validate-object-references.ts`) ────────────────

/** The sites judged — both object-reference ids. */
Expand Down Expand Up @@ -5326,6 +5347,7 @@ export const RULE_EXPLANATIONS: Readonly<Record<string, RuleExplanation>> = Obje
[NAV_TARGET_UNRESOLVED_EXPLANATION.rule]: NAV_TARGET_UNRESOLVED_EXPLANATION,
[OBJECT_FIELD_REF_UNKNOWN_EXPLANATION.rule]: OBJECT_FIELD_REF_UNKNOWN_EXPLANATION,
[RELATED_LIST_REF_UNKNOWN_EXPLANATION.rule]: RELATED_LIST_REF_UNKNOWN_EXPLANATION,
[RELATED_LIST_GROUP_NOT_BODY_EXPLANATION.rule]: RELATED_LIST_GROUP_NOT_BODY_EXPLANATION,
[OBJECT_REFERENCE_UNKNOWN_EXPLANATION.rule]: OBJECT_REFERENCE_UNKNOWN_EXPLANATION,
[OBJECT_REFERENCE_UNREGISTERED_PLATFORM_EXPLANATION.rule]: OBJECT_REFERENCE_UNREGISTERED_PLATFORM_EXPLANATION,
[CAPABILITY_REFERENCE_UNKNOWN_EXPLANATION.rule]: CAPABILITY_REFERENCE_UNKNOWN_EXPLANATION,
Expand Down
32 changes: 31 additions & 1 deletion packages/lint/src/runtime-gate.object-writes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -597,6 +597,7 @@ describe('the object door judges related-list names against the live universe (#
fields: { title: { type: 'text' }, ...fields },
});
const withFk = childWith({ request_id: { type: 'lookup', reference: 'leave_request' } });
const withBodyFk = childWith({ request_id: { type: 'lookup', reference: 'leave_request', relatedList: 'primary' } });
const withoutFk = childWith({});
const gateWith = (item: unknown, child: unknown) =>
runRuntimeAuthoringRules({ type: 'object', item, context: { objects: [...STORED, child] } });
Expand Down Expand Up @@ -625,7 +626,36 @@ describe('the object door judges related-list names against the live universe (#
expect(refused.errors.map((e) => [e.rule, e.path])).toEqual([
['related-list-ref-unknown', 'objects.leave_request.fieldGroups[0].relatedList'],
]);
expect(gateWith(cleanObject({ fieldGroups: groups }), withFk).errors).toEqual([]);
expect(gateWith(cleanObject({ fieldGroups: groups }), withBodyFk).errors).toEqual([]);
});

// [#27478] A group only positions a BODY TABLE: the same group naming the
// stored child through a plain lookup (a side card) is refused; its control
// differs by the FK's `relatedList: 'primary'` alone.
it('REFUSES a field group naming a side card, and publishes it once the FK is primary', () => {
const groups = [{ key: 'lines', label: 'Lines', relatedList: 'leave_task' }];
const refused = gateWith(cleanObject({ fieldGroups: groups }), withFk);
expect(refused.errors.map((e) => [e.rule, e.path])).toEqual([
['related-list-group-not-body', 'objects.leave_request.fieldGroups[0].relatedList'],
]);
expect(refused.errors[0].message).toContain("'leave_task.request_id' is a side card");
expect(gateWith(cleanObject({ fieldGroups: groups }), withBodyFk).errors).toEqual([]);
});

// [#27478] The other half of the same verdict: a write of the CHILD that
// turns a body table a stored group places into a side card is refused too
// (the finding is new on the stored parent). Rewriting the child while it
// stays a body table publishes.
it('REFUSES a child write that makes a placed body table a side card; keeping it primary publishes', () => {
const parent = cleanObject({ fieldGroups: [{ key: 'lines', label: 'Lines', relatedList: 'leave_task' }] });
const gateChild = (child: unknown) =>
runRuntimeAuthoringRules({ type: 'object', item: child, context: { objects: [parent, withBodyFk] } });
const refused = gateChild(childWith({ request_id: { type: 'lookup', reference: 'leave_request', relatedList: true } }));
expect(refused.errors.map((e) => [e.rule, e.path])).toEqual([
['related-list-group-not-body', 'objects.leave_request.fieldGroups[0].relatedList'],
]);
expect(gateChild(childWith({ request_id: { type: 'lookup', reference: 'leave_request', relatedList: 'primary' }, note: { type: 'text' } })).errors)
.toEqual([]);
});

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