From 99614da2e2758ba6811369fe4072fb535458419f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 01:34:11 +0000 Subject: [PATCH 1/5] docs(spec): related-list describes say what is refused; primary list and trackHistory describe the v3 record page relatedLists and fieldGroups[i].relatedList now state the enforced boundary: a stack child with no inbound FK is refused everywhere; a child that exists nowhere is refused by defineStack 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: 'primary' is a body table in the details column (inside the Details tab when tabbed, with no tab of its own), and enable.trackHistory feeds the record's one timeline stream (its Changes filter) instead of a History tab. Claude-Session: https://claude.ai/code/session_01WGufzgaGBuahtvHVCAa4ny Co-Authored-By: Claude --- content/docs/ui/pages.mdx | 20 +-- packages/spec/src/data/field.zod.ts | 25 ++-- packages/spec/src/data/object.zod.ts | 42 ++++-- .../src/data/record-page-describes.test.ts | 121 ++++++++++++++++++ .../metadata-form-zod-reconciliation.test.ts | 2 +- 5 files changed, 179 insertions(+), 31 deletions(-) create mode 100644 packages/spec/src/data/record-page-describes.test.ts diff --git a/content/docs/ui/pages.mdx b/content/docs/ui/pages.mdx index f3f02dd26c0..d75c3bb344e 100644 --- a/content/docs/ui/pages.mdx +++ b/content/docs/ui/pages.mdx @@ -180,17 +180,21 @@ replacement. ### The synthesized default A record page needs no authoring. With no page assigned, the Console synthesizes one from -the object: a header, the highlights strip, a tab strip and the discussion panel. The strip -is **Details**, then one tab per relationship whose child field declares -`relatedList: 'primary'`, then a shared **Related** tab for the other related lists, then -**History**; an object with `enable: { files: true }` also gets an **Attachments** tab with a -count badge. Promoting a child table to its own tab is therefore a one-word change on the -relationship field, not a page-authoring task, and `relatedList: false` leaves a child off -the page. +the object: a header, the highlights strip and a tab strip of **Details**, **Timeline** and +**Related**, plus **Approvals** when the record has approval requests. A relationship whose +child field declares `relatedList: 'primary'` is part of the record's body: its list is a +full-width table inside **Details**, after the first field section, with no tab of its own. +The other related lists are cards in the shared **Related** tab, which an object with +`enable: { files: true }` closes with its attachments. **Timeline** is one stream: the +discussion composer on top, then the discussion, the activity and, with +`enable: { trackHistory: true }`, the record's field changes, filtered by All / Activity / +Discussion / Changes; there is no separate History tab. Moving a child table into the +record's body is therefore a one-word change on the relationship field, not a +page-authoring task, and `relatedList: false` leaves a child off the page. The Console lays the page out by the width of the surface it occupies (the drawer, the split pane or the full page), never by the viewport. From 1280 px the tabs become three -columns: details, a timeline (the discussion composer, then Activity and History) and the +columns: details, the timeline stream and the related lists as cards with Approvals, Attachments and the created/updated line. From 1024 px the related column folds into a rail behind a **Related (N)** toggle. Below that, and always in a drawer or split pane, the tab strip is drawn. `?tab=` selects a tab in the strip. diff --git a/packages/spec/src/data/field.zod.ts b/packages/spec/src/data/field.zod.ts index 76aa6a2bd0f..898afae1ce5 100644 --- a/packages/spec/src/data/field.zod.ts +++ b/packages/spec/src/data/field.zod.ts @@ -1734,17 +1734,20 @@ export const FieldSchema = lazySchema(() => { * Tri-state (ADR-0085 semantic-role style — a statement about the * relationship, not a layout switch): * - `false` → suppress this child from the parent's detail page. - * - `true` / absent → shown as a supporting list: a side card beside the - * record (under the tabbed page, the shared "Related" - * tab). There is no count-based auto-split. + * - `true` / absent → shown as a supporting list: a side card in the + * record's related column (under the tabbed page, the + * shared "Related" tab). There is no count-based + * auto-split. * - `'primary'` → part of the record's MAIN CONTENT (#27069): drawn in - * the record's body — the details column, after the - * field sections — as a full-width table, not as a side - * card (under the tabbed page, its own tab). A field - * group's `relatedList` can place it among the field - * sections instead. `'primary'` no longer ORDERS lists: - * the parent's `ObjectSchema.relatedLists` does, among - * the body tables and among the side cards alike. + * the record's body — the details column, after its + * first field section — as a full-width table, not as + * a side card (under the tabbed page, inside the + * Details tab: a primary list gets no tab of its + * own). A field group's `relatedList` can place it + * among the field sections instead. `'primary'` no + * longer ORDERS lists: the parent's + * `ObjectSchema.relatedLists` does, among the body + * tables and among the side cards alike. * * Absent on a `master_detail` field that sets `inlineEdit`, the list is * `'primary'` by default: line items edited inside the parent's form are part @@ -1762,7 +1765,7 @@ export const FieldSchema = lazySchema(() => { * A child object with no list-view sort emits no ordering parameter at all, * and rows fall back to record-id order. */ - relatedList: z.union([z.boolean(), z.literal('primary')]).optional().describe('Show this child collection as a related list on the parent\'s detail page (read-side mirror of inlineEdit). false = suppress; true/absent = shown as a supporting list, a side card beside the record (the shared "Related" tab on the tabbed page); \'primary\' = part of the record\'s main content, drawn in the record\'s body (the details column, after the field sections) as a full-width table (its own tab on the tabbed page). Absent on a master_detail field that sets inlineEdit, the list is \'primary\' by default; write true to keep it a side card. \'primary\' places a list and does not order it: the parent object\'s relatedLists orders the body tables and the side cards (ADR-0085). A derived related list (relatedList: \'primary\') inherits its row order from the child object\'s DEFAULT list view sort — the isDefault view, or the first declared list item when none is marked default; wire spelling is sort= / sort=-, never $orderby. A child object with no list-view sort emits no ordering parameter, and rows fall back to record-id order.'), + relatedList: z.union([z.boolean(), z.literal('primary')]).optional().describe('Show this child collection as a related list on the parent\'s detail page (read-side mirror of inlineEdit). false = suppress; true/absent = shown as a supporting list, a side card in the record\'s related column (the shared "Related" tab on the tabbed page); \'primary\' = part of the record\'s main content, drawn in the record\'s body (the details column, after its first field section) as a full-width table (inside the Details tab on the tabbed page, with no tab of its own). Absent on a master_detail field that sets inlineEdit, the list is \'primary\' by default; write true to keep it a side card. \'primary\' places a list and does not order it: the parent object\'s relatedLists orders the body tables and the side cards (ADR-0085). A derived related list (relatedList: \'primary\') inherits its row order from the child object\'s DEFAULT list view sort — the isDefault view, or the first declared list item when none is marked default; wire spelling is sort= / sort=-, never $orderby. A child object with no list-view sort emits no ordering parameter, and rows fall back to record-id order.'), /** Optional section title for the detail-page related list (defaults to the child object label). */ relatedListTitle: z.string().optional().describe('Title for the detail-page related list'), /** diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index d1113ead737..01b9356f048 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -262,14 +262,19 @@ export const ObjectCapabilities = strictObject({ /** * History tracking (Audit Trail) master switch — opt-in. * - * Contract: `true` surfaces the record History tab (audit-trail UI) in the - * console. Pair with per-field `trackHistory: true` to select which field + * Contract: `true` surfaces the record's field changes (audit-trail UI) in + * the console. On the synthesized record page they join the record's one + * timeline stream, beside its discussion and activity, under the stream's + * Changes filter; that page has no separate History tab. A page an author + * assigns is drawn as authored: it shows changes where it places a + * `record:history` block, which reads the record's own activity, not this + * flag. Pair with per-field `trackHistory: true` to select which field * diffs render as human-readable timeline summaries (ADR-0052 §5b). Audit * *capture* into `sys_audit_log` is a compliance ledger and stays on * regardless of this flag; retention is governed by data lifecycle * (ADR-0057), not by hiding the UI. */ - trackHistory: z.boolean().default(false).describe('Show the record History tab (audit-trail UI). Pair with per-field trackHistory to pick which field diffs are summarized; audit capture itself is always on for compliance'), + trackHistory: z.boolean().default(false).describe('Show the record\'s field changes (audit-trail UI): on the default record page they join the record\'s one timeline stream, under its Changes filter. An authored page shows changes only where it places a record:history block. Pair with per-field trackHistory to pick which field diffs are summarized; audit capture itself is always on for compliance'), /** Enable global search indexing */ searchable: z.boolean().default(true).describe('Index records for global search'), @@ -1240,14 +1245,17 @@ export const ObjectFieldGroupSchema = lazySchema(() => strictObject({ * items can sit under its basic information with its terms after them. * * Such a group carries no fields: a field whose `group` names it is refused - * at parse. A name that matches no inbound relationship is refused at - * `defineStack`, as a `relatedLists` entry is. Body tables no group places + * at parse. Its name is judged exactly as a `relatedLists` entry's is (see + * there): a child in the stack with no inbound relationship is refused + * everywhere; a child that exists nowhere is refused at `defineStack` and by + * a metadata write that names no package, and a write into a package stores + * it, placing nothing. Body tables no group places * keep the record page's default position. `relatedList: false` on the FK * still hides the table. */ relatedList: z.string().regex(RELATED_LIST_ENTRY_PATTERN, { message: 'A field group\'s relatedList is a child object name (e.g. "task") or object.field (e.g. "event.related_to"), in snake_case', - }).optional().describe('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, and so is a name that matches no inbound lookup / master_detail field. relatedList: false on the relationship still hides the table.').meta({ title: 'Related List' }), + }).optional().describe('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, 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.').meta({ title: 'Related List' }), /** * [ADR-0085 §5] Section visibility predicate — CEL, the ADR-0089 canonical @@ -3042,10 +3050,22 @@ const ObjectSchemaBase = strictObject( * one follows in the derived order (owned `master_detail` children, then * `lookup` children, each in declaration order). * - * Strict names: an entry that matches no inbound `lookup` / `master_detail` - * field of this object is refused at `defineStack` (the cross-object check, - * where every child's fields are known), naming the entry and the inbound - * relationships that exist. The shape alone is judged at parse. + * Names are judged where the children are known (the shape alone is judged + * at parse), and each refusal names the entry and the inbound relationships + * that exist: + * + * - a child that IS in the stack but has no inbound `lookup` / + * `master_detail` field to this object is refused everywhere: at + * `defineStack` (`os validate`, `os build`) and at the runtime metadata + * write door; + * - a child that exists nowhere is refused at `defineStack` and by a + * metadata write that names no package (judged against the whole + * collection the host serves). A write into a package judges only that + * package's closure, and a child outside it may be a downstream + * package's object that points in, so the entry is stored, and the + * record page places nothing for it. The boundary is deliberate: the + * `validate-related-list-refs` rule's header in `@objectstack/lint` + * carries the reasoning. * * It orders; it does not show or hide. `relatedList: false` on the FK still * hides a listed relationship, and a FK's `relatedList: 'primary'` still @@ -3059,7 +3079,7 @@ const ObjectSchemaBase = strictObject( z.string().regex(RELATED_LIST_ENTRY_PATTERN, { message: 'A relatedLists entry is a child object name (e.g. "task") or object.field (e.g. "event.related_to"), in snake_case', }), - ).optional().describe('Business order of this object\'s related lists on its record page, most important first. Each entry is a child object name (e.g. "task"), or object.field when that child has more than one relationship to this object (e.g. "event.related_to"). Listed relationships come first in this order; unlisted ones follow in the derived order (owned master_detail children, then lookup children). An entry that matches no inbound lookup / master_detail field of this object is refused. It orders only: relatedList: false on the relationship still hides a list, and relatedList: \'primary\' still places a list in the record\'s body, so the order applies among the body tables and among the side cards.'), + ).optional().describe('Business order of this object\'s related lists on its record page, most important first. Each entry is a child object name (e.g. "task"), or object.field when that child has more than one relationship to this object (e.g. "event.related_to"). Listed relationships come first in this order; unlisted ones follow in the derived order (owned master_detail children, then lookup children). An entry whose child is in the stack but has no inbound lookup / master_detail field to this object is refused everywhere. An entry whose child exists nowhere is refused by os validate / os build and by a metadata write that names no package; a write into a package stores it, since a child outside the package may be a downstream package\'s object that points here, and the record page places nothing for it. It orders only: relatedList: false on the relationship still hides a list, and relatedList: \'primary\' still places a list in the record\'s body, so the order applies among the body tables and among the side cards.'), /** * [ADR-0085] Semantic role: the field that represents the record's LINEAR * lifecycle (an ordered pipeline / stage progression). A string names the diff --git a/packages/spec/src/data/record-page-describes.test.ts b/packages/spec/src/data/record-page-describes.test.ts new file mode 100644 index 00000000000..75c4d6cf237 --- /dev/null +++ b/packages/spec/src/data/record-page-describes.test.ts @@ -0,0 +1,121 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The `.describe()` text of four record-page keys says what the runtime does, + * in the projection every reader sees (the generated references, Studio's + * inspector help, the skills): the JSON-schema `description`. + * + * - [#27311] `ObjectSchema.relatedLists` and `fieldGroups[i].relatedList` + * promise exactly the refusal that is enforced. A child that is in the stack + * with no inbound FK is refused everywhere; a child that exists nowhere is + * refused by `defineStack` and by a metadata write that names no package; a + * write into a package stores it (the `validate-related-list-refs` header in + * `@objectstack/lint` holds the reasoning). The old text promised a refusal + * with no boundary. + * - [#27312] `relatedList: 'primary'` and `enable.trackHistory` describe the + * v3 synthesized record page: a primary list is a body table in the details + * column (inside the Details tab when tabbed), and field changes join the + * record's one timeline stream. Neither key gets a tab of its own, so the + * v2 phrases ("its own tab", "History tab") must not come back. + * + * Phrases are pinned, not whole sentences, so a later rewording that keeps the + * contract stays green. + */ + +import { describe, it, expect } from 'vitest'; +import { z } from 'zod'; + +import { FieldSchema } from './field.zod'; +import { ObjectSchema } from './object.zod'; + +type JsonNode = { description?: string; properties?: Record; items?: JsonNode; $ref?: string }; +type JsonRoot = JsonNode & { $defs?: Record; definitions?: Record }; + +const project = (schema: unknown): JsonRoot => + z.toJSONSchema(schema as z.ZodType, { unrepresentable: 'any', io: 'input' }) as JsonRoot; + +/** Follow a `#/$defs/NAME` (or `#/definitions/NAME`) ref inside the same root. */ +const deref = (root: JsonRoot, node: JsonNode | undefined): JsonNode | undefined => { + if (!node?.$ref) return node; + const name = node.$ref.split('/').pop()!; + return root.$defs?.[name] ?? root.definitions?.[name]; +}; + +/** The description at a property path, refs followed; `[]` steps into an array's items. */ +const descriptionAt = (root: JsonRoot, path: string[]): string => { + let node: JsonNode | undefined = root; + for (const step of path) { + node = deref(root, node); + node = step === '[]' ? node?.items : node?.properties?.[step]; + } + const own = node?.description; + const description = own ?? deref(root, node)?.description; + expect(description, `no description at ${path.join('.')}`).toBeTypeOf('string'); + return description!; +}; + +const objectRoot = project(ObjectSchema); +const fieldRoot = project(FieldSchema); + +const V2_PHRASES = [/its own tab/i, /History tab/i]; + +describe('[#27311] relatedLists describes the refusal that is enforced', () => { + const text = descriptionAt(objectRoot, ['relatedLists']); + + it('refuses a stack child with no inbound FK everywhere', () => { + expect(text).toContain('is in the stack but has no inbound lookup / master_detail field to this object is refused everywhere'); + }); + + it('refuses a child that exists nowhere on os validate / os build and on a write that names no package', () => { + expect(text).toContain('exists nowhere is refused by os validate / os build and by a metadata write that names no package'); + }); + + it('says a write into a package stores the entry and the page places nothing', () => { + expect(text).toContain('a write into a package stores it'); + expect(text).toContain('the record page places nothing for it'); + }); + + it('no longer promises an unbounded refusal', () => { + expect(text).not.toContain('An entry that matches no inbound lookup / master_detail field of this object is refused.'); + }); +}); + +describe('[#27311] fieldGroups[i].relatedList is judged as a relatedLists entry is', () => { + const text = descriptionAt(objectRoot, ['fieldGroups', '[]', 'relatedList']); + + it('names the same two refusals and the package-write boundary', () => { + expect(text).toContain('The name is judged as a relatedLists entry is'); + expect(text).toContain('in the stack with no inbound lookup / master_detail field, or exists nowhere'); + expect(text).toContain('a write into a package stores it'); + }); + + it('no longer promises an unbounded refusal', () => { + expect(text).not.toContain('and so is a name that matches no inbound lookup / master_detail field'); + }); +}); + +describe('[#27312] relatedList: \'primary\' describes the v3 body table', () => { + const text = descriptionAt(fieldRoot, ['relatedList']); + + it('places a primary list in the details column, inside the Details tab when tabbed', () => { + expect(text).toContain('the details column, after its first field section'); + expect(text).toContain('inside the Details tab on the tabbed page, with no tab of its own'); + }); + + it('carries no v2 tab phrase', () => { + for (const phrase of V2_PHRASES) expect(text).not.toMatch(phrase); + }); +}); + +describe('[#27312] enable.trackHistory describes the one timeline stream', () => { + const text = descriptionAt(objectRoot, ['enable', 'trackHistory']); + + it('sends field changes into the record\'s timeline stream', () => { + expect(text).toContain('join the record\'s one timeline stream, under its Changes filter'); + expect(text).toContain('An authored page shows changes only where it places a record:history block'); + }); + + it('carries no v2 tab phrase', () => { + for (const phrase of V2_PHRASES) expect(text).not.toMatch(phrase); + }); +}); diff --git a/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts b/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts index 8d23c62903d..437e3b2f7c3 100644 --- a/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts +++ b/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts @@ -584,7 +584,7 @@ const LEDGER: ReadonlyArray = [ type: 'field', path: ROOT_PATH, key: 'relatedList', - why: "no registered widget spells this union (ruling record 5861442317): `true` / `false` or `'primary'`, and a boolean cannot be an option value while a union renders only its first arm on create, so a control would be a switch that never promotes the list to its own tab (`'primary'`)", + why: "no registered widget spells this union (ruling record 5861442317): `true` / `false` or `'primary'`, and a boolean cannot be an option value while a union renders only its first arm on create, so a control would be a switch that never places the list in the record's body (`'primary'`)", }, // Measured, and deliberately NOT recorded: seven `view` keys with no From e4e06533f1b596c975eaec3ae6287d4b26bfd934 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 01:39:53 +0000 Subject: [PATCH 2/5] docs(spec): regenerate references; trackHistory liveness evidence names the timeline stream; changeset Claude-Session: https://claude.ai/code/session_01WGufzgaGBuahtvHVCAa4ny Co-Authored-By: Claude --- .changeset/27311-27312-record-page-describes.md | 16 ++++++++++++++++ content/docs/references/api/metadata.mdx | 2 +- content/docs/references/data/field.mdx | 2 +- content/docs/references/data/object.mdx | 14 +++++++------- content/docs/references/system/migration.mdx | 8 ++++---- packages/spec/liveness/object.json | 2 +- 6 files changed, 30 insertions(+), 14 deletions(-) create mode 100644 .changeset/27311-27312-record-page-describes.md diff --git a/.changeset/27311-27312-record-page-describes.md b/.changeset/27311-27312-record-page-describes.md new file mode 100644 index 00000000000..7124055b8eb --- /dev/null +++ b/.changeset/27311-27312-record-page-describes.md @@ -0,0 +1,16 @@ +--- +'@objectstack/spec': patch +--- + +docs(spec): the related-list descriptions say which unknown names are refused, and `relatedList: 'primary'` and `enable.trackHistory` describe the current record page + +Clause-②: no +Metadata-protocol: none + +Description text only: no key, type, default, accepted value or export moves, and every stack that parsed, validated or published before still does. + +- **`ObjectSchema.relatedLists` and `fieldGroups[i].relatedList`.** The descriptions promised that a name matching no inbound `lookup` / `master_detail` relationship is refused. They now say exactly where. A name whose child is in the stack but has no such relationship to this object is refused everywhere. A name whose child exists nowhere is refused by `os validate` / `os build` and by a metadata write that names no package. A write into a package stores it, because a child outside that package may be a downstream package's object that points in; the record page places nothing for it. +- **`relatedList: 'primary'`.** A primary list is a full-width table in the record's details column, after the first field section, and inside the Details tab when the page is tabbed. It no longer gets a tab of its own; a supporting list is a card in the related column (the shared Related tab when tabbed). +- **`enable.trackHistory`.** The record's field changes join its one timeline stream, under the Changes filter, instead of a History tab. A page an author assigns shows changes where it places a `record:history` block. + +What to do: nothing. diff --git a/content/docs/references/api/metadata.mdx b/content/docs/references/api/metadata.mdx index 0fdbe25d695..8ce8e726cf1 100644 --- a/content/docs/references/api/metadata.mdx +++ b/content/docs/references/api/metadata.mdx @@ -958,7 +958,7 @@ Metadata query with filtering, sorting, and pagination | **titleFormat** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → nameField (ADR-0079)] Render-only title template; the server cannot return or query it, and an explicit nameField now takes precedence. Migrate a single-field title to nameField; a composite title that import, sort or search must match to a stored computed field (a non-formula field with an expression, computed and stored on every write) designated as nameField — a text formula designated as nameField stays legal for a title shown for display only. Placeholders may be written `{{field}}` or `{field}` — the title renderers treat the two as equivalent, normalizing `{{field}}` to `{field}` before substituting; neither spelling is judged at parse time. | | **imageField** | `string` | optional | The record's picture: names the field the record page header (record chrome) draws beside the title — one object-level declaration every record detail page reads, not a per-page header prop. Must name a field of this object whose type is `image` or `avatar`; any other name is refused. A record whose field is empty shows no picture (no initials or placeholder). | | **highlightFields** | `string[]` | optional | [ADR-0085] Ordered most-important fields; first entry wins where only one fits. Drives default columns, cards, previews, detail highlight strip. Renamed from compactLayout. | -| **relatedLists** | `string[]` | optional | Business order of this object's related lists on its record page, most important first. Each entry is a child object name (e.g. "task"), or object.field when that child has more than one relationship to this object (e.g. "event.related_to"). Listed relationships come first in this order; unlisted ones follow in the derived order (owned master_detail children, then lookup children). An entry that matches no inbound lookup / master_detail field of this object is refused. It orders only: relatedList: false on the relationship still hides a list, and relatedList: 'primary' still places a list in the record's body, so the order applies among the body tables and among the side cards. | +| **relatedLists** | `string[]` | optional | Business order of this object's related lists on its record page, most important first. Each entry is a child object name (e.g. "task"), or object.field when that child has more than one relationship to this object (e.g. "event.related_to"). Listed relationships come first in this order; unlisted ones follow in the derived order (owned master_detail children, then lookup children). An entry whose child is in the stack but has no inbound lookup / master_detail field to this object is refused everywhere. An entry whose child exists nowhere is refused by os validate / os build and by a metadata write that names no package; a write into a package stores it, since a child outside the package may be a downstream package's object that points here, and the record page places nothing for it. It orders only: relatedList: false on the relationship still hides a list, and relatedList: 'primary' still places a list in the record's body, so the order applies among the body tables and among the side cards. | | **stageField** | `string \| false` | optional | [ADR-0085] Lifecycle stage field (linear/ordered), or false to declare the status field non-linear and suppress stage heuristics. Absent = heuristic detection allowed. When the named field carries options, it must be a single-value `select` / `radio` with inline `options`, each declaring its `kind` ('open' / 'won' / 'lost'); a picklist-backed or multi-value stage field is refused. A name that resolves to no declared field, or a field with no options, is not judged at parse. | | **editMode** | `Enum<'modal' \| 'page'>` | optional | Edit-interaction intent for records of this object: 'modal' opens the edit form as a dialog over the current view; 'page' navigates to a dedicated full-page edit route. Absent = the renderer picks its own default (objectui defaults to modal). Cross-renderer intent, not pixel styling (family). | | **listViews** | `Record; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>; data?: object \| object \| object; … }>` | optional | Built-in named list views (segmented tabs) shipped with the object schema — "views" mode, dropdown userFilters allowed, no page-only tabs (ADR-0047) | diff --git a/content/docs/references/data/field.mdx b/content/docs/references/data/field.mdx index 34160966776..9c3cd88f109 100644 --- a/content/docs/references/data/field.mdx +++ b/content/docs/references/data/field.mdx @@ -85,7 +85,7 @@ const result = CurrencyConfigSchema.parse(data); | **inlineTitle** | `string` | optional | Title for the inline master-detail grid | | **inlineColumns** | `{ name: string; label?: string; type?: Enum<'text' \| 'number' \| 'currency' \| 'date' \| 'datetime' \| 'time' \| 'select' \| 'lookup' \| 'file'>; width?: number; … }[]` | optional | Explicit columns for the inline grid (derived from the child object when omitted). Each entry is a strict, name-keyed column (`{ name, label?, type?, … }` — objectui GridColumn); identity-only entries (`{ name }`) hydrate everything else from the child object's fields. Unknown keys and the retired `field` spelling are refused at parse. | | **inlineAmountField** | `string` | optional | Numeric child field summed for the inline grid total | -| **relatedList** | `boolean \| 'primary'` | optional | Show this child collection as a related list on the parent's detail page (read-side mirror of inlineEdit). false = suppress; true/absent = shown as a supporting list, a side card beside the record (the shared "Related" tab on the tabbed page); 'primary' = part of the record's main content, drawn in the record's body (the details column, after the field sections) as a full-width table (its own tab on the tabbed page). Absent on a master_detail field that sets inlineEdit, the list is 'primary' by default; write true to keep it a side card. 'primary' places a list and does not order it: the parent object's relatedLists orders the body tables and the side cards (ADR-0085). A derived related list (relatedList: 'primary') inherits its row order from the child object's DEFAULT list view sort — the isDefault view, or the first declared list item when none is marked default; wire spelling is sort=`` / sort=-``, never $orderby. A child object with no list-view sort emits no ordering parameter, and rows fall back to record-id order. | +| **relatedList** | `boolean \| 'primary'` | optional | Show this child collection as a related list on the parent's detail page (read-side mirror of inlineEdit). false = suppress; true/absent = shown as a supporting list, a side card in the record's related column (the shared "Related" tab on the tabbed page); 'primary' = part of the record's main content, drawn in the record's body (the details column, after its first field section) as a full-width table (inside the Details tab on the tabbed page, with no tab of its own). Absent on a master_detail field that sets inlineEdit, the list is 'primary' by default; write true to keep it a side card. 'primary' places a list and does not order it: the parent object's relatedLists orders the body tables and the side cards (ADR-0085). A derived related list (relatedList: 'primary') inherits its row order from the child object's DEFAULT list view sort — the isDefault view, or the first declared list item when none is marked default; wire spelling is sort=`` / sort=-``, never $orderby. A child object with no list-view sort emits no ordering parameter, and rows fall back to record-id order. | | **relatedListTitle** | `string` | optional | Title for the detail-page related list | | **relatedListColumns** | `string[]` | optional | Explicit columns for the detail-page related list, as child field names (e.g. ['name', 'status']); derived from the child object (highlightFields → field walk) when omitted. Strings only — labels, cell types and formatting always derive from the child object's field definitions; column objects are refused at parse. | | **relatedListFilter** | `any` | optional | Declarative default filter for the detail-page related list: AND-composed with the parent-relationship condition `{ [referenceField]: parentId }` — an authored constraint, never a user-editable suggestion. The related-list tab badge count honors the same composed filter, so counts match the visible rows. Canonical Query-DSL FilterCondition (the same dialect as a query `where`), e.g. `{ status: { $ne: 'deleted' } }` to hide soft-deleted children. | diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index 86c559d0301..61bfe72957a 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -165,7 +165,7 @@ const result = ApiMethod.parse(data); | **titleFormat** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → nameField (ADR-0079)] Render-only title template; the server cannot return or query it, and an explicit nameField now takes precedence. Migrate a single-field title to nameField; a composite title that import, sort or search must match to a stored computed field (a non-formula field with an expression, computed and stored on every write) designated as nameField — a text formula designated as nameField stays legal for a title shown for display only. Placeholders may be written `{{field}}` or `{field}` — the title renderers treat the two as equivalent, normalizing `{{field}}` to `{field}` before substituting; neither spelling is judged at parse time. | | **imageField** | `string` | optional | The record's picture: names the field the record page header (record chrome) draws beside the title — one object-level declaration every record detail page reads, not a per-page header prop. Must name a field of this object whose type is `image` or `avatar`; any other name is refused. A record whose field is empty shows no picture (no initials or placeholder). | | **highlightFields** | `string[]` | optional | [ADR-0085] Ordered most-important fields; first entry wins where only one fits. Drives default columns, cards, previews, detail highlight strip. Renamed from compactLayout. | -| **relatedLists** | `string[]` | optional | Business order of this object's related lists on its record page, most important first. Each entry is a child object name (e.g. "task"), or object.field when that child has more than one relationship to this object (e.g. "event.related_to"). Listed relationships come first in this order; unlisted ones follow in the derived order (owned master_detail children, then lookup children). An entry that matches no inbound lookup / master_detail field of this object is refused. It orders only: relatedList: false on the relationship still hides a list, and relatedList: 'primary' still places a list in the record's body, so the order applies among the body tables and among the side cards. | +| **relatedLists** | `string[]` | optional | Business order of this object's related lists on its record page, most important first. Each entry is a child object name (e.g. "task"), or object.field when that child has more than one relationship to this object (e.g. "event.related_to"). Listed relationships come first in this order; unlisted ones follow in the derived order (owned master_detail children, then lookup children). An entry whose child is in the stack but has no inbound lookup / master_detail field to this object is refused everywhere. An entry whose child exists nowhere is refused by os validate / os build and by a metadata write that names no package; a write into a package stores it, since a child outside the package may be a downstream package's object that points here, and the record page places nothing for it. It orders only: relatedList: false on the relationship still hides a list, and relatedList: 'primary' still places a list in the record's body, so the order applies among the body tables and among the side cards. | | **stageField** | `string \| false` | optional | [ADR-0085] Lifecycle stage field (linear/ordered), or false to declare the status field non-linear and suppress stage heuristics. Absent = heuristic detection allowed. When the named field carries options, it must be a single-value `select` / `radio` with inline `options`, each declaring its `kind` ('open' / 'won' / 'lost'); a picklist-backed or multi-value stage field is refused. A name that resolves to no declared field, or a field with no options, is not judged at parse. | | **editMode** | `Enum<'modal' \| 'page'>` | optional | Edit-interaction intent for records of this object: 'modal' opens the edit form as a dialog over the current view; 'page' navigates to a dedicated full-page edit route. Absent = the renderer picks its own default (objectui defaults to modal). Cross-renderer intent, not pixel styling (family). | | **listViews** | `Record; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>; data?: object \| object \| object; … }>` | optional | Built-in named list views (segmented tabs) shipped with the object schema — "views" mode, dropdown userFilters allowed, no page-only tabs (ADR-0047) | @@ -252,7 +252,7 @@ const result = ApiMethod.parse(data); | **inlineTitle** | `string` | optional | Title for the inline master-detail grid | | **inlineColumns** | `{ name: string; label?: string; type?: Enum<'text' \| 'number' \| 'currency' \| 'date' \| 'datetime' \| 'time' \| 'select' \| 'lookup' \| 'file'>; width?: number; … }[]` | optional | Explicit columns for the inline grid (derived from the child object when omitted). Each entry is a strict, name-keyed column (`{ name, label?, type?, … }` — objectui GridColumn); identity-only entries (`{ name }`) hydrate everything else from the child object's fields. Unknown keys and the retired `field` spelling are refused at parse. | | **inlineAmountField** | `string` | optional | Numeric child field summed for the inline grid total | -| **relatedList** | `boolean \| 'primary'` | optional | Show this child collection as a related list on the parent's detail page (read-side mirror of inlineEdit). false = suppress; true/absent = shown as a supporting list, a side card beside the record (the shared "Related" tab on the tabbed page); 'primary' = part of the record's main content, drawn in the record's body (the details column, after the field sections) as a full-width table (its own tab on the tabbed page). Absent on a master_detail field that sets inlineEdit, the list is 'primary' by default; write true to keep it a side card. 'primary' places a list and does not order it: the parent object's relatedLists orders the body tables and the side cards (ADR-0085). A derived related list (relatedList: 'primary') inherits its row order from the child object's DEFAULT list view sort — the isDefault view, or the first declared list item when none is marked default; wire spelling is sort=`` / sort=-``, never $orderby. A child object with no list-view sort emits no ordering parameter, and rows fall back to record-id order. | +| **relatedList** | `boolean \| 'primary'` | optional | Show this child collection as a related list on the parent's detail page (read-side mirror of inlineEdit). false = suppress; true/absent = shown as a supporting list, a side card in the record's related column (the shared "Related" tab on the tabbed page); 'primary' = part of the record's main content, drawn in the record's body (the details column, after its first field section) as a full-width table (inside the Details tab on the tabbed page, with no tab of its own). Absent on a master_detail field that sets inlineEdit, the list is 'primary' by default; write true to keep it a side card. 'primary' places a list and does not order it: the parent object's relatedLists orders the body tables and the side cards (ADR-0085). A derived related list (relatedList: 'primary') inherits its row order from the child object's DEFAULT list view sort — the isDefault view, or the first declared list item when none is marked default; wire spelling is sort=`` / sort=-``, never $orderby. A child object with no list-view sort emits no ordering parameter, and rows fall back to record-id order. | | **relatedListTitle** | `string` | optional | Title for the detail-page related list | | **relatedListColumns** | `string[]` | optional | Explicit columns for the detail-page related list, as child field names (e.g. ['name', 'status']); derived from the child object (highlightFields → field walk) when omitted. Strings only — labels, cell types and formatting always derive from the child object's field definitions; column objects are refused at parse. | | **relatedListFilter** | `any` | optional | Declarative default filter for the detail-page related list: AND-composed with the parent-relationship condition `{ [referenceField]: parentId }` — an authored constraint, never a user-editable suggestion. The related-list tab badge count honors the same composed filter, so counts match the visible rows. Canonical Query-DSL FilterCondition (the same dialect as a query `where`), e.g. `{ status: { $ne: 'deleted' } }` to hide soft-deleted children. | @@ -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, and so is a name that matches no inbound lookup / master_detail field. 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, 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. | | **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'. | @@ -419,7 +419,7 @@ const result = ApiMethod.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **trackHistory** | `boolean` | optional (default: `false`) | Show the record History tab (audit-trail UI). Pair with per-field trackHistory to pick which field diffs are summarized; audit capture itself is always on for compliance | +| **trackHistory** | `boolean` | optional (default: `false`) | Show the record's field changes (audit-trail UI): on the default record page they join the record's one timeline stream, under its Changes filter. An authored page shows changes only where it places a record:history block. Pair with per-field trackHistory to pick which field diffs are summarized; audit capture itself is always on for compliance | | **searchable** | `boolean` | optional (default: `true`) | Index records for global search | | **apiEnabled** | `boolean` | optional (default: `true`) | Expose object via automatic APIs | | **apiMethods** | `Enum<'get' \| 'list' \| 'create' \| 'update' \| 'delete' \| 'bulk'>[]` | optional | Whitelist of allowed API operations (six primitives; undefined = all, [] = none) | @@ -550,7 +550,7 @@ const result = ApiMethod.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **trackHistory** | `boolean` | optional (default: `false`) | Show the record History tab (audit-trail UI). Pair with per-field trackHistory to pick which field diffs are summarized; audit capture itself is always on for compliance | +| **trackHistory** | `boolean` | optional (default: `false`) | Show the record's field changes (audit-trail UI): on the default record page they join the record's one timeline stream, under its Changes filter. An authored page shows changes only where it places a record:history block. Pair with per-field trackHistory to pick which field diffs are summarized; audit capture itself is always on for compliance | | **searchable** | `boolean` | optional (default: `true`) | Index records for global search | | **apiEnabled** | `boolean` | optional (default: `true`) | Expose object via automatic APIs | | **apiMethods** | `Enum<'get' \| 'list' \| 'create' \| 'update' \| 'delete' \| 'bulk'>[]` | optional | Whitelist of allowed API operations (six primitives; undefined = all, [] = none) | @@ -615,7 +615,7 @@ const result = ApiMethod.parse(data); | **inlineTitle** | `string` | optional | Title for the inline master-detail grid | | **inlineColumns** | `{ name: string; label?: string; type?: Enum<'text' \| 'number' \| 'currency' \| 'date' \| 'datetime' \| 'time' \| 'select' \| 'lookup' \| 'file'>; width?: number; … }[]` | optional | Explicit columns for the inline grid (derived from the child object when omitted). Each entry is a strict, name-keyed column (`{ name, label?, type?, … }` — objectui GridColumn); identity-only entries (`{ name }`) hydrate everything else from the child object's fields. Unknown keys and the retired `field` spelling are refused at parse. | | **inlineAmountField** | `string` | optional | Numeric child field summed for the inline grid total | -| **relatedList** | `boolean \| 'primary'` | optional | Show this child collection as a related list on the parent's detail page (read-side mirror of inlineEdit). false = suppress; true/absent = shown as a supporting list, a side card beside the record (the shared "Related" tab on the tabbed page); 'primary' = part of the record's main content, drawn in the record's body (the details column, after the field sections) as a full-width table (its own tab on the tabbed page). Absent on a master_detail field that sets inlineEdit, the list is 'primary' by default; write true to keep it a side card. 'primary' places a list and does not order it: the parent object's relatedLists orders the body tables and the side cards (ADR-0085). A derived related list (relatedList: 'primary') inherits its row order from the child object's DEFAULT list view sort — the isDefault view, or the first declared list item when none is marked default; wire spelling is sort=`` / sort=-``, never $orderby. A child object with no list-view sort emits no ordering parameter, and rows fall back to record-id order. | +| **relatedList** | `boolean \| 'primary'` | optional | Show this child collection as a related list on the parent's detail page (read-side mirror of inlineEdit). false = suppress; true/absent = shown as a supporting list, a side card in the record's related column (the shared "Related" tab on the tabbed page); 'primary' = part of the record's main content, drawn in the record's body (the details column, after its first field section) as a full-width table (inside the Details tab on the tabbed page, with no tab of its own). Absent on a master_detail field that sets inlineEdit, the list is 'primary' by default; write true to keep it a side card. 'primary' places a list and does not order it: the parent object's relatedLists orders the body tables and the side cards (ADR-0085). A derived related list (relatedList: 'primary') inherits its row order from the child object's DEFAULT list view sort — the isDefault view, or the first declared list item when none is marked default; wire spelling is sort=`` / sort=-``, never $orderby. A child object with no list-view sort emits no ordering parameter, and rows fall back to record-id order. | | **relatedListTitle** | `string` | optional | Title for the detail-page related list | | **relatedListColumns** | `string[]` | optional | Explicit columns for the detail-page related list, as child field names (e.g. ['name', 'status']); derived from the child object (highlightFields → field walk) when omitted. Strings only — labels, cell types and formatting always derive from the child object's field definitions; column objects are refused at parse. | | **relatedListFilter** | `any` | optional | Declarative default filter for the detail-page related list: AND-composed with the parent-relationship condition `{ [referenceField]: parentId }` — an authored constraint, never a user-editable suggestion. The related-list tab badge count honors the same composed filter, so counts match the visible rows. Canonical Query-DSL FilterCondition (the same dialect as a query `where`), e.g. `{ status: { $ne: 'deleted' } }` to hide soft-deleted children. | @@ -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, and so is a name that matches no inbound lookup / master_detail field. 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, 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. | | **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'. | diff --git a/content/docs/references/system/migration.mdx b/content/docs/references/system/migration.mdx index 485c6eaae0f..098265fa59d 100644 --- a/content/docs/references/system/migration.mdx +++ b/content/docs/references/system/migration.mdx @@ -86,7 +86,7 @@ Add a new field to an existing object | **inlineTitle** | `string` | optional | Title for the inline master-detail grid | | **inlineColumns** | `{ name: string; label?: string; type?: Enum<'text' \| 'number' \| 'currency' \| 'date' \| 'datetime' \| 'time' \| 'select' \| 'lookup' \| 'file'>; width?: number; … }[]` | optional | Explicit columns for the inline grid (derived from the child object when omitted). Each entry is a strict, name-keyed column (`{ name, label?, type?, … }` — objectui GridColumn); identity-only entries (`{ name }`) hydrate everything else from the child object's fields. Unknown keys and the retired `field` spelling are refused at parse. | | **inlineAmountField** | `string` | optional | Numeric child field summed for the inline grid total | -| **relatedList** | `boolean \| 'primary'` | optional | Show this child collection as a related list on the parent's detail page (read-side mirror of inlineEdit). false = suppress; true/absent = shown as a supporting list, a side card beside the record (the shared "Related" tab on the tabbed page); 'primary' = part of the record's main content, drawn in the record's body (the details column, after the field sections) as a full-width table (its own tab on the tabbed page). Absent on a master_detail field that sets inlineEdit, the list is 'primary' by default; write true to keep it a side card. 'primary' places a list and does not order it: the parent object's relatedLists orders the body tables and the side cards (ADR-0085). A derived related list (relatedList: 'primary') inherits its row order from the child object's DEFAULT list view sort — the isDefault view, or the first declared list item when none is marked default; wire spelling is sort=`` / sort=-``, never $orderby. A child object with no list-view sort emits no ordering parameter, and rows fall back to record-id order. | +| **relatedList** | `boolean \| 'primary'` | optional | Show this child collection as a related list on the parent's detail page (read-side mirror of inlineEdit). false = suppress; true/absent = shown as a supporting list, a side card in the record's related column (the shared "Related" tab on the tabbed page); 'primary' = part of the record's main content, drawn in the record's body (the details column, after its first field section) as a full-width table (inside the Details tab on the tabbed page, with no tab of its own). Absent on a master_detail field that sets inlineEdit, the list is 'primary' by default; write true to keep it a side card. 'primary' places a list and does not order it: the parent object's relatedLists orders the body tables and the side cards (ADR-0085). A derived related list (relatedList: 'primary') inherits its row order from the child object's DEFAULT list view sort — the isDefault view, or the first declared list item when none is marked default; wire spelling is sort=`` / sort=-``, never $orderby. A child object with no list-view sort emits no ordering parameter, and rows fall back to record-id order. | | **relatedListTitle** | `string` | optional | Title for the detail-page related list | | **relatedListColumns** | `string[]` | optional | Explicit columns for the detail-page related list, as child field names (e.g. ['name', 'status']); derived from the child object (highlightFields → field walk) when omitted. Strings only — labels, cell types and formatting always derive from the child object's field definitions; column objects are refused at parse. | | **relatedListFilter** | `any` | optional | Declarative default filter for the detail-page related list: AND-composed with the parent-relationship condition `{ [referenceField]: parentId }` — an authored constraint, never a user-editable suggestion. The related-list tab badge count honors the same composed filter, so counts match the visible rows. Canonical Query-DSL FilterCondition (the same dialect as a query `where`), e.g. `{ status: { $ne: 'deleted' } }` to hide soft-deleted children. | @@ -348,7 +348,7 @@ Create a new object | **titleFormat** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → nameField (ADR-0079)] Render-only title template; the server cannot return or query it, and an explicit nameField now takes precedence. Migrate a single-field title to nameField; a composite title that import, sort or search must match to a stored computed field (a non-formula field with an expression, computed and stored on every write) designated as nameField — a text formula designated as nameField stays legal for a title shown for display only. Placeholders may be written `{{field}}` or `{field}` — the title renderers treat the two as equivalent, normalizing `{{field}}` to `{field}` before substituting; neither spelling is judged at parse time. | | **imageField** | `string` | optional | The record's picture: names the field the record page header (record chrome) draws beside the title — one object-level declaration every record detail page reads, not a per-page header prop. Must name a field of this object whose type is `image` or `avatar`; any other name is refused. A record whose field is empty shows no picture (no initials or placeholder). | | **highlightFields** | `string[]` | optional | [ADR-0085] Ordered most-important fields; first entry wins where only one fits. Drives default columns, cards, previews, detail highlight strip. Renamed from compactLayout. | -| **relatedLists** | `string[]` | optional | Business order of this object's related lists on its record page, most important first. Each entry is a child object name (e.g. "task"), or object.field when that child has more than one relationship to this object (e.g. "event.related_to"). Listed relationships come first in this order; unlisted ones follow in the derived order (owned master_detail children, then lookup children). An entry that matches no inbound lookup / master_detail field of this object is refused. It orders only: relatedList: false on the relationship still hides a list, and relatedList: 'primary' still places a list in the record's body, so the order applies among the body tables and among the side cards. | +| **relatedLists** | `string[]` | optional | Business order of this object's related lists on its record page, most important first. Each entry is a child object name (e.g. "task"), or object.field when that child has more than one relationship to this object (e.g. "event.related_to"). Listed relationships come first in this order; unlisted ones follow in the derived order (owned master_detail children, then lookup children). An entry whose child is in the stack but has no inbound lookup / master_detail field to this object is refused everywhere. An entry whose child exists nowhere is refused by os validate / os build and by a metadata write that names no package; a write into a package stores it, since a child outside the package may be a downstream package's object that points here, and the record page places nothing for it. It orders only: relatedList: false on the relationship still hides a list, and relatedList: 'primary' still places a list in the record's body, so the order applies among the body tables and among the side cards. | | **stageField** | `string \| false` | optional | [ADR-0085] Lifecycle stage field (linear/ordered), or false to declare the status field non-linear and suppress stage heuristics. Absent = heuristic detection allowed. When the named field carries options, it must be a single-value `select` / `radio` with inline `options`, each declaring its `kind` ('open' / 'won' / 'lost'); a picklist-backed or multi-value stage field is refused. A name that resolves to no declared field, or a field with no options, is not judged at parse. | | **editMode** | `Enum<'modal' \| 'page'>` | optional | Edit-interaction intent for records of this object: 'modal' opens the edit form as a dialog over the current view; 'page' navigates to a dedicated full-page edit route. Absent = the renderer picks its own default (objectui defaults to modal). Cross-renderer intent, not pixel styling (family). | | **listViews** | `Record; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>; data?: object \| object \| object; … }>` | optional | Built-in named list views (segmented tabs) shipped with the object schema — "views" mode, dropdown userFilters allowed, no page-only tabs (ADR-0047) | @@ -517,7 +517,7 @@ Add a new field to an existing object | **inlineTitle** | `string` | optional | Title for the inline master-detail grid | | **inlineColumns** | `{ name: string; label?: string; type?: Enum<'text' \| 'number' \| 'currency' \| 'date' \| 'datetime' \| 'time' \| 'select' \| 'lookup' \| 'file'>; width?: number; … }[]` | optional | Explicit columns for the inline grid (derived from the child object when omitted). Each entry is a strict, name-keyed column (`{ name, label?, type?, … }` — objectui GridColumn); identity-only entries (`{ name }`) hydrate everything else from the child object's fields. Unknown keys and the retired `field` spelling are refused at parse. | | **inlineAmountField** | `string` | optional | Numeric child field summed for the inline grid total | -| **relatedList** | `boolean \| 'primary'` | optional | Show this child collection as a related list on the parent's detail page (read-side mirror of inlineEdit). false = suppress; true/absent = shown as a supporting list, a side card beside the record (the shared "Related" tab on the tabbed page); 'primary' = part of the record's main content, drawn in the record's body (the details column, after the field sections) as a full-width table (its own tab on the tabbed page). Absent on a master_detail field that sets inlineEdit, the list is 'primary' by default; write true to keep it a side card. 'primary' places a list and does not order it: the parent object's relatedLists orders the body tables and the side cards (ADR-0085). A derived related list (relatedList: 'primary') inherits its row order from the child object's DEFAULT list view sort — the isDefault view, or the first declared list item when none is marked default; wire spelling is sort=`` / sort=-``, never $orderby. A child object with no list-view sort emits no ordering parameter, and rows fall back to record-id order. | +| **relatedList** | `boolean \| 'primary'` | optional | Show this child collection as a related list on the parent's detail page (read-side mirror of inlineEdit). false = suppress; true/absent = shown as a supporting list, a side card in the record's related column (the shared "Related" tab on the tabbed page); 'primary' = part of the record's main content, drawn in the record's body (the details column, after its first field section) as a full-width table (inside the Details tab on the tabbed page, with no tab of its own). Absent on a master_detail field that sets inlineEdit, the list is 'primary' by default; write true to keep it a side card. 'primary' places a list and does not order it: the parent object's relatedLists orders the body tables and the side cards (ADR-0085). A derived related list (relatedList: 'primary') inherits its row order from the child object's DEFAULT list view sort — the isDefault view, or the first declared list item when none is marked default; wire spelling is sort=`` / sort=-``, never $orderby. A child object with no list-view sort emits no ordering parameter, and rows fall back to record-id order. | | **relatedListTitle** | `string` | optional | Title for the detail-page related list | | **relatedListColumns** | `string[]` | optional | Explicit columns for the detail-page related list, as child field names (e.g. ['name', 'status']); derived from the child object (highlightFields → field walk) when omitted. Strings only — labels, cell types and formatting always derive from the child object's field definitions; column objects are refused at parse. | | **relatedListFilter** | `any` | optional | Declarative default filter for the detail-page related list: AND-composed with the parent-relationship condition `{ [referenceField]: parentId }` — an authored constraint, never a user-editable suggestion. The related-list tab badge count honors the same composed filter, so counts match the visible rows. Canonical Query-DSL FilterCondition (the same dialect as a query `where`), e.g. `{ status: { $ne: 'deleted' } }` to hide soft-deleted children. | @@ -644,7 +644,7 @@ Create a new object | **titleFormat** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → nameField (ADR-0079)] Render-only title template; the server cannot return or query it, and an explicit nameField now takes precedence. Migrate a single-field title to nameField; a composite title that import, sort or search must match to a stored computed field (a non-formula field with an expression, computed and stored on every write) designated as nameField — a text formula designated as nameField stays legal for a title shown for display only. Placeholders may be written `{{field}}` or `{field}` — the title renderers treat the two as equivalent, normalizing `{{field}}` to `{field}` before substituting; neither spelling is judged at parse time. | | **imageField** | `string` | optional | The record's picture: names the field the record page header (record chrome) draws beside the title — one object-level declaration every record detail page reads, not a per-page header prop. Must name a field of this object whose type is `image` or `avatar`; any other name is refused. A record whose field is empty shows no picture (no initials or placeholder). | | **highlightFields** | `string[]` | optional | [ADR-0085] Ordered most-important fields; first entry wins where only one fits. Drives default columns, cards, previews, detail highlight strip. Renamed from compactLayout. | -| **relatedLists** | `string[]` | optional | Business order of this object's related lists on its record page, most important first. Each entry is a child object name (e.g. "task"), or object.field when that child has more than one relationship to this object (e.g. "event.related_to"). Listed relationships come first in this order; unlisted ones follow in the derived order (owned master_detail children, then lookup children). An entry that matches no inbound lookup / master_detail field of this object is refused. It orders only: relatedList: false on the relationship still hides a list, and relatedList: 'primary' still places a list in the record's body, so the order applies among the body tables and among the side cards. | +| **relatedLists** | `string[]` | optional | Business order of this object's related lists on its record page, most important first. Each entry is a child object name (e.g. "task"), or object.field when that child has more than one relationship to this object (e.g. "event.related_to"). Listed relationships come first in this order; unlisted ones follow in the derived order (owned master_detail children, then lookup children). An entry whose child is in the stack but has no inbound lookup / master_detail field to this object is refused everywhere. An entry whose child exists nowhere is refused by os validate / os build and by a metadata write that names no package; a write into a package stores it, since a child outside the package may be a downstream package's object that points here, and the record page places nothing for it. It orders only: relatedList: false on the relationship still hides a list, and relatedList: 'primary' still places a list in the record's body, so the order applies among the body tables and among the side cards. | | **stageField** | `string \| false` | optional | [ADR-0085] Lifecycle stage field (linear/ordered), or false to declare the status field non-linear and suppress stage heuristics. Absent = heuristic detection allowed. When the named field carries options, it must be a single-value `select` / `radio` with inline `options`, each declaring its `kind` ('open' / 'won' / 'lost'); a picklist-backed or multi-value stage field is refused. A name that resolves to no declared field, or a field with no options, is not judged at parse. | | **editMode** | `Enum<'modal' \| 'page'>` | optional | Edit-interaction intent for records of this object: 'modal' opens the edit form as a dialog over the current view; 'page' navigates to a dedicated full-page edit route. Absent = the renderer picks its own default (objectui defaults to modal). Cross-renderer intent, not pixel styling (family). | | **listViews** | `Record; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>; data?: object \| object \| object; … }>` | optional | Built-in named list views (segmented tabs) shipped with the object schema — "views" mode, dropdown userFilters allowed, no page-only tabs (ADR-0047) | diff --git a/packages/spec/liveness/object.json b/packages/spec/liveness/object.json index 1e42fb646a8..07915aea59e 100644 --- a/packages/spec/liveness/object.json +++ b/packages/spec/liveness/object.json @@ -386,7 +386,7 @@ }, "trackHistory": { "status": "live", - "evidence": "objectui app-shell RecordDetailView gates the record History tab on enable.trackHistory === true (historyEnabled memo); pairs with per-field trackHistory (plugin-audit renderTrackedChangeSummary, packages/plugins/plugin-audit/src/audit-writers.ts) which selects the field diffs. Audit CAPTURE into sys_audit_log is deliberately unconditional (compliance ledger) — the flag gates the UI surface, not the ledger (#2707)." + "evidence": "objectui app-shell RecordDetailView gates the record's field changes on enable.trackHistory === true (historyEnabled memo): on the synthesized record page they join the one timeline stream (RecordFieldChangesContext, its Changes filter), with no History tab; pairs with per-field trackHistory (plugin-audit renderTrackedChangeSummary, packages/plugins/plugin-audit/src/audit-writers.ts) which selects the field diffs. Audit CAPTURE into sys_audit_log is deliberately unconditional (compliance ledger) — the flag gates the UI surface, not the ledger (#2707)." }, "searchable": { "status": "live", From 0fae4859d8cfb549e435c1ee4a8b9551e942001b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 05:28:34 +0000 Subject: [PATCH 3/5] docs(spec): related-list text names the platform-prefix exemption; trackHistory narrows to a field-change history; plugin-detail README timeline column is the one stream Regenerates the references from the merged tree. Claude-Session: https://claude.ai/code/session_01WGufzgaGBuahtvHVCAa4ny Co-Authored-By: Claude --- .../27311-27312-record-page-describes.md | 6 +++-- content/docs/references/api/metadata.mdx | 2 +- content/docs/references/data/field.mdx | 18 +++---------- content/docs/references/data/object.mdx | 10 +++---- content/docs/references/system/migration.mdx | 4 +-- packages/spec/src/data/object.zod.ts | 26 +++++++++++-------- .../src/data/record-page-describes.test.ts | 10 ++++--- ui/packages/plugin-detail/README.md | 2 +- 8 files changed, 37 insertions(+), 41 deletions(-) diff --git a/.changeset/27311-27312-record-page-describes.md b/.changeset/27311-27312-record-page-describes.md index 7124055b8eb..74483748e51 100644 --- a/.changeset/27311-27312-record-page-describes.md +++ b/.changeset/27311-27312-record-page-describes.md @@ -1,5 +1,6 @@ --- '@objectstack/spec': patch +'@object-ui/plugin-detail': patch --- docs(spec): the related-list descriptions say which unknown names are refused, and `relatedList: 'primary'` and `enable.trackHistory` describe the current record page @@ -9,8 +10,9 @@ Metadata-protocol: none Description text only: no key, type, default, accepted value or export moves, and every stack that parsed, validated or published before still does. -- **`ObjectSchema.relatedLists` and `fieldGroups[i].relatedList`.** The descriptions promised that a name matching no inbound `lookup` / `master_detail` relationship is refused. They now say exactly where. A name whose child is in the stack but has no such relationship to this object is refused everywhere. A name whose child exists nowhere is refused by `os validate` / `os build` and by a metadata write that names no package. A write into a package stores it, because a child outside that package may be a downstream package's object that points in; the record page places nothing for it. +- **`ObjectSchema.relatedLists` and `fieldGroups[i].relatedList`.** The descriptions promised that a name matching no inbound `lookup` / `master_detail` relationship is refused. They now say exactly where. A name whose child is in the stack but has no such relationship to this object is refused everywhere. A name whose child exists nowhere is refused 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, because a child outside that package may be a downstream package's object that points in; the record page places nothing for it. - **`relatedList: 'primary'`.** A primary list is a full-width table in the record's details column, after the first field section, and inside the Details tab when the page is tabbed. It no longer gets a tab of its own; a supporting list is a card in the related column (the shared Related tab when tabbed). -- **`enable.trackHistory`.** The record's field changes join its one timeline stream, under the Changes filter, instead of a History tab. A page an author assigns shows changes where it places a `record:history` block. +- **`enable.trackHistory`.** The record's field changes join its one timeline stream, under the Changes filter, instead of a History tab. A page an author assigns shows a field-change history only where it places a `record:history` block. +- **`@object-ui/plugin-detail` README.** The wide-layout table's timeline column is the one stream (composer, discussion, activity and, with `enable.trackHistory`, field changes) rather than "Activity and History". What to do: nothing. diff --git a/content/docs/references/api/metadata.mdx b/content/docs/references/api/metadata.mdx index 8ce8e726cf1..de4f82ee7ac 100644 --- a/content/docs/references/api/metadata.mdx +++ b/content/docs/references/api/metadata.mdx @@ -958,7 +958,7 @@ Metadata query with filtering, sorting, and pagination | **titleFormat** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → nameField (ADR-0079)] Render-only title template; the server cannot return or query it, and an explicit nameField now takes precedence. Migrate a single-field title to nameField; a composite title that import, sort or search must match to a stored computed field (a non-formula field with an expression, computed and stored on every write) designated as nameField — a text formula designated as nameField stays legal for a title shown for display only. Placeholders may be written `{{field}}` or `{field}` — the title renderers treat the two as equivalent, normalizing `{{field}}` to `{field}` before substituting; neither spelling is judged at parse time. | | **imageField** | `string` | optional | The record's picture: names the field the record page header (record chrome) draws beside the title — one object-level declaration every record detail page reads, not a per-page header prop. Must name a field of this object whose type is `image` or `avatar`; any other name is refused. A record whose field is empty shows no picture (no initials or placeholder). | | **highlightFields** | `string[]` | optional | [ADR-0085] Ordered most-important fields; first entry wins where only one fits. Drives default columns, cards, previews, detail highlight strip. Renamed from compactLayout. | -| **relatedLists** | `string[]` | optional | Business order of this object's related lists on its record page, most important first. Each entry is a child object name (e.g. "task"), or object.field when that child has more than one relationship to this object (e.g. "event.related_to"). Listed relationships come first in this order; unlisted ones follow in the derived order (owned master_detail children, then lookup children). An entry whose child is in the stack but has no inbound lookup / master_detail field to this object is refused everywhere. An entry whose child exists nowhere is refused by os validate / os build and by a metadata write that names no package; a write into a package stores it, since a child outside the package may be a downstream package's object that points here, and the record page places nothing for it. It orders only: relatedList: false on the relationship still hides a list, and relatedList: 'primary' still places a list in the record's body, so the order applies among the body tables and among the side cards. | +| **relatedLists** | `string[]` | optional | Business order of this object's related lists on its record page, most important first. Each entry is a child object name (e.g. "task"), or object.field when that child has more than one relationship to this object (e.g. "event.related_to"). Listed relationships come first in this order; unlisted ones follow in the derived order (owned master_detail children, then lookup children). An entry whose child is in the stack but has no inbound lookup / master_detail field to this object is refused everywhere. An entry whose child exists nowhere is refused 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, since a child outside the package may be a downstream package's object that points here, and the record page places nothing for it. It orders only: relatedList: false on the relationship still hides a list, and relatedList: 'primary' still places a list in the record's body, so the order applies among the body tables and among the side cards. | | **stageField** | `string \| false` | optional | [ADR-0085] Lifecycle stage field (linear/ordered), or false to declare the status field non-linear and suppress stage heuristics. Absent = heuristic detection allowed. When the named field carries options, it must be a single-value `select` / `radio` with inline `options`, each declaring its `kind` ('open' / 'won' / 'lost'); a picklist-backed or multi-value stage field is refused. A name that resolves to no declared field, or a field with no options, is not judged at parse. | | **editMode** | `Enum<'modal' \| 'page'>` | optional | Edit-interaction intent for records of this object: 'modal' opens the edit form as a dialog over the current view; 'page' navigates to a dedicated full-page edit route. Absent = the renderer picks its own default (objectui defaults to modal). Cross-renderer intent, not pixel styling (family). | | **listViews** | `Record; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>; data?: object \| object \| object; … }>` | optional | Built-in named list views (segmented tabs) shipped with the object schema — "views" mode, dropdown userFilters allowed, no page-only tabs (ADR-0047) | diff --git a/content/docs/references/data/field.mdx b/content/docs/references/data/field.mdx index 9c3cd88f109..5866b17be8e 100644 --- a/content/docs/references/data/field.mdx +++ b/content/docs/references/data/field.mdx @@ -1,7 +1,7 @@ --- title: Field schema — Data Protocol reference navTitle: Field -description: "Field schemas of the ObjectStack Data Protocol: CurrencyConfig, CurrencyValue, Field and 7 more — each property with its type, default and a TypeScript example." +description: "Field schemas of the ObjectStack Data Protocol: CurrencyConfig, Field and 7 more — each property with its type, default and a TypeScript example." --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -13,8 +13,8 @@ description: "Field schemas of the ObjectStack Data Protocol: CurrencyConfig, Cu ## TypeScript Usage ```typescript -import { CurrencyConfigSchema, CurrencyValueSchema, FieldSchema, FieldMaskingKeepSchema, FieldMaskingRuleSchema, FieldType, InlineGridColumnSchema, LocationCoordinatesSchema, SelectOptionSchema, UniqueScopeSchema } from '@objectstack/spec/data'; -import type { CurrencyConfig, CurrencyValue, Field, FieldMaskingKeep, FieldMaskingRule, FieldType, InlineGridColumn, LocationCoordinates, SelectOption, UniqueScope } from '@objectstack/spec/data'; +import { CurrencyConfigSchema, FieldSchema, FieldMaskingKeepSchema, FieldMaskingRuleSchema, FieldType, InlineGridColumnSchema, LocationCoordinatesSchema, SelectOptionSchema, UniqueScopeSchema } from '@objectstack/spec/data'; +import type { CurrencyConfig, Field, FieldMaskingKeep, FieldMaskingRule, FieldType, InlineGridColumn, LocationCoordinates, SelectOption, UniqueScope } from '@objectstack/spec/data'; // Validate data const result = CurrencyConfigSchema.parse(data); @@ -32,18 +32,6 @@ const result = CurrencyConfigSchema.parse(data); | **defaultCurrency** | `string` | optional | The currency code (ISO 4217, e.g. USD, CNY, EUR) of a `fixed`-mode field: its one currency, required under `fixed`. Not read under `dynamic` (the default), where amounts display in the tenant default currency. | ---- - -## CurrencyValue - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **value** | `number` | ✅ | Monetary amount | -| **currency** | `string` | ✅ | Currency code (ISO 4217) | - - --- ## Field diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index 61bfe72957a..851de5dd1ad 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -165,7 +165,7 @@ const result = ApiMethod.parse(data); | **titleFormat** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → nameField (ADR-0079)] Render-only title template; the server cannot return or query it, and an explicit nameField now takes precedence. Migrate a single-field title to nameField; a composite title that import, sort or search must match to a stored computed field (a non-formula field with an expression, computed and stored on every write) designated as nameField — a text formula designated as nameField stays legal for a title shown for display only. Placeholders may be written `{{field}}` or `{field}` — the title renderers treat the two as equivalent, normalizing `{{field}}` to `{field}` before substituting; neither spelling is judged at parse time. | | **imageField** | `string` | optional | The record's picture: names the field the record page header (record chrome) draws beside the title — one object-level declaration every record detail page reads, not a per-page header prop. Must name a field of this object whose type is `image` or `avatar`; any other name is refused. A record whose field is empty shows no picture (no initials or placeholder). | | **highlightFields** | `string[]` | optional | [ADR-0085] Ordered most-important fields; first entry wins where only one fits. Drives default columns, cards, previews, detail highlight strip. Renamed from compactLayout. | -| **relatedLists** | `string[]` | optional | Business order of this object's related lists on its record page, most important first. Each entry is a child object name (e.g. "task"), or object.field when that child has more than one relationship to this object (e.g. "event.related_to"). Listed relationships come first in this order; unlisted ones follow in the derived order (owned master_detail children, then lookup children). An entry whose child is in the stack but has no inbound lookup / master_detail field to this object is refused everywhere. An entry whose child exists nowhere is refused by os validate / os build and by a metadata write that names no package; a write into a package stores it, since a child outside the package may be a downstream package's object that points here, and the record page places nothing for it. It orders only: relatedList: false on the relationship still hides a list, and relatedList: 'primary' still places a list in the record's body, so the order applies among the body tables and among the side cards. | +| **relatedLists** | `string[]` | optional | Business order of this object's related lists on its record page, most important first. Each entry is a child object name (e.g. "task"), or object.field when that child has more than one relationship to this object (e.g. "event.related_to"). Listed relationships come first in this order; unlisted ones follow in the derived order (owned master_detail children, then lookup children). An entry whose child is in the stack but has no inbound lookup / master_detail field to this object is refused everywhere. An entry whose child exists nowhere is refused 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, since a child outside the package may be a downstream package's object that points here, and the record page places nothing for it. It orders only: relatedList: false on the relationship still hides a list, and relatedList: 'primary' still places a list in the record's body, so the order applies among the body tables and among the side cards. | | **stageField** | `string \| false` | optional | [ADR-0085] Lifecycle stage field (linear/ordered), or false to declare the status field non-linear and suppress stage heuristics. Absent = heuristic detection allowed. When the named field carries options, it must be a single-value `select` / `radio` with inline `options`, each declaring its `kind` ('open' / 'won' / 'lost'); a picklist-backed or multi-value stage field is refused. A name that resolves to no declared field, or a field with no options, is not judged at parse. | | **editMode** | `Enum<'modal' \| 'page'>` | optional | Edit-interaction intent for records of this object: 'modal' opens the edit form as a dialog over the current view; 'page' navigates to a dedicated full-page edit route. Absent = the renderer picks its own default (objectui defaults to modal). Cross-renderer intent, not pixel styling (family). | | **listViews** | `Record; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>; data?: object \| object \| object; … }>` | optional | Built-in named list views (segmented tabs) shipped with the object schema — "views" mode, dropdown userFilters allowed, no page-only tabs (ADR-0047) | @@ -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, 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. relatedList: false on the relationship still hides the table. | | **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'. | @@ -419,7 +419,7 @@ const result = ApiMethod.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **trackHistory** | `boolean` | optional (default: `false`) | Show the record's field changes (audit-trail UI): on the default record page they join the record's one timeline stream, under its Changes filter. An authored page shows changes only where it places a record:history block. Pair with per-field trackHistory to pick which field diffs are summarized; audit capture itself is always on for compliance | +| **trackHistory** | `boolean` | optional (default: `false`) | Show the record's field changes (audit-trail UI): on the default record page they join the record's one timeline stream, under its Changes filter. An authored page shows a field-change history only where it places a record:history block. Pair with per-field trackHistory to pick which field diffs are summarized; audit capture itself is always on for compliance | | **searchable** | `boolean` | optional (default: `true`) | Index records for global search | | **apiEnabled** | `boolean` | optional (default: `true`) | Expose object via automatic APIs | | **apiMethods** | `Enum<'get' \| 'list' \| 'create' \| 'update' \| 'delete' \| 'bulk'>[]` | optional | Whitelist of allowed API operations (six primitives; undefined = all, [] = none) | @@ -550,7 +550,7 @@ const result = ApiMethod.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **trackHistory** | `boolean` | optional (default: `false`) | Show the record's field changes (audit-trail UI): on the default record page they join the record's one timeline stream, under its Changes filter. An authored page shows changes only where it places a record:history block. Pair with per-field trackHistory to pick which field diffs are summarized; audit capture itself is always on for compliance | +| **trackHistory** | `boolean` | optional (default: `false`) | Show the record's field changes (audit-trail UI): on the default record page they join the record's one timeline stream, under its Changes filter. An authored page shows a field-change history only where it places a record:history block. Pair with per-field trackHistory to pick which field diffs are summarized; audit capture itself is always on for compliance | | **searchable** | `boolean` | optional (default: `true`) | Index records for global search | | **apiEnabled** | `boolean` | optional (default: `true`) | Expose object via automatic APIs | | **apiMethods** | `Enum<'get' \| 'list' \| 'create' \| 'update' \| 'delete' \| 'bulk'>[]` | optional | Whitelist of allowed API operations (six primitives; undefined = all, [] = none) | @@ -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, 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. relatedList: false on the relationship still hides the table. | | **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'. | diff --git a/content/docs/references/system/migration.mdx b/content/docs/references/system/migration.mdx index 098265fa59d..e465dc1ab51 100644 --- a/content/docs/references/system/migration.mdx +++ b/content/docs/references/system/migration.mdx @@ -348,7 +348,7 @@ Create a new object | **titleFormat** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → nameField (ADR-0079)] Render-only title template; the server cannot return or query it, and an explicit nameField now takes precedence. Migrate a single-field title to nameField; a composite title that import, sort or search must match to a stored computed field (a non-formula field with an expression, computed and stored on every write) designated as nameField — a text formula designated as nameField stays legal for a title shown for display only. Placeholders may be written `{{field}}` or `{field}` — the title renderers treat the two as equivalent, normalizing `{{field}}` to `{field}` before substituting; neither spelling is judged at parse time. | | **imageField** | `string` | optional | The record's picture: names the field the record page header (record chrome) draws beside the title — one object-level declaration every record detail page reads, not a per-page header prop. Must name a field of this object whose type is `image` or `avatar`; any other name is refused. A record whose field is empty shows no picture (no initials or placeholder). | | **highlightFields** | `string[]` | optional | [ADR-0085] Ordered most-important fields; first entry wins where only one fits. Drives default columns, cards, previews, detail highlight strip. Renamed from compactLayout. | -| **relatedLists** | `string[]` | optional | Business order of this object's related lists on its record page, most important first. Each entry is a child object name (e.g. "task"), or object.field when that child has more than one relationship to this object (e.g. "event.related_to"). Listed relationships come first in this order; unlisted ones follow in the derived order (owned master_detail children, then lookup children). An entry whose child is in the stack but has no inbound lookup / master_detail field to this object is refused everywhere. An entry whose child exists nowhere is refused by os validate / os build and by a metadata write that names no package; a write into a package stores it, since a child outside the package may be a downstream package's object that points here, and the record page places nothing for it. It orders only: relatedList: false on the relationship still hides a list, and relatedList: 'primary' still places a list in the record's body, so the order applies among the body tables and among the side cards. | +| **relatedLists** | `string[]` | optional | Business order of this object's related lists on its record page, most important first. Each entry is a child object name (e.g. "task"), or object.field when that child has more than one relationship to this object (e.g. "event.related_to"). Listed relationships come first in this order; unlisted ones follow in the derived order (owned master_detail children, then lookup children). An entry whose child is in the stack but has no inbound lookup / master_detail field to this object is refused everywhere. An entry whose child exists nowhere is refused 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, since a child outside the package may be a downstream package's object that points here, and the record page places nothing for it. It orders only: relatedList: false on the relationship still hides a list, and relatedList: 'primary' still places a list in the record's body, so the order applies among the body tables and among the side cards. | | **stageField** | `string \| false` | optional | [ADR-0085] Lifecycle stage field (linear/ordered), or false to declare the status field non-linear and suppress stage heuristics. Absent = heuristic detection allowed. When the named field carries options, it must be a single-value `select` / `radio` with inline `options`, each declaring its `kind` ('open' / 'won' / 'lost'); a picklist-backed or multi-value stage field is refused. A name that resolves to no declared field, or a field with no options, is not judged at parse. | | **editMode** | `Enum<'modal' \| 'page'>` | optional | Edit-interaction intent for records of this object: 'modal' opens the edit form as a dialog over the current view; 'page' navigates to a dedicated full-page edit route. Absent = the renderer picks its own default (objectui defaults to modal). Cross-renderer intent, not pixel styling (family). | | **listViews** | `Record; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>; data?: object \| object \| object; … }>` | optional | Built-in named list views (segmented tabs) shipped with the object schema — "views" mode, dropdown userFilters allowed, no page-only tabs (ADR-0047) | @@ -644,7 +644,7 @@ Create a new object | **titleFormat** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → nameField (ADR-0079)] Render-only title template; the server cannot return or query it, and an explicit nameField now takes precedence. Migrate a single-field title to nameField; a composite title that import, sort or search must match to a stored computed field (a non-formula field with an expression, computed and stored on every write) designated as nameField — a text formula designated as nameField stays legal for a title shown for display only. Placeholders may be written `{{field}}` or `{field}` — the title renderers treat the two as equivalent, normalizing `{{field}}` to `{field}` before substituting; neither spelling is judged at parse time. | | **imageField** | `string` | optional | The record's picture: names the field the record page header (record chrome) draws beside the title — one object-level declaration every record detail page reads, not a per-page header prop. Must name a field of this object whose type is `image` or `avatar`; any other name is refused. A record whose field is empty shows no picture (no initials or placeholder). | | **highlightFields** | `string[]` | optional | [ADR-0085] Ordered most-important fields; first entry wins where only one fits. Drives default columns, cards, previews, detail highlight strip. Renamed from compactLayout. | -| **relatedLists** | `string[]` | optional | Business order of this object's related lists on its record page, most important first. Each entry is a child object name (e.g. "task"), or object.field when that child has more than one relationship to this object (e.g. "event.related_to"). Listed relationships come first in this order; unlisted ones follow in the derived order (owned master_detail children, then lookup children). An entry whose child is in the stack but has no inbound lookup / master_detail field to this object is refused everywhere. An entry whose child exists nowhere is refused by os validate / os build and by a metadata write that names no package; a write into a package stores it, since a child outside the package may be a downstream package's object that points here, and the record page places nothing for it. It orders only: relatedList: false on the relationship still hides a list, and relatedList: 'primary' still places a list in the record's body, so the order applies among the body tables and among the side cards. | +| **relatedLists** | `string[]` | optional | Business order of this object's related lists on its record page, most important first. Each entry is a child object name (e.g. "task"), or object.field when that child has more than one relationship to this object (e.g. "event.related_to"). Listed relationships come first in this order; unlisted ones follow in the derived order (owned master_detail children, then lookup children). An entry whose child is in the stack but has no inbound lookup / master_detail field to this object is refused everywhere. An entry whose child exists nowhere is refused 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, since a child outside the package may be a downstream package's object that points here, and the record page places nothing for it. It orders only: relatedList: false on the relationship still hides a list, and relatedList: 'primary' still places a list in the record's body, so the order applies among the body tables and among the side cards. | | **stageField** | `string \| false` | optional | [ADR-0085] Lifecycle stage field (linear/ordered), or false to declare the status field non-linear and suppress stage heuristics. Absent = heuristic detection allowed. When the named field carries options, it must be a single-value `select` / `radio` with inline `options`, each declaring its `kind` ('open' / 'won' / 'lost'); a picklist-backed or multi-value stage field is refused. A name that resolves to no declared field, or a field with no options, is not judged at parse. | | **editMode** | `Enum<'modal' \| 'page'>` | optional | Edit-interaction intent for records of this object: 'modal' opens the edit form as a dialog over the current view; 'page' navigates to a dedicated full-page edit route. Absent = the renderer picks its own default (objectui defaults to modal). Cross-renderer intent, not pixel styling (family). | | **listViews** | `Record; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>; data?: object \| object \| object; … }>` | optional | Built-in named list views (segmented tabs) shipped with the object schema — "views" mode, dropdown userFilters allowed, no page-only tabs (ADR-0047) | diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index 01b9356f048..1ca1f5f803f 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -266,15 +266,16 @@ export const ObjectCapabilities = strictObject({ * the console. On the synthesized record page they join the record's one * timeline stream, beside its discussion and activity, under the stream's * Changes filter; that page has no separate History tab. A page an author - * assigns is drawn as authored: it shows changes where it places a - * `record:history` block, which reads the record's own activity, not this - * flag. Pair with per-field `trackHistory: true` to select which field + * assigns is drawn as authored: it shows a field-change history only where + * it places a `record:history` block, which reads the record's own activity, + * not this flag (a `record:activity` block there lists field edits only as + * activity lines). Pair with per-field `trackHistory: true` to select which field * diffs render as human-readable timeline summaries (ADR-0052 §5b). Audit * *capture* into `sys_audit_log` is a compliance ledger and stays on * regardless of this flag; retention is governed by data lifecycle * (ADR-0057), not by hiding the UI. */ - trackHistory: z.boolean().default(false).describe('Show the record\'s field changes (audit-trail UI): on the default record page they join the record\'s one timeline stream, under its Changes filter. An authored page shows changes only where it places a record:history block. Pair with per-field trackHistory to pick which field diffs are summarized; audit capture itself is always on for compliance'), + trackHistory: z.boolean().default(false).describe('Show the record\'s field changes (audit-trail UI): on the default record page they join the record\'s one timeline stream, under its Changes filter. An authored page shows a field-change history only where it places a record:history block. Pair with per-field trackHistory to pick which field diffs are summarized; audit capture itself is always on for compliance'), /** Enable global search indexing */ searchable: z.boolean().default(true).describe('Index records for global search'), @@ -1247,15 +1248,15 @@ export const ObjectFieldGroupSchema = lazySchema(() => strictObject({ * Such a group carries no fields: a field whose `group` names it is refused * at parse. Its name is judged exactly as a `relatedLists` entry's is (see * there): a child in the stack with no inbound relationship is refused - * everywhere; a child that exists nowhere is refused at `defineStack` and by - * a metadata write that names no package, and a write into a package stores - * it, placing nothing. Body tables no group places - * keep the record page's default position. `relatedList: false` on the FK + * everywhere; a child that exists nowhere is refused at `defineStack` (save a + * platform-prefixed name, left to the runtime) and by a metadata write that + * names no package, and a write into a package stores it, placing nothing. + * Body tables no group places keep the record page's default position. `relatedList: false` on the FK * still hides the table. */ relatedList: z.string().regex(RELATED_LIST_ENTRY_PATTERN, { message: 'A field group\'s relatedList is a child object name (e.g. "task") or object.field (e.g. "event.related_to"), in snake_case', - }).optional().describe('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, 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.').meta({ title: 'Related List' }), + }).optional().describe('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.').meta({ title: 'Related List' }), /** * [ADR-0085 §5] Section visibility predicate — CEL, the ADR-0089 canonical @@ -3060,7 +3061,10 @@ const ObjectSchemaBase = strictObject( * write door; * - a child that exists nowhere is refused at `defineStack` and by a * metadata write that names no package (judged against the whole - * collection the host serves). A write into a package judges only that + * collection the host serves). `defineStack` leaves a name carrying a + * platform prefix (`sys_`, `cloud_`, `ai_`; `hasPlatformObjectPrefix`) + * unjudged, since a platform or third-party package may provide it; the + * runtime door, which sees the registry, judges it. A write into a package judges only that * package's closure, and a child outside it may be a downstream * package's object that points in, so the entry is stored, and the * record page places nothing for it. The boundary is deliberate: the @@ -3079,7 +3083,7 @@ const ObjectSchemaBase = strictObject( z.string().regex(RELATED_LIST_ENTRY_PATTERN, { message: 'A relatedLists entry is a child object name (e.g. "task") or object.field (e.g. "event.related_to"), in snake_case', }), - ).optional().describe('Business order of this object\'s related lists on its record page, most important first. Each entry is a child object name (e.g. "task"), or object.field when that child has more than one relationship to this object (e.g. "event.related_to"). Listed relationships come first in this order; unlisted ones follow in the derived order (owned master_detail children, then lookup children). An entry whose child is in the stack but has no inbound lookup / master_detail field to this object is refused everywhere. An entry whose child exists nowhere is refused by os validate / os build and by a metadata write that names no package; a write into a package stores it, since a child outside the package may be a downstream package\'s object that points here, and the record page places nothing for it. It orders only: relatedList: false on the relationship still hides a list, and relatedList: \'primary\' still places a list in the record\'s body, so the order applies among the body tables and among the side cards.'), + ).optional().describe('Business order of this object\'s related lists on its record page, most important first. Each entry is a child object name (e.g. "task"), or object.field when that child has more than one relationship to this object (e.g. "event.related_to"). Listed relationships come first in this order; unlisted ones follow in the derived order (owned master_detail children, then lookup children). An entry whose child is in the stack but has no inbound lookup / master_detail field to this object is refused everywhere. An entry whose child exists nowhere is refused 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, since a child outside the package may be a downstream package\'s object that points here, and the record page places nothing for it. It orders only: relatedList: false on the relationship still hides a list, and relatedList: \'primary\' still places a list in the record\'s body, so the order applies among the body tables and among the side cards.'), /** * [ADR-0085] Semantic role: the field that represents the record's LINEAR * lifecycle (an ordered pipeline / stage progression). A string names the diff --git a/packages/spec/src/data/record-page-describes.test.ts b/packages/spec/src/data/record-page-describes.test.ts index 75c4d6cf237..bb155bd4128 100644 --- a/packages/spec/src/data/record-page-describes.test.ts +++ b/packages/spec/src/data/record-page-describes.test.ts @@ -10,8 +10,9 @@ * with no inbound FK is refused everywhere; a child that exists nowhere is * refused by `defineStack` and by a metadata write that names no package; a * write into a package stores it (the `validate-related-list-refs` header in - * `@objectstack/lint` holds the reasoning). The old text promised a refusal - * with no boundary. + * `@objectstack/lint` holds the reasoning). `defineStack` leaves a + * platform-prefixed name (`sys_` / `cloud_` / `ai_`) to the runtime, and the + * text says so. The old text promised a refusal with no boundary. * - [#27312] `relatedList: 'primary'` and `enable.trackHistory` describe the * v3 synthesized record page: a primary list is a body table in the details * column (inside the Details tab when tabbed), and field changes join the @@ -67,7 +68,7 @@ describe('[#27311] relatedLists describes the refusal that is enforced', () => { }); it('refuses a child that exists nowhere on os validate / os build and on a write that names no package', () => { - expect(text).toContain('exists nowhere is refused by os validate / os build and by a metadata write that names no package'); + expect(text).toContain('exists nowhere is refused by os validate / os build (which leave a sys_ / cloud_ / ai_ name to the runtime) and by a metadata write that names no package'); }); it('says a write into a package stores the entry and the page places nothing', () => { @@ -86,6 +87,7 @@ describe('[#27311] fieldGroups[i].relatedList is judged as a relatedLists entry it('names the same two refusals and the package-write boundary', () => { expect(text).toContain('The name is judged as a relatedLists entry is'); expect(text).toContain('in the stack with no inbound lookup / master_detail field, or exists nowhere'); + expect(text).toContain('which leave a sys_ / cloud_ / ai_ name to the runtime'); expect(text).toContain('a write into a package stores it'); }); @@ -112,7 +114,7 @@ describe('[#27312] enable.trackHistory describes the one timeline stream', () => it('sends field changes into the record\'s timeline stream', () => { expect(text).toContain('join the record\'s one timeline stream, under its Changes filter'); - expect(text).toContain('An authored page shows changes only where it places a record:history block'); + expect(text).toContain('An authored page shows a field-change history only where it places a record:history block'); }); it('carries no v2 tab phrase', () => { diff --git a/ui/packages/plugin-detail/README.md b/ui/packages/plugin-detail/README.md index 1b37d309819..068cb9fcb76 100644 --- a/ui/packages/plugin-detail/README.md +++ b/ui/packages/plugin-detail/README.md @@ -803,7 +803,7 @@ viewport: | Container width | Layout | |---|---| -| 1280 px and up | Three resident columns: details · timeline (the discussion composer on top, then Activity and History) · related (each related list as a card, Approvals, Attachments, the provenance footer) | +| 1280 px and up | Three resident columns: details · timeline (one stream: the composer on top, then the discussion, the activity and, with `enable.trackHistory`, the field changes, filtered by All / Activity / Discussion / Changes) · related (each related list as a card, Approvals, Attachments, the provenance footer) | | 1024 – 1279 px | Details · timeline; the related column folds into a rail opened from a **Related (N)** toggle above the columns | | below 1024 px, or embedded (drawer / split pane) | The tab strip, exactly as before | From a7c2af82dcce82d78f8311d5023b4c7d37b1f868 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 09:21:42 +0000 Subject: [PATCH 4/5] docs(spec): trackHistory names both authored blocks that show field changes, and claims no exclusivity Claude-Session: https://claude.ai/code/session_01WGufzgaGBuahtvHVCAa4ny Co-Authored-By: Claude --- .changeset/27311-27312-record-page-describes.md | 2 +- packages/spec/src/data/object.zod.ts | 17 ++++++++--------- .../spec/src/data/record-page-describes.test.ts | 5 ++++- 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/.changeset/27311-27312-record-page-describes.md b/.changeset/27311-27312-record-page-describes.md index 74483748e51..5ab320329bd 100644 --- a/.changeset/27311-27312-record-page-describes.md +++ b/.changeset/27311-27312-record-page-describes.md @@ -12,7 +12,7 @@ Description text only: no key, type, default, accepted value or export moves, an - **`ObjectSchema.relatedLists` and `fieldGroups[i].relatedList`.** The descriptions promised that a name matching no inbound `lookup` / `master_detail` relationship is refused. They now say exactly where. A name whose child is in the stack but has no such relationship to this object is refused everywhere. A name whose child exists nowhere is refused 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, because a child outside that package may be a downstream package's object that points in; the record page places nothing for it. - **`relatedList: 'primary'`.** A primary list is a full-width table in the record's details column, after the first field section, and inside the Details tab when the page is tabbed. It no longer gets a tab of its own; a supporting list is a card in the related column (the shared Related tab when tabbed). -- **`enable.trackHistory`.** The record's field changes join its one timeline stream, under the Changes filter, instead of a History tab. A page an author assigns shows a field-change history only where it places a `record:history` block. +- **`enable.trackHistory`.** The record's field changes join its one timeline stream, under the Changes filter, instead of a History tab. On a page an author assigns, a `record:history` or `record:activity` block shows the record's field changes from its own activity, and neither reads this flag. - **`@object-ui/plugin-detail` README.** The wide-layout table's timeline column is the one stream (composer, discussion, activity and, with `enable.trackHistory`, field changes) rather than "Activity and History". What to do: nothing. diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index 1ca1f5f803f..289c4db5dd2 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -266,16 +266,15 @@ export const ObjectCapabilities = strictObject({ * the console. On the synthesized record page they join the record's one * timeline stream, beside its discussion and activity, under the stream's * Changes filter; that page has no separate History tab. A page an author - * assigns is drawn as authored: it shows a field-change history only where - * it places a `record:history` block, which reads the record's own activity, - * not this flag (a `record:activity` block there lists field edits only as - * activity lines). Pair with per-field `trackHistory: true` to select which field - * diffs render as human-readable timeline summaries (ADR-0052 §5b). Audit - * *capture* into `sys_audit_log` is a compliance ledger and stays on - * regardless of this flag; retention is governed by data lifecycle - * (ADR-0057), not by hiding the UI. + * assigns is drawn as authored: a `record:history` or `record:activity` + * block placed there shows the record's field changes from its own activity + * (`sys_activity`), and neither reads this flag. Pair with per-field + * `trackHistory: true` to select which field diffs render as human-readable + * timeline summaries (ADR-0052 §5b). Audit *capture* into `sys_audit_log` is + * a compliance ledger and stays on regardless of this flag; retention is + * governed by data lifecycle (ADR-0057), not by hiding the UI. */ - trackHistory: z.boolean().default(false).describe('Show the record\'s field changes (audit-trail UI): on the default record page they join the record\'s one timeline stream, under its Changes filter. An authored page shows a field-change history only where it places a record:history block. Pair with per-field trackHistory to pick which field diffs are summarized; audit capture itself is always on for compliance'), + trackHistory: z.boolean().default(false).describe('Show the record\'s field changes (audit-trail UI): on the default record page they join the record\'s one timeline stream, under its Changes filter. On an authored page, a record:history or record:activity block shows the record\'s field changes from its own activity, and neither reads this flag. Pair with per-field trackHistory to pick which field diffs are summarized; audit capture itself is always on for compliance'), /** Enable global search indexing */ searchable: z.boolean().default(true).describe('Index records for global search'), diff --git a/packages/spec/src/data/record-page-describes.test.ts b/packages/spec/src/data/record-page-describes.test.ts index bb155bd4128..cfe25268407 100644 --- a/packages/spec/src/data/record-page-describes.test.ts +++ b/packages/spec/src/data/record-page-describes.test.ts @@ -114,7 +114,10 @@ describe('[#27312] enable.trackHistory describes the one timeline stream', () => it('sends field changes into the record\'s timeline stream', () => { expect(text).toContain('join the record\'s one timeline stream, under its Changes filter'); - expect(text).toContain('An authored page shows a field-change history only where it places a record:history block'); + expect(text).toContain('On an authored page, a record:history or record:activity block shows the record\'s field changes from its own activity, and neither reads this flag'); + // Neither block is the only place an authored page shows changes. + expect(text).not.toMatch(/only where it places/); + expect(text).not.toMatch(/activity lines/); }); it('carries no v2 tab phrase', () => { From 456377baad071e43d680b8b61cd22ca519d6f5d2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 09:26:39 +0000 Subject: [PATCH 5/5] docs(spec): regenerate references for the trackHistory wording Claude-Session: https://claude.ai/code/session_01WGufzgaGBuahtvHVCAa4ny Co-Authored-By: Claude --- content/docs/references/data/object.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index 851de5dd1ad..418bbaf7b74 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -419,7 +419,7 @@ const result = ApiMethod.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **trackHistory** | `boolean` | optional (default: `false`) | Show the record's field changes (audit-trail UI): on the default record page they join the record's one timeline stream, under its Changes filter. An authored page shows a field-change history only where it places a record:history block. Pair with per-field trackHistory to pick which field diffs are summarized; audit capture itself is always on for compliance | +| **trackHistory** | `boolean` | optional (default: `false`) | Show the record's field changes (audit-trail UI): on the default record page they join the record's one timeline stream, under its Changes filter. On an authored page, a record:history or record:activity block shows the record's field changes from its own activity, and neither reads this flag. Pair with per-field trackHistory to pick which field diffs are summarized; audit capture itself is always on for compliance | | **searchable** | `boolean` | optional (default: `true`) | Index records for global search | | **apiEnabled** | `boolean` | optional (default: `true`) | Expose object via automatic APIs | | **apiMethods** | `Enum<'get' \| 'list' \| 'create' \| 'update' \| 'delete' \| 'bulk'>[]` | optional | Whitelist of allowed API operations (six primitives; undefined = all, [] = none) | @@ -550,7 +550,7 @@ const result = ApiMethod.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **trackHistory** | `boolean` | optional (default: `false`) | Show the record's field changes (audit-trail UI): on the default record page they join the record's one timeline stream, under its Changes filter. An authored page shows a field-change history only where it places a record:history block. Pair with per-field trackHistory to pick which field diffs are summarized; audit capture itself is always on for compliance | +| **trackHistory** | `boolean` | optional (default: `false`) | Show the record's field changes (audit-trail UI): on the default record page they join the record's one timeline stream, under its Changes filter. On an authored page, a record:history or record:activity block shows the record's field changes from its own activity, and neither reads this flag. Pair with per-field trackHistory to pick which field diffs are summarized; audit capture itself is always on for compliance | | **searchable** | `boolean` | optional (default: `true`) | Index records for global search | | **apiEnabled** | `boolean` | optional (default: `true`) | Expose object via automatic APIs | | **apiMethods** | `Enum<'get' \| 'list' \| 'create' \| 'update' \| 'delete' \| 'bulk'>[]` | optional | Whitelist of allowed API operations (six primitives; undefined = all, [] = none) |