diff --git a/.changeset/adr-0085-semantic-roles.md b/.changeset/adr-0085-semantic-roles.md new file mode 100644 index 0000000000..e2505183ff --- /dev/null +++ b/.changeset/adr-0085-semantic-roles.md @@ -0,0 +1,53 @@ +--- +'@objectstack/spec': minor +'@objectstack/lint': minor +'@objectstack/cli': patch +'@objectstack/platform-objects': patch +--- + +ADR-0085: object presentation intent is declared as cross-surface semantic +roles, never as per-surface hint blocks. + +**@objectstack/spec** + +- New top-level `stageField: string | false` — names the object's linear + lifecycle field (`false` declares the status-like field non-linear and + suppresses every consumer's stage heuristics). Legitimizes the key the UI + runtime already read but the schema rejected. +- `compactLayout` → **`highlightFields`** (the value is an ordered field + list, not a layout; "highlight" is already the renderer-side term of art). + `compactLayout` stays accepted as a parse-time alias and is preserved on + output — the ADR-0079 `displayNameField → nameField` pattern. +- `fieldGroups[].collapse: 'none' | 'expanded' | 'collapsed'` replaces + `defaultExpanded` AND the UI-dialect `collapsible`/`collapsed` boolean pair + (which had drifted two ways: spec declared a key no renderer read, renderers + read keys the spec rejected). Old keys map onto the enum at parse and remain + accepted for one minor. +- `fieldGroups[].visibleOn` removed (no consumer anywhere — ADR-0049 + enforce-or-remove; re-add together with its enforcement when a surface + evaluates it). +- The `detail: { … }.passthrough()` UI-hints block is **removed**. Every key + in it was either unauthorable, a proven no-op for spec authors + (`hideReferenceRail` — the rail is default-off and its enabling key was + never typed), or a per-page toggle that belongs to an assigned Page. Zero + authors existed across framework and objectui (evidence in ADR-0085); the + removal ships as a minor under the documented dead-surface exception + (PR #2272 precedent). +- New `deriveFieldGroupLayout(def)` in `@objectstack/spec/data` — the single + source of the fieldGroups rendering semantics (declared order, empty groups + dropped, ungrouped trailing bucket minus audit/system fields, collapse + passthrough incl. deprecated aliases). UI renderers consume this instead of + their two pre-existing near-identical local copies. + +**@objectstack/lint / @objectstack/cli** + +- New `validateSemanticRoles` (wired into `os lint`): warns on + `Field.group` → undeclared group, declared-but-unreferenced groups, and + `stageField`/`highlightFields` entries naming non-existent fields — the + dangling-pointer shapes that are Zod-valid but silently inert at render + time (ADR-0078 completeness gate). + +**@objectstack/platform-objects** + +- All 35 system objects renamed `compactLayout:` → `highlightFields:` + (behaviour unchanged via the alias). diff --git a/content/docs/guides/data-modeling.mdx b/content/docs/guides/data-modeling.mdx index a53198a4f1..531432d985 100644 --- a/content/docs/guides/data-modeling.mdx +++ b/content/docs/guides/data-modeling.mdx @@ -39,7 +39,7 @@ export const MyObject = ObjectSchema.create({ // Display configuration titleFormat: '{{record.field1}} - {{record.field2}}', - compactLayout: ['field1', 'field2', 'field3'], + highlightFields: ['field1', 'field2', 'field3'], // Fields definition fields: { @@ -67,7 +67,7 @@ export const MyObject = ObjectSchema.create({ | `icon` | string | Icon identifier | `'building'` | | `description` | string | Help text | `'Companies...'` | | `titleFormat` | string | Record title template (`{{record.field}}` interpolation) | `'{{record.name}} - {{record.id}}'` | -| `compactLayout` | string[] | Quick view fields | `['name', 'status']` | +| `highlightFields` | string[] | Most-important fields, in priority order (default columns, cards, previews, detail highlight strip; ADR-0085 — formerly `compactLayout`, still accepted as an alias) | `['name', 'status']` | ### Enable Features diff --git a/content/docs/guides/metadata/object.mdx b/content/docs/guides/metadata/object.mdx index 2fdbc45947..a7e5ef8395 100644 --- a/content/docs/guides/metadata/object.mdx +++ b/content/docs/guides/metadata/object.mdx @@ -68,7 +68,8 @@ export const Account = ObjectSchema.create({ | :--- | :--- | :--- | :--- | | `displayNameField` | `string` | optional | Field used as record display name (defaults to `'name'`) | | `titleFormat` | `string` | optional | Title expression (e.g. `'{name} - {code}'`) | -| `compactLayout` | `string[]` | optional | Primary fields for hover cards and lookups | +| `highlightFields` | `string[]` | optional | Most-important fields in priority order — default list columns, cards, previews, detail highlight strip (ADR-0085; formerly `compactLayout`, accepted as an alias) | +| `stageField` | `string \| false` | optional | Linear lifecycle field; `false` declares the status field non-linear and suppresses stage heuristics (ADR-0085) | | `recordName` | `object` | optional | Record name auto-generation config | ### Capabilities (`enable`) diff --git a/content/docs/guides/standards.mdx b/content/docs/guides/standards.mdx index 8a56b20e44..57857b7701 100644 --- a/content/docs/guides/standards.mdx +++ b/content/docs/guides/standards.mdx @@ -95,7 +95,7 @@ export const MyObject = ObjectSchema.create({ // ✅ Display Configuration titleFormat: '{field1} - {field2}', - compactLayout: ['field1', 'field2', 'field3'], + highlightFields: ['field1', 'field2', 'field3'], // ✅ Fields Definition fields: { diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index 279d759121..8e6b17a972 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -274,9 +274,9 @@ const result = AIChatWindowProps.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **columns** | `Enum<'1' \| '2' \| '3' \| '4'>` | ✅ | Number of columns for field layout (1-4) | -| **layout** | `Enum<'auto' \| 'custom'>` | ✅ | Layout mode: auto uses object compactLayout, custom uses explicit sections | +| **layout** | `Enum<'auto' \| 'custom'>` | ✅ | Layout mode: auto uses object highlightFields (formerly compactLayout), custom uses explicit sections | | **sections** | `string[]` | optional | Section IDs to show (required when layout is "custom") | -| **fields** | `string[]` | optional | Explicit field list to display (optional, overrides compactLayout) | +| **fields** | `string[]` | optional | Explicit field list to display (optional, overrides highlightFields) | | **aria** | `Object` | optional | ARIA accessibility attributes | diff --git a/examples/app-todo/src/objects/task.object.ts b/examples/app-todo/src/objects/task.object.ts index a38fd4665f..e209a97408 100644 --- a/examples/app-todo/src/objects/task.object.ts +++ b/examples/app-todo/src/objects/task.object.ts @@ -181,7 +181,7 @@ export const Task = ObjectSchema.create({ ], nameField: 'subject', - compactLayout: ['subject', 'status', 'priority', 'due_date', 'owner'], + highlightFields: ['subject', 'status', 'priority', 'due_date', 'owner'], validations: [ { diff --git a/packages/cli/src/commands/lint.ts b/packages/cli/src/commands/lint.ts index 73dc23a662..42d6652146 100644 --- a/packages/cli/src/commands/lint.ts +++ b/packages/cli/src/commands/lint.ts @@ -8,7 +8,7 @@ import { loadConfig, BUNDLE_REQUIRE_EXTERNALS } from '../utils/config.js'; import { computeI18nCoverage } from '../utils/i18n-coverage.js'; import { lintDataModel } from '../lint/data-model-rules.js'; import { validateWidgetBindings } from '@objectstack/lint'; -import { validateRecordTitle } from '@objectstack/lint'; +import { validateRecordTitle, validateSemanticRoles } from '@objectstack/lint'; import { collectAndLintDocs } from '../utils/collect-docs.js'; import { scoreMetadata } from '../lint/score.js'; import { runMetadataEval } from '../lint/metadata-eval.js'; @@ -322,6 +322,21 @@ export function lintConfig(config: any): LintIssue[] { }); } + // ── Semantic-role pointers (ADR-0085) ── + // stageField / highlightFields / Field.group are pointers into the object's + // field map; a dangling pointer is Zod-valid but silently inert at render + // time (the ADR-0078 completeness gate). All advisory — every consumer + // degrades gracefully. + for (const t of validateSemanticRoles(config)) { + issues.push({ + severity: t.severity, + rule: t.rule, + message: `${t.where}: ${t.message}`, + path: t.path, + fix: t.hint, + }); + } + return issues; } diff --git a/packages/dogfood/test/expression-conformance.ledger.ts b/packages/dogfood/test/expression-conformance.ledger.ts index 93b00c8d0f..f1afb5bc76 100644 --- a/packages/dogfood/test/expression-conformance.ledger.ts +++ b/packages/dogfood/test/expression-conformance.ledger.ts @@ -108,7 +108,10 @@ export const EXPRESSION_SURFACE: ExprSurface[] = [ dialect: 'cel', mode: 'interpret', state: 'enforced', failPolicy: 'fail-soft-log', enforcement: 'console (objectui) SchemaRenderer + server celEngine (interpret)', covers: [ - 'data/object.zod.ts:visibleOn', + // data/object.zod.ts:visibleOn (fieldGroups group-level CEL visibility) + // was removed by ADR-0085 §3 — declared but never consumed anywhere + // (enforce-or-remove, ADR-0049). Re-add the row when the key returns + // WITH an enforcement path. 'ui/action.zod.ts:visible', 'ui/app.zod.ts:visible', 'ui/page.zod.ts:visibility', diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index 1881525c6c..bf9226ab1f 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -50,3 +50,11 @@ export { TITLE_UNRESOLVABLE, } from './validate-record-title.js'; export type { RecordTitleFinding, RecordTitleSeverity } from './validate-record-title.js'; + +export { + validateSemanticRoles, + FIELD_GROUP_UNDECLARED, + FIELD_GROUP_EMPTY, + SEMANTIC_ROLE_FIELD_UNKNOWN, +} from './validate-semantic-roles.js'; +export type { SemanticRoleFinding, SemanticRoleSeverity } from './validate-semantic-roles.js'; diff --git a/packages/lint/src/validate-semantic-roles.test.ts b/packages/lint/src/validate-semantic-roles.test.ts new file mode 100644 index 0000000000..d9c82f9f70 --- /dev/null +++ b/packages/lint/src/validate-semantic-roles.test.ts @@ -0,0 +1,100 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { + validateSemanticRoles, + FIELD_GROUP_UNDECLARED, + FIELD_GROUP_EMPTY, + SEMANTIC_ROLE_FIELD_UNKNOWN, +} from './validate-semantic-roles'; + +const stack = (objects: unknown) => ({ objects }); + +describe('validateSemanticRoles (ADR-0085)', () => { + it('passes a clean object', () => { + const findings = validateSemanticRoles(stack([{ + name: 'account', + stageField: 'status', + highlightFields: ['name', 'status'], + fieldGroups: [{ key: 'basic', label: 'Basic' }], + fields: { + name: { type: 'text', group: 'basic' }, + status: { type: 'select' }, + }, + }])); + expect(findings).toEqual([]); + }); + + it('flags a Field.group referencing an undeclared group', () => { + const findings = validateSemanticRoles(stack([{ + name: 'account', + fieldGroups: [{ key: 'basic', label: 'Basic' }], + fields: { + name: { type: 'text', group: 'basic' }, + vat: { type: 'text', group: 'billling' }, // typo + }, + }])); + expect(findings).toHaveLength(1); + expect(findings[0]).toMatchObject({ + severity: 'warning', + rule: FIELD_GROUP_UNDECLARED, + path: 'objects[0].fields.vat.group', + }); + expect(findings[0].message).toContain('billling'); + }); + + it('flags a declared group no field references', () => { + const findings = validateSemanticRoles(stack([{ + name: 'account', + fieldGroups: [ + { key: 'basic', label: 'Basic' }, + { key: 'unused', label: 'Unused' }, + ], + fields: { name: { type: 'text', group: 'basic' } }, + }])); + expect(findings).toHaveLength(1); + expect(findings[0]).toMatchObject({ rule: FIELD_GROUP_EMPTY }); + expect(findings[0].message).toContain('unused'); + }); + + it('flags stageField pointing at a missing field; false is fine', () => { + const bad = validateSemanticRoles(stack([{ + name: 'lead', stageField: 'pipeline', fields: { status: {} }, + }])); + expect(bad).toHaveLength(1); + expect(bad[0]).toMatchObject({ rule: SEMANTIC_ROLE_FIELD_UNKNOWN, path: 'objects[0].stageField' }); + + const optedOut = validateSemanticRoles(stack([{ + name: 'lead', stageField: false, fields: { status: {} }, + }])); + expect(optedOut).toEqual([]); + }); + + it('flags unknown highlightFields entries, including via the compactLayout alias', () => { + const findings = validateSemanticRoles(stack([{ + name: 'account', + highlightFields: ['name', 'industy'], // typo + fields: { name: {}, industry: {} }, + }])); + expect(findings).toHaveLength(1); + expect(findings[0].message).toContain('industy'); + + const aliased = validateSemanticRoles(stack([{ + name: 'account', + compactLayout: ['ghost'], + fields: { name: {} }, + }])); + expect(aliased).toHaveLength(1); + expect(aliased[0]).toMatchObject({ rule: SEMANTIC_ROLE_FIELD_UNKNOWN }); + }); + + it('accepts objects as a name-keyed map and tolerates junk shapes', () => { + const findings = validateSemanticRoles(stack({ + account: { stageField: 'nope', fields: {} }, + })); + expect(findings).toHaveLength(1); + expect(validateSemanticRoles({})).toEqual([]); + expect(validateSemanticRoles(stack(null))).toEqual([]); + expect(validateSemanticRoles(stack([null, 'junk', 42]))).toEqual([]); + }); +}); diff --git a/packages/lint/src/validate-semantic-roles.ts b/packages/lint/src/validate-semantic-roles.ts new file mode 100644 index 0000000000..bd2b771228 --- /dev/null +++ b/packages/lint/src/validate-semantic-roles.ts @@ -0,0 +1,160 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Build-time semantic-role diagnostics (ADR-0085). + * + * The object-level semantic roles (`stageField`, `highlightFields` / + * deprecated `compactLayout`, `fieldGroups` + `Field.group`) are pointers + * into the object's own field map. A dangling pointer is Zod-valid but + * silently inert at render time — the exact "parsed, unmarked, silently + * inert" shape ADR-0078 prohibits — so the completeness lint flags it here, + * uniformly for `os build`/`os validate`, MCP authoring and hand authors. + * + * All three rules are warnings, not errors: every consumer degrades + * gracefully (an unknown `Field.group` renders in the ungrouped bucket, an + * unknown highlight name is skipped, an unknown `stageField` falls back to + * heuristics), so nothing is fully broken — but the author almost certainly + * typo'd a name and should be told at author time, not discover it by + * staring at an unchanged page. + */ + +export const FIELD_GROUP_UNDECLARED = 'field-group-undeclared'; +export const FIELD_GROUP_EMPTY = 'field-group-empty'; +export const SEMANTIC_ROLE_FIELD_UNKNOWN = 'semantic-role-field-unknown'; + +export type SemanticRoleSeverity = 'error' | 'warning'; + +export interface SemanticRoleFinding { + /** Always `warning` today — all three rules are advisory (see module note). */ + severity: SemanticRoleSeverity; + /** Diagnostic rule id, e.g. `field-group-undeclared`. */ + rule: string; + /** Human-readable location, e.g. `object "invoice"`. */ + where: string; + /** Config path, e.g. `objects[3]`. */ + path: string; + /** What is wrong. */ + message: string; + /** How to fix it. */ + hint: string; +} + +type AnyRec = Record; + +/** Coerce a collection (array or name-keyed map) to an array of records. */ +function asArray(v: unknown): AnyRec[] { + if (Array.isArray(v)) return v as AnyRec[]; + if (v && typeof v === 'object') { + return Object.entries(v as AnyRec).map(([name, def]) => ({ name, ...(def as AnyRec) })); + } + return []; +} + +/** + * Validate every object's semantic-role pointers. Returns the list of + * findings (empty = clean). Advisory only — the caller must never fail the + * build on these alone. + */ +export function validateSemanticRoles(stack: AnyRec): SemanticRoleFinding[] { + const findings: SemanticRoleFinding[] = []; + + const objects = asArray(stack.objects); + for (let i = 0; i < objects.length; i++) { + const obj = objects[i]; + if (!obj || typeof obj !== 'object') continue; // tolerate junk entries + const objName = typeof obj.name === 'string' ? obj.name : `(object ${i})`; + const where = `object "${objName}"`; + const path = `objects[${i}]`; + + const fields = (obj.fields && typeof obj.fields === 'object' && !Array.isArray(obj.fields)) + ? (obj.fields as Record) + : {}; + const fieldNames = new Set(Object.keys(fields)); + + // ── (a) Field.group → declared fieldGroups[].key ── + const declaredGroups = new Set( + (Array.isArray(obj.fieldGroups) ? obj.fieldGroups : []) + .filter((g): g is AnyRec => !!g && typeof g === 'object') + .map((g) => g.key) + .filter((k): k is string => typeof k === 'string' && k.length > 0), + ); + const referencedGroups = new Set(); + for (const [fname, f] of Object.entries(fields)) { + const g = f?.group; + if (typeof g !== 'string' || g.length === 0) continue; + referencedGroups.add(g); + if (!declaredGroups.has(g)) { + findings.push({ + severity: 'warning', + rule: FIELD_GROUP_UNDECLARED, + where, + path: `${path}.fields.${fname}.group`, + message: + `${objName}.${fname}: group "${g}" is not declared in fieldGroups — ` + + `the field renders in the ungrouped bucket, not under "${g}"`, + hint: + `Declare { key: '${g}', label: '…' } in ${objName}.fieldGroups, or fix ` + + `the field's group reference. Group keys are snake_case and must match exactly.`, + }); + } + } + + // ── (b) declared group no field references ── + for (const key of declaredGroups) { + if (!referencedGroups.has(key)) { + findings.push({ + severity: 'warning', + rule: FIELD_GROUP_EMPTY, + where, + path: `${path}.fieldGroups`, + message: + `${objName}: fieldGroups declares "${key}" but no field references it — ` + + `the group never renders`, + hint: + `Assign at least one field via group: '${key}', or remove the unused ` + + `group declaration.`, + }); + } + } + + // ── (c) semantic-role pointers name real fields ── + const stage = obj.stageField; + if (typeof stage === 'string' && stage.length > 0 && !fieldNames.has(stage)) { + findings.push({ + severity: 'warning', + rule: SEMANTIC_ROLE_FIELD_UNKNOWN, + where, + path: `${path}.stageField`, + message: + `${objName}: stageField "${stage}" is not a field on this object — ` + + `consumers fall back to heuristic stage detection`, + hint: + `Point stageField at an existing select/status field, or set ` + + `stageField: false to declare the object has no linear lifecycle.`, + }); + } + + const highlights = Array.isArray(obj.highlightFields) + ? obj.highlightFields + : Array.isArray(obj.compactLayout) // deprecated alias (pre-normalization input) + ? obj.compactLayout + : []; + for (const entry of highlights) { + if (typeof entry !== 'string' || entry.length === 0 || fieldNames.has(entry)) continue; + findings.push({ + severity: 'warning', + rule: SEMANTIC_ROLE_FIELD_UNKNOWN, + where, + path: `${path}.highlightFields`, + message: + `${objName}: highlightFields entry "${entry}" is not a field on this ` + + `object — it is silently skipped by every consumer`, + hint: + `Fix the field name (highlightFields drives default columns, cards, ` + + `previews and the detail highlight strip, in order).`, + }); + } + } + + return findings; +} diff --git a/packages/platform-objects/src/audit/sys-attachment.object.ts b/packages/platform-objects/src/audit/sys-attachment.object.ts index beacf5a74b..7d58137dce 100644 --- a/packages/platform-objects/src/audit/sys-attachment.object.ts +++ b/packages/platform-objects/src/audit/sys-attachment.object.ts @@ -31,7 +31,7 @@ export const SysAttachment = ObjectSchema.create({ managedBy: 'platform', description: 'Polymorphic link between a sys_file and any other record', titleFormat: '{file_name} → {parent_object}/{parent_id}', - compactLayout: ['created_at', 'parent_object', 'file_name', 'mime_type', 'size'], + highlightFields: ['created_at', 'parent_object', 'file_name', 'mime_type', 'size'], fields: { id: Field.text({ diff --git a/packages/platform-objects/src/audit/sys-email-template.object.ts b/packages/platform-objects/src/audit/sys-email-template.object.ts index 113076a678..3b70534de1 100644 --- a/packages/platform-objects/src/audit/sys-email-template.object.ts +++ b/packages/platform-objects/src/audit/sys-email-template.object.ts @@ -27,7 +27,7 @@ export const SysEmailTemplate = ObjectSchema.create({ displayNameField: 'label', nameField: 'label', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) titleFormat: '{label}', - compactLayout: ['name', 'label', 'category', 'locale', 'active'], + highlightFields: ['name', 'label', 'category', 'locale', 'active'], fields: { id: Field.text({ diff --git a/packages/platform-objects/src/audit/sys-email.object.ts b/packages/platform-objects/src/audit/sys-email.object.ts index a9cfbfb91d..72493fff83 100644 --- a/packages/platform-objects/src/audit/sys-email.object.ts +++ b/packages/platform-objects/src/audit/sys-email.object.ts @@ -29,7 +29,7 @@ export const SysEmail = ObjectSchema.create({ displayNameField: 'subject', nameField: 'subject', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) titleFormat: '{subject}', - compactLayout: ['subject', 'to', 'status', 'sent_at'], + highlightFields: ['subject', 'to', 'status', 'sent_at'], fields: { id: Field.text({ diff --git a/packages/platform-objects/src/audit/sys-import-job.object.ts b/packages/platform-objects/src/audit/sys-import-job.object.ts index 55ff63cf59..9d0ea76c22 100644 --- a/packages/platform-objects/src/audit/sys-import-job.object.ts +++ b/packages/platform-objects/src/audit/sys-import-job.object.ts @@ -30,7 +30,7 @@ export const SysImportJob = ObjectSchema.create({ displayNameField: 'object_name', nameField: 'object_name', // [ADR-0079] canonical primary-title pointer titleFormat: '{object_name} import @ {created_at}', - compactLayout: ['object_name', 'status', 'processed_rows', 'total_rows', 'created_at'], + highlightFields: ['object_name', 'status', 'processed_rows', 'total_rows', 'created_at'], fields: { id: Field.text({ label: 'Job ID', required: true, readonly: true, group: 'System' }), diff --git a/packages/platform-objects/src/audit/sys-job-queue.object.ts b/packages/platform-objects/src/audit/sys-job-queue.object.ts index 3e55aec576..830efcd121 100644 --- a/packages/platform-objects/src/audit/sys-job-queue.object.ts +++ b/packages/platform-objects/src/audit/sys-job-queue.object.ts @@ -35,7 +35,7 @@ export const SysJobQueue = ObjectSchema.create({ displayNameField: 'queue', nameField: 'queue', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) titleFormat: '{queue} #{id}', - compactLayout: ['queue', 'status', 'attempts', 'scheduled_for', 'last_error'], + highlightFields: ['queue', 'status', 'attempts', 'scheduled_for', 'last_error'], fields: { id: Field.text({ label: 'Message ID', required: true, readonly: true, group: 'System' }), diff --git a/packages/platform-objects/src/audit/sys-job-run.object.ts b/packages/platform-objects/src/audit/sys-job-run.object.ts index dae3eeff5c..75f5af78d9 100644 --- a/packages/platform-objects/src/audit/sys-job-run.object.ts +++ b/packages/platform-objects/src/audit/sys-job-run.object.ts @@ -26,7 +26,7 @@ export const SysJobRun = ObjectSchema.create({ displayNameField: 'job_name', nameField: 'job_name', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) titleFormat: '{job_name} @ {started_at}', - compactLayout: ['job_name', 'status', 'started_at', 'duration_ms', 'attempt'], + highlightFields: ['job_name', 'status', 'started_at', 'duration_ms', 'attempt'], fields: { id: Field.text({ label: 'Run ID', required: true, readonly: true, group: 'System' }), diff --git a/packages/platform-objects/src/audit/sys-job.object.ts b/packages/platform-objects/src/audit/sys-job.object.ts index 34789680d6..64275db776 100644 --- a/packages/platform-objects/src/audit/sys-job.object.ts +++ b/packages/platform-objects/src/audit/sys-job.object.ts @@ -26,7 +26,7 @@ export const SysJob = ObjectSchema.create({ displayNameField: 'name', nameField: 'name', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) titleFormat: '{name}', - compactLayout: ['name', 'schedule_type', 'active', 'last_run_at', 'last_status'], + highlightFields: ['name', 'schedule_type', 'active', 'last_run_at', 'last_status'], fields: { id: Field.text({ label: 'Job ID', required: true, readonly: true, group: 'System' }), diff --git a/packages/platform-objects/src/audit/sys-notification.object.ts b/packages/platform-objects/src/audit/sys-notification.object.ts index 242fd806ee..1033070611 100644 --- a/packages/platform-objects/src/audit/sys-notification.object.ts +++ b/packages/platform-objects/src/audit/sys-notification.object.ts @@ -36,7 +36,7 @@ export const SysNotification = ObjectSchema.create({ displayNameField: 'topic', nameField: 'topic', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) titleFormat: '{topic}', - compactLayout: ['topic', 'severity', 'source_object', 'created_at'], + highlightFields: ['topic', 'severity', 'source_object', 'created_at'], listViews: { recent: { diff --git a/packages/platform-objects/src/audit/sys-report-schedule.object.ts b/packages/platform-objects/src/audit/sys-report-schedule.object.ts index 24e1ec7cf3..3fd6a71f22 100644 --- a/packages/platform-objects/src/audit/sys-report-schedule.object.ts +++ b/packages/platform-objects/src/audit/sys-report-schedule.object.ts @@ -38,7 +38,7 @@ export const SysReportSchedule = ObjectSchema.create({ managedBy: 'platform', description: 'Recurring delivery of a sys_saved_report via email', titleFormat: '{report_id} → {recipients}', - compactLayout: ['report_id', 'recipients', 'interval_minutes', 'active', 'next_run_at'], + highlightFields: ['report_id', 'recipients', 'interval_minutes', 'active', 'next_run_at'], fields: { id: Field.text({ diff --git a/packages/platform-objects/src/audit/sys-saved-report.object.ts b/packages/platform-objects/src/audit/sys-saved-report.object.ts index 9bd4481645..3962207ddf 100644 --- a/packages/platform-objects/src/audit/sys-saved-report.object.ts +++ b/packages/platform-objects/src/audit/sys-saved-report.object.ts @@ -34,7 +34,7 @@ export const SysSavedReport = ObjectSchema.create({ displayNameField: 'name', nameField: 'name', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) titleFormat: '{name}', - compactLayout: ['name', 'object_name', 'format', 'owner_id', 'updated_at'], + highlightFields: ['name', 'object_name', 'format', 'owner_id', 'updated_at'], fields: { id: Field.text({ diff --git a/packages/platform-objects/src/identity/sys-account.object.ts b/packages/platform-objects/src/identity/sys-account.object.ts index c979254b30..2aa77cb08e 100644 --- a/packages/platform-objects/src/identity/sys-account.object.ts +++ b/packages/platform-objects/src/identity/sys-account.object.ts @@ -27,7 +27,7 @@ export const SysAccount = ObjectSchema.create({ }, description: 'OAuth and authentication provider accounts', titleFormat: '{provider_id} - {account_id}', - compactLayout: ['provider_id', 'user_id', 'account_id'], + highlightFields: ['provider_id', 'user_id', 'account_id'], // Custom actions — sysadmins routinely need to revoke a user's OAuth // link (e.g. when an SSO provider is decommissioned or the user diff --git a/packages/platform-objects/src/identity/sys-api-key.object.ts b/packages/platform-objects/src/identity/sys-api-key.object.ts index 8586ff4760..f3a0f59ed9 100644 --- a/packages/platform-objects/src/identity/sys-api-key.object.ts +++ b/packages/platform-objects/src/identity/sys-api-key.object.ts @@ -32,7 +32,7 @@ export const SysApiKey = ObjectSchema.create({ displayNameField: 'name', nameField: 'name', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) titleFormat: '{name}', - compactLayout: ['name', 'prefix', 'user_id', 'expires_at', 'revoked'], + highlightFields: ['name', 'prefix', 'user_id', 'expires_at', 'revoked'], // Custom actions — sys_api_key is managed-by 'better-auth' but the // `revoked` boolean is a column we control via the data API. These row diff --git a/packages/platform-objects/src/identity/sys-business-unit-member.object.ts b/packages/platform-objects/src/identity/sys-business-unit-member.object.ts index 342b5eecf8..3337ea158d 100644 --- a/packages/platform-objects/src/identity/sys-business-unit-member.object.ts +++ b/packages/platform-objects/src/identity/sys-business-unit-member.object.ts @@ -23,7 +23,7 @@ export const SysBusinessUnitMember = ObjectSchema.create({ managedBy: 'platform', description: 'User assignment to a business unit (matrix-org friendly, effective-dated).', titleFormat: '{user_id} in {business_unit_id}', - compactLayout: ['user_id', 'business_unit_id', 'role_in_business_unit', 'is_primary'], + highlightFields: ['user_id', 'business_unit_id', 'role_in_business_unit', 'is_primary'], fields: { id: Field.text({ diff --git a/packages/platform-objects/src/identity/sys-business-unit.object.ts b/packages/platform-objects/src/identity/sys-business-unit.object.ts index 2eed6e1193..94b94b75d3 100644 --- a/packages/platform-objects/src/identity/sys-business-unit.object.ts +++ b/packages/platform-objects/src/identity/sys-business-unit.object.ts @@ -31,7 +31,7 @@ export const SysBusinessUnit = ObjectSchema.create({ displayNameField: 'name', nameField: 'name', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) titleFormat: '{name}', - compactLayout: ['name', 'kind', 'parent_business_unit_id', 'manager_user_id'], + highlightFields: ['name', 'kind', 'parent_business_unit_id', 'manager_user_id'], listViews: { // Org chart — the hierarchy view. Surfaces the self-referencing diff --git a/packages/platform-objects/src/identity/sys-device-code.object.ts b/packages/platform-objects/src/identity/sys-device-code.object.ts index 7f856a8daa..25857ad495 100644 --- a/packages/platform-objects/src/identity/sys-device-code.object.ts +++ b/packages/platform-objects/src/identity/sys-device-code.object.ts @@ -37,7 +37,7 @@ export const SysDeviceCode = ObjectSchema.create({ description: 'OAuth 2.0 Device Authorization Grant (RFC 8628) pending requests', nameField: 'user_code', // [ADR-0079] canonical primary-title pointer (single-field titleFormat) titleFormat: '{user_code}', - compactLayout: ['user_code', 'status', 'client_id', 'expires_at'], + highlightFields: ['user_code', 'status', 'client_id', 'expires_at'], fields: { id: Field.text({ diff --git a/packages/platform-objects/src/identity/sys-invitation.object.ts b/packages/platform-objects/src/identity/sys-invitation.object.ts index 74e5d26ca1..31bf7b6d82 100644 --- a/packages/platform-objects/src/identity/sys-invitation.object.ts +++ b/packages/platform-objects/src/identity/sys-invitation.object.ts @@ -30,7 +30,7 @@ export const SysInvitation = ObjectSchema.create({ // single-org mode (renders "Invitation to null"), and the recipient email is // the more useful identifier in both modes anyway. titleFormat: 'Invitation for {email}', - compactLayout: ['email', 'organization_id', 'status'], + highlightFields: ['email', 'organization_id', 'status'], // Custom actions — generic CRUD is suppressed (better-auth-managed). // Mirror the `invite_user` toolbar action from sys_user here so admins diff --git a/packages/platform-objects/src/identity/sys-jwks.object.ts b/packages/platform-objects/src/identity/sys-jwks.object.ts index a67ab9e1e2..a2c94bcdf6 100644 --- a/packages/platform-objects/src/identity/sys-jwks.object.ts +++ b/packages/platform-objects/src/identity/sys-jwks.object.ts @@ -30,7 +30,7 @@ export const SysJwks = ObjectSchema.create({ docsUrl: 'https://docs.objectstack.ai/adr/0010-metadata-protection', }, description: 'Asymmetric key pairs used to sign and verify issued JWTs', - compactLayout: ['id', 'created_at', 'expires_at'], + highlightFields: ['id', 'created_at', 'expires_at'], fields: { id: Field.text({ diff --git a/packages/platform-objects/src/identity/sys-member.object.ts b/packages/platform-objects/src/identity/sys-member.object.ts index a41be46d54..2bb17b2ee0 100644 --- a/packages/platform-objects/src/identity/sys-member.object.ts +++ b/packages/platform-objects/src/identity/sys-member.object.ts @@ -30,7 +30,7 @@ export const SysMember = ObjectSchema.create({ // '{user_id} in {organization_id}' format renders "… in null". User + role // identifies the membership in both single- and multi-org deployments. titleFormat: '{user_id} ({role})', - compactLayout: ['user_id', 'organization_id', 'role'], + highlightFields: ['user_id', 'organization_id', 'role'], // Row-level actions: better-auth `organization/update-member-role` and // `organization/remove-member`. Generic CRUD is suppressed on better-auth diff --git a/packages/platform-objects/src/identity/sys-oauth-access-token.object.ts b/packages/platform-objects/src/identity/sys-oauth-access-token.object.ts index a7a5c5ccca..1831fb3b69 100644 --- a/packages/platform-objects/src/identity/sys-oauth-access-token.object.ts +++ b/packages/platform-objects/src/identity/sys-oauth-access-token.object.ts @@ -31,7 +31,7 @@ export const SysOauthAccessToken = ObjectSchema.create({ docsUrl: 'https://docs.objectstack.ai/adr/0010-metadata-protection', }, description: 'Opaque OAuth access tokens issued to client applications', - compactLayout: ['client_id', 'user_id', 'expires_at'], + highlightFields: ['client_id', 'user_id', 'expires_at'], fields: { id: Field.text({ diff --git a/packages/platform-objects/src/identity/sys-oauth-application.object.ts b/packages/platform-objects/src/identity/sys-oauth-application.object.ts index 906f105fea..8793e2d9d7 100644 --- a/packages/platform-objects/src/identity/sys-oauth-application.object.ts +++ b/packages/platform-objects/src/identity/sys-oauth-application.object.ts @@ -36,7 +36,7 @@ export const SysOauthApplication = ObjectSchema.create({ displayNameField: 'name', nameField: 'name', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) titleFormat: '{name}', - compactLayout: ['name', 'client_id', 'type', 'disabled'], + highlightFields: ['name', 'client_id', 'type', 'disabled'], // Custom actions — all OAuth-application mutations are routed through // better-auth's `@better-auth/oauth-provider` endpoints (and a thin diff --git a/packages/platform-objects/src/identity/sys-oauth-consent.object.ts b/packages/platform-objects/src/identity/sys-oauth-consent.object.ts index 7d55474d20..58439a7f86 100644 --- a/packages/platform-objects/src/identity/sys-oauth-consent.object.ts +++ b/packages/platform-objects/src/identity/sys-oauth-consent.object.ts @@ -32,7 +32,7 @@ export const SysOauthConsent = ObjectSchema.create({ docsUrl: 'https://docs.objectstack.ai/adr/0010-metadata-protection', }, description: 'User consent records for OAuth client applications', - compactLayout: ['client_id', 'user_id', 'scopes'], + highlightFields: ['client_id', 'user_id', 'scopes'], fields: { id: Field.text({ diff --git a/packages/platform-objects/src/identity/sys-oauth-refresh-token.object.ts b/packages/platform-objects/src/identity/sys-oauth-refresh-token.object.ts index 2782ef02fa..f7da8b1780 100644 --- a/packages/platform-objects/src/identity/sys-oauth-refresh-token.object.ts +++ b/packages/platform-objects/src/identity/sys-oauth-refresh-token.object.ts @@ -30,7 +30,7 @@ export const SysOauthRefreshToken = ObjectSchema.create({ docsUrl: 'https://docs.objectstack.ai/adr/0010-metadata-protection', }, description: 'Opaque OAuth refresh tokens (linked to a session)', - compactLayout: ['client_id', 'user_id', 'expires_at'], + highlightFields: ['client_id', 'user_id', 'expires_at'], fields: { id: Field.text({ diff --git a/packages/platform-objects/src/identity/sys-organization.object.ts b/packages/platform-objects/src/identity/sys-organization.object.ts index 7f6946bad2..fcdf756767 100644 --- a/packages/platform-objects/src/identity/sys-organization.object.ts +++ b/packages/platform-objects/src/identity/sys-organization.object.ts @@ -29,7 +29,7 @@ export const SysOrganization = ObjectSchema.create({ displayNameField: 'name', nameField: 'name', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) titleFormat: '{name}', - compactLayout: ['name', 'slug'], + highlightFields: ['name', 'slug'], // Custom actions — generic CRUD is suppressed (better-auth-managed), // but admins still need to create new orgs from the Setup app. diff --git a/packages/platform-objects/src/identity/sys-scim-provider.object.ts b/packages/platform-objects/src/identity/sys-scim-provider.object.ts index 312174dc38..cf5cff86c2 100644 --- a/packages/platform-objects/src/identity/sys-scim-provider.object.ts +++ b/packages/platform-objects/src/identity/sys-scim-provider.object.ts @@ -43,7 +43,7 @@ export const SysScimProvider = ObjectSchema.create({ displayNameField: 'provider_id', nameField: 'provider_id', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) titleFormat: '{provider_id}', - compactLayout: ['provider_id', 'organization_id'], + highlightFields: ['provider_id', 'organization_id'], listViews: { all: { diff --git a/packages/platform-objects/src/identity/sys-session.object.ts b/packages/platform-objects/src/identity/sys-session.object.ts index 646af30308..f84ae7b08b 100644 --- a/packages/platform-objects/src/identity/sys-session.object.ts +++ b/packages/platform-objects/src/identity/sys-session.object.ts @@ -33,7 +33,7 @@ export const SysSession = ObjectSchema.create({ displayNameField: 'user_id', nameField: 'user_id', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) titleFormat: 'Session — {user_id}', - compactLayout: ['user_id', 'ip_address', 'expires_at'], + highlightFields: ['user_id', 'ip_address', 'expires_at'], // Custom actions — sessions are managed by better-auth (generic CRUD // suppressed). "Sign out other devices" is the high-value self-service diff --git a/packages/platform-objects/src/identity/sys-sso-provider.object.ts b/packages/platform-objects/src/identity/sys-sso-provider.object.ts index b6812b91d5..902590ea32 100644 --- a/packages/platform-objects/src/identity/sys-sso-provider.object.ts +++ b/packages/platform-objects/src/identity/sys-sso-provider.object.ts @@ -57,7 +57,7 @@ export const SysSsoProvider = ObjectSchema.create({ displayNameField: 'provider_id', nameField: 'provider_id', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) titleFormat: '{provider_id}', - compactLayout: ['provider_id', 'issuer', 'domain'], + highlightFields: ['provider_id', 'issuer', 'domain'], // All mutations go through @better-auth/sso's endpoints under // /api/v1/auth/sso/* (register / delete-provider) rather than the generic diff --git a/packages/platform-objects/src/identity/sys-team-member.object.ts b/packages/platform-objects/src/identity/sys-team-member.object.ts index 0e23d7d71b..174f6df910 100644 --- a/packages/platform-objects/src/identity/sys-team-member.object.ts +++ b/packages/platform-objects/src/identity/sys-team-member.object.ts @@ -27,7 +27,7 @@ export const SysTeamMember = ObjectSchema.create({ }, description: 'Team membership records linking users to teams', titleFormat: '{user_id} in {team_id}', - compactLayout: ['user_id', 'team_id', 'created_at'], + highlightFields: ['user_id', 'team_id', 'created_at'], // Custom actions calling better-auth's team-member endpoints. Generic // CRUD is suppressed (managedBy: 'better-auth') so these are the diff --git a/packages/platform-objects/src/identity/sys-team.object.ts b/packages/platform-objects/src/identity/sys-team.object.ts index 1b572d1cd3..bc58983ba9 100644 --- a/packages/platform-objects/src/identity/sys-team.object.ts +++ b/packages/platform-objects/src/identity/sys-team.object.ts @@ -29,7 +29,7 @@ export const SysTeam = ObjectSchema.create({ displayNameField: 'name', nameField: 'name', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) titleFormat: '{name}', - compactLayout: ['name', 'organization_id'], + highlightFields: ['name', 'organization_id'], // Custom actions calling better-auth's team endpoints. Generic CRUD is // suppressed (managedBy: 'better-auth'), so these are the canonical diff --git a/packages/platform-objects/src/identity/sys-two-factor.object.ts b/packages/platform-objects/src/identity/sys-two-factor.object.ts index 19788d08df..66aee6c192 100644 --- a/packages/platform-objects/src/identity/sys-two-factor.object.ts +++ b/packages/platform-objects/src/identity/sys-two-factor.object.ts @@ -27,7 +27,7 @@ export const SysTwoFactor = ObjectSchema.create({ }, description: 'Two-factor authentication credentials', titleFormat: 'Two-factor for {user_id}', - compactLayout: ['user_id', 'created_at'], + highlightFields: ['user_id', 'created_at'], listViews: { mine: { diff --git a/packages/platform-objects/src/identity/sys-user-preference.object.ts b/packages/platform-objects/src/identity/sys-user-preference.object.ts index c57c98bae9..c1deb02f9d 100644 --- a/packages/platform-objects/src/identity/sys-user-preference.object.ts +++ b/packages/platform-objects/src/identity/sys-user-preference.object.ts @@ -29,7 +29,7 @@ export const SysUserPreference = ObjectSchema.create({ description: 'Per-user key-value preferences (theme, locale, etc.)', nameField: 'key', // [ADR-0079] canonical primary-title pointer (single-field titleFormat) titleFormat: '{key}', - compactLayout: ['user_id', 'key'], + highlightFields: ['user_id', 'key'], listViews: { mine: { diff --git a/packages/platform-objects/src/identity/sys-user.object.ts b/packages/platform-objects/src/identity/sys-user.object.ts index 22726b2f19..10307b6c35 100644 --- a/packages/platform-objects/src/identity/sys-user.object.ts +++ b/packages/platform-objects/src/identity/sys-user.object.ts @@ -30,7 +30,7 @@ export const SysUser = ObjectSchema.create({ displayNameField: 'name', nameField: 'name', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) titleFormat: '{name}', - compactLayout: ['name', 'email', 'email_verified'], + highlightFields: ['name', 'email', 'email_verified'], // Custom actions — generic CRUD is suppressed because user accounts are // managed by better-auth, but we still need first-class affordances for diff --git a/packages/platform-objects/src/identity/sys-verification.object.ts b/packages/platform-objects/src/identity/sys-verification.object.ts index 07b6fe851d..3d54808b18 100644 --- a/packages/platform-objects/src/identity/sys-verification.object.ts +++ b/packages/platform-objects/src/identity/sys-verification.object.ts @@ -27,7 +27,7 @@ export const SysVerification = ObjectSchema.create({ }, description: 'Email and phone verification tokens', titleFormat: 'Verification for {identifier}', - compactLayout: ['identifier', 'expires_at', 'created_at'], + highlightFields: ['identifier', 'expires_at', 'created_at'], fields: { id: Field.text({ diff --git a/packages/platform-objects/src/system/sys-secret.object.ts b/packages/platform-objects/src/system/sys-secret.object.ts index b588387536..aad9b06d53 100644 --- a/packages/platform-objects/src/system/sys-secret.object.ts +++ b/packages/platform-objects/src/system/sys-secret.object.ts @@ -37,7 +37,7 @@ export const SysSecret = ObjectSchema.create({ isSystem: true, managedBy: 'system', description: 'Cipher store referenced by sys_setting handles. Never holds plaintext.', - compactLayout: ['namespace', 'key', 'kms_key_id', 'version', 'rotated_at'], + highlightFields: ['namespace', 'key', 'kms_key_id', 'version', 'rotated_at'], listViews: { all: { type: 'grid', diff --git a/packages/platform-objects/src/system/sys-setting-audit.object.ts b/packages/platform-objects/src/system/sys-setting-audit.object.ts index af05c79a4d..f403928b2c 100644 --- a/packages/platform-objects/src/system/sys-setting-audit.object.ts +++ b/packages/platform-objects/src/system/sys-setting-audit.object.ts @@ -34,7 +34,7 @@ export const SysSettingAudit = ObjectSchema.create({ isSystem: true, managedBy: 'system', description: 'Append-only audit trail for SettingsService mutations.', - compactLayout: ['namespace', 'key', 'scope', 'action', 'actor_id', 'created_at'], + highlightFields: ['namespace', 'key', 'scope', 'action', 'actor_id', 'created_at'], listViews: { recent: { type: 'grid', diff --git a/packages/platform-objects/src/system/sys-setting.object.ts b/packages/platform-objects/src/system/sys-setting.object.ts index 3e4a651864..902f27614c 100644 --- a/packages/platform-objects/src/system/sys-setting.object.ts +++ b/packages/platform-objects/src/system/sys-setting.object.ts @@ -42,7 +42,7 @@ export const SysSetting = ObjectSchema.create({ displayNameField: 'key', nameField: 'key', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) titleFormat: '{namespace}.{key}', - compactLayout: ['namespace', 'key', 'scope', 'updated_at'], + highlightFields: ['namespace', 'key', 'scope', 'updated_at'], listViews: { by_namespace: { diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index 25bf545034..3bb3f4019e 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -247,6 +247,7 @@ "ExternalLookupSchema (const)", "ExternalTable (type)", "ExternalTableSchema (const)", + "FIELD_GROUP_SYSTEM_FIELDS (const)", "FILTER_OPERATORS (const)", "FeedActor (type)", "FeedActorSchema (const)", @@ -258,6 +259,8 @@ "Field (type)", "FieldChangeEntry (type)", "FieldChangeEntrySchema (const)", + "FieldGroupCollapse (type)", + "FieldGroupSection (interface)", "FieldInput (type)", "FieldMapping (type)", "FieldMappingSchema (const)", @@ -450,6 +453,7 @@ "defineMapping (function)", "defineObjectExtension (function)", "defineSeed (function)", + "deriveFieldGroupLayout (function)", "fieldForm (const)", "hasDynamicTokens (function)", "hookForm (const)", diff --git a/packages/spec/liveness/object.json b/packages/spec/liveness/object.json index 09c04f1345..a7172e160e 100644 --- a/packages/spec/liveness/object.json +++ b/packages/spec/liveness/object.json @@ -37,7 +37,15 @@ }, "compactLayout": { "status": "live", - "note": "objectui hover/cards/lookups." + "note": "[DEPRECATED → highlightFields, ADR-0085] parse-time alias copied onto highlightFields; preserved on output for back-compat." + }, + "highlightFields": { + "status": "live", + "note": "ADR-0085 semantic role (renamed from compactLayout): objectui default list/grid columns (ObjectGrid/ObjectView/InterfaceListPage), child-record previews (first entry), record:details auto layout, detail highlight strip (first 4)." + }, + "stageField": { + "status": "live", + "note": "ADR-0085 semantic role: objectui plugin-detail detectStatusField drives the record:path stepper; string names the lifecycle field, false suppresses heuristic stage detection." }, "fieldGroups": { "status": "live", @@ -47,10 +55,6 @@ "status": "live", "note": "objectui segmented tabs." }, - "detail": { - "status": "live", - "note": "objectui plugin-detail (detail.renderViaSchema)." - }, "fields": { "status": "live", "evidence": "packages/objectql/src/engine.ts", diff --git a/packages/spec/src/data/field-group-layout.test.ts b/packages/spec/src/data/field-group-layout.test.ts new file mode 100644 index 0000000000..851ccabfbd --- /dev/null +++ b/packages/spec/src/data/field-group-layout.test.ts @@ -0,0 +1,117 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { deriveFieldGroupLayout, FIELD_GROUP_SYSTEM_FIELDS } from './field-group-layout'; + +describe('deriveFieldGroupLayout (ADR-0085 §5)', () => { + const groupedDef = { + name: 'account', + fieldGroups: [ + { key: 'basic', label: '基本信息' }, + { key: 'finance', label: '财务', collapse: 'collapsed' }, + { key: 'unused', label: 'Empty group' }, + ], + fields: { + name: { label: 'Name', type: 'text', group: 'basic' }, + industry: { label: 'Industry', type: 'select', group: 'basic' }, + revenue: { label: 'Revenue', type: 'currency', group: 'finance' }, + website: { label: 'Website', type: 'url' }, + secret: { label: 'Secret', type: 'text', group: 'basic', hidden: true }, + created_at: { label: 'Created', type: 'datetime' }, + organization_id: { label: 'Org', type: 'text' }, + }, + }; + + it('returns sections in declared order, drops empty declared groups', () => { + const sections = deriveFieldGroupLayout(groupedDef)!; + expect(sections.map((s) => s.key)).toEqual(['basic', 'finance', undefined]); + expect(sections[0].label).toBe('基本信息'); + expect(sections[0].fields).toEqual(['name', 'industry']); + expect(sections.some((s) => s.key === 'unused')).toBe(false); + }); + + it('passes collapse through and defaults it to none', () => { + const sections = deriveFieldGroupLayout(groupedDef)!; + expect(sections[0].collapse).toBe('none'); + expect(sections[1].collapse).toBe('collapsed'); + }); + + it('honours the deprecated collapse aliases on un-normalized metadata', () => { + const legacy = (extra: Record) => + deriveFieldGroupLayout({ + fieldGroups: [{ key: 'g', label: 'G', ...extra }], + fields: { a: { group: 'g' } }, + })![0].collapse; + expect(legacy({ collapsible: true, collapsed: true })).toBe('collapsed'); + expect(legacy({ collapsible: true })).toBe('expanded'); + expect(legacy({ collapsible: false })).toBe('none'); + expect(legacy({ defaultExpanded: false })).toBe('collapsed'); + expect(legacy({ defaultExpanded: true })).toBe('expanded'); + // Canonical key wins over any alias. + expect(legacy({ collapse: 'none', collapsed: true })).toBe('none'); + }); + + it('collects ungrouped fields into a trailing untitled bucket, skipping system fields', () => { + const sections = deriveFieldGroupLayout(groupedDef)!; + const trailing = sections[sections.length - 1]; + expect(trailing.key).toBeUndefined(); + expect(trailing.label).toBeUndefined(); + expect(trailing.fields).toEqual(['website']); + expect(FIELD_GROUP_SYSTEM_FIELDS.has('created_at')).toBe(true); + }); + + it('keeps system fields an author EXPLICITLY grouped', () => { + const sections = deriveFieldGroupLayout({ + fieldGroups: [{ key: 'meta', label: 'Meta' }], + fields: { + title: { type: 'text' }, + created_at: { type: 'datetime', group: 'meta' }, + }, + })!; + expect(sections[0].fields).toEqual(['created_at']); + }); + + it('skips hidden fields even when grouped', () => { + const sections = deriveFieldGroupLayout(groupedDef)!; + expect(sections.find((s) => s.key === 'basic')!.fields).not.toContain('secret'); + }); + + it('carries icon and description through', () => { + const sections = deriveFieldGroupLayout({ + fieldGroups: [{ key: 'g', label: 'G', icon: 'credit-card', description: 'Money things' }], + fields: { a: { group: 'g' } }, + })!; + expect(sections[0]).toMatchObject({ icon: 'credit-card', description: 'Money things' }); + }); + + it('returns null when grouping does not apply', () => { + // No fieldGroups at all. + expect(deriveFieldGroupLayout({ fields: { a: {} } })).toBeNull(); + // Declared groups but no field references one. + expect( + deriveFieldGroupLayout({ fieldGroups: [{ key: 'g1', label: 'G1' }], fields: { a: {}, b: {} } }), + ).toBeNull(); + // Malformed input. + expect(deriveFieldGroupLayout(undefined)).toBeNull(); + expect(deriveFieldGroupLayout(null)).toBeNull(); + expect(deriveFieldGroupLayout('nope')).toBeNull(); + expect(deriveFieldGroupLayout([])).toBeNull(); + }); + + it('ignores keyless / malformed group entries', () => { + expect( + deriveFieldGroupLayout({ + fieldGroups: [{ label: 'No key' }, null, 'junk'], + fields: { a: { group: 'x' } }, + }), + ).toBeNull(); + }); + + it('defaults label to the group key', () => { + const sections = deriveFieldGroupLayout({ + fieldGroups: [{ key: 'billing' }], + fields: { amount: { group: 'billing' } }, + })!; + expect(sections[0].label).toBe('billing'); + }); +}); diff --git a/packages/spec/src/data/field-group-layout.ts b/packages/spec/src/data/field-group-layout.ts new file mode 100644 index 0000000000..f7d4317447 --- /dev/null +++ b/packages/spec/src/data/field-group-layout.ts @@ -0,0 +1,143 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Field-group layout derivation — the single source of the `fieldGroups` + * rendering semantics (ADR-0085 §5). + * + * An object's `fieldGroups` + each field's `Field.group` membership are a + * cross-surface semantic role: forms, detail pages, drawers and the designer + * all render the SAME grouping. The rules live here — a pure, dependency-free + * helper next to the schema that defines the keys (the ADR-0078 §2 + * shared-predicate pattern) — so renderers consume one implementation instead + * of re-deriving it (two near-identical copies in `@object-ui` predate this + * module and are retired by it). + * + * Rules (per the ObjectFieldGroupSchema contract): + * - sections come back in declared-group order; + * - declared groups no visible field references are dropped; + * - fields without a (declared) group collect into a trailing untitled + * bucket, preserving field declaration order — EXCEPT audit/system + * fields, which only surface when an author EXPLICITLY groups them + * (explicit listing wins, same as authored page sections); + * - hidden fields never surface; + * - `collapse` passes through (deprecated `defaultExpanded` / + * `collapsible`+`collapsed` aliases are honoured for pre-ADR-0085 + * metadata that reaches consumers un-normalized, e.g. bare DB rows). + * + * Returns `null` when grouping does not apply — no declared groups, or no + * visible field references one — so callers fall back to their existing + * flat/auto layout. + */ + +/** Collapse behaviour of a derived section (mirrors ObjectFieldGroupSchema.collapse). */ +export type FieldGroupCollapse = 'none' | 'expanded' | 'collapsed'; + +/** One derived section. `key` is absent on the trailing ungrouped bucket. */ +export interface FieldGroupSection { + /** Group machine key; i18n anchor (`…objects.{obj}._sections.{key}.label`). Absent = ungrouped bucket. */ + key?: string; + /** Group display label (default text; i18n overrides at render time). */ + label?: string; + /** Optional icon name declared on the group. */ + icon?: string; + /** Optional description declared on the group. */ + description?: string; + /** Collapse behaviour; 'none' when the group declared nothing. */ + collapse: FieldGroupCollapse; + /** Member field NAMES in field-declaration order. Renderers resolve defs themselves. */ + fields: string[]; +} + +/** + * Audit/system fields excluded from the derived UNGROUPED bucket (they carry + * no business meaning in a default layout). A field an author explicitly + * assigns to a group is kept. Exported so renderers filtering flat layouts + * agree with the derivation. + */ +export const FIELD_GROUP_SYSTEM_FIELDS: ReadonlySet = new Set([ + 'created_at', 'created_by', 'updated_at', 'updated_by', + 'organization_id', 'tenant_id', 'is_deleted', 'deleted_at', +]); + +type AnyRec = Record; + +/** Normalize one declared group entry; null for malformed/keyless entries. */ +function readGroup(g: unknown): { key: string; label?: string; icon?: string; description?: string; collapse: FieldGroupCollapse } | null { + if (!g || typeof g !== 'object' || Array.isArray(g)) return null; + const grp = g as AnyRec; + if (typeof grp.key !== 'string' || grp.key.length === 0) return null; + let collapse: FieldGroupCollapse = 'none'; + if (grp.collapse === 'expanded' || grp.collapse === 'collapsed' || grp.collapse === 'none') { + collapse = grp.collapse; + } else if (typeof grp.collapsible === 'boolean' || typeof grp.collapsed === 'boolean') { + // Deprecated UI-dialect pair (pre-ADR-0085 designer metadata). + collapse = grp.collapsed === true ? 'collapsed' : grp.collapsible === true ? 'expanded' : 'none'; + } else if (typeof grp.defaultExpanded === 'boolean') { + // Deprecated spec flag. + collapse = grp.defaultExpanded ? 'expanded' : 'collapsed'; + } + return { + key: grp.key, + label: typeof grp.label === 'string' ? grp.label : undefined, + icon: typeof grp.icon === 'string' ? grp.icon : undefined, + description: typeof grp.description === 'string' ? grp.description : undefined, + collapse, + }; +} + +/** + * Derive the grouped layout for an object definition (or any bare metadata + * record shaped like one — the helper is deliberately tolerant of + * un-parsed/legacy input so every consumer can call it). + */ +export function deriveFieldGroupLayout(def: unknown): FieldGroupSection[] | null { + if (!def || typeof def !== 'object' || Array.isArray(def)) return null; + const obj = def as AnyRec; + + const declared = (Array.isArray(obj.fieldGroups) ? obj.fieldGroups : []) + .map(readGroup) + .filter((g): g is NonNullable> => g !== null); + if (declared.length === 0) return null; + + const declaredKeys = new Set(declared.map((g) => g.key)); + const fields = (obj.fields && typeof obj.fields === 'object' && !Array.isArray(obj.fields)) + ? (obj.fields as Record) + : {}; + + const buckets = new Map(); + for (const g of declared) buckets.set(g.key, []); + const ungrouped: string[] = []; + let anyGrouped = false; + for (const [name, f] of Object.entries(fields)) { + if (f?.hidden === true) continue; + const g = typeof f?.group === 'string' && declaredKeys.has(f.group) ? (f.group as string) : null; + if (g) { + buckets.get(g)!.push(name); + anyGrouped = true; + } else if (!FIELD_GROUP_SYSTEM_FIELDS.has(name)) { + ungrouped.push(name); + } + } + // No visible field references a declared group → grouping doesn't apply. + if (!anyGrouped) return null; + + const sections: FieldGroupSection[] = []; + for (const g of declared) { + const names = buckets.get(g.key)!; + if (names.length === 0) continue; // declared-but-empty groups are dropped + sections.push({ + key: g.key, + label: g.label ?? g.key, + ...(g.icon !== undefined ? { icon: g.icon } : {}), + ...(g.description !== undefined ? { description: g.description } : {}), + collapse: g.collapse, + fields: names, + }); + } + // Trailing untitled bucket: ungrouped fields render flat (no key/label → + // renderers show no card chrome) after the declared groups. + if (ungrouped.length > 0) { + sections.push({ collapse: 'none', fields: ungrouped }); + } + return sections.length > 0 ? sections : null; +} diff --git a/packages/spec/src/data/index.ts b/packages/spec/src/data/index.ts index 7450ac7771..6ec2c6e1ad 100644 --- a/packages/spec/src/data/index.ts +++ b/packages/spec/src/data/index.ts @@ -49,6 +49,10 @@ export * from './aggregation-policy'; // enrichment, search field resolution, and lint. export * from './display-name'; +// fieldGroups layout derivation (ADR-0085 §5) — the single source of the +// grouping semantics every renderer (form, detail, drawer, designer) applies. +export * from './field-group-layout'; + // Feed & Activity Protocol export * from './feed.zod'; diff --git a/packages/spec/src/data/object.test.ts b/packages/spec/src/data/object.test.ts index 9ab4e047bf..f7a9157053 100644 --- a/packages/spec/src/data/object.test.ts +++ b/packages/spec/src/data/object.test.ts @@ -848,11 +848,10 @@ describe('ObjectFieldGroupSchema', () => { const result = ObjectFieldGroupSchema.parse(group); expect(result.key).toBe('contact_info'); expect(result.label).toBe('Contact Information'); - // defaultExpanded defaults to true - expect(result.defaultExpanded).toBe(true); + // collapse defaults to 'none' (ADR-0085) + expect(result.collapse).toBe('none'); expect(result.icon).toBeUndefined(); expect(result.description).toBeUndefined(); - expect(result.visibleOn).toBeUndefined(); }); it('should accept a fully-specified group', () => { @@ -861,11 +860,16 @@ describe('ObjectFieldGroupSchema', () => { label: 'Billing', icon: 'credit-card', description: 'Billing and payment details', - defaultExpanded: false, - visibleOn: '$user.isAdmin', + collapse: 'collapsed' as const, }; const result = ObjectFieldGroupSchema.parse(group); - expect(result).toEqual({ ...group, visibleOn: { dialect: 'cel', source: '$user.isAdmin' } }); + expect(result).toEqual(group); + }); + + it('should reject an invalid collapse value', () => { + expect(() => + ObjectFieldGroupSchema.parse({ key: 'billing', label: 'Billing', collapse: 'maybe' }), + ).toThrow(); }); it('should reject missing key or label', () => { @@ -881,6 +885,63 @@ describe('ObjectFieldGroupSchema', () => { }); }); +// ================================================================= +// Object-level semantic roles (ADR-0085) +// ================================================================= + +describe('ObjectSchema semantic roles (ADR-0085)', () => { + it('accepts stageField as a string or literal false, rejects other values', () => { + expect(ObjectSchema.parse({ name: 'lead', fields: {}, stageField: 'status' }).stageField).toBe('status'); + expect(ObjectSchema.parse({ name: 'lead', fields: {}, stageField: false }).stageField).toBe(false); + expect(ObjectSchema.safeParse({ name: 'lead', fields: {}, stageField: true }).success).toBe(false); + expect(ObjectSchema.safeParse({ name: 'lead', fields: {}, stageField: 3 }).success).toBe(false); + }); + + it('accepts highlightFields and aliases the deprecated compactLayout onto it', () => { + const direct = ObjectSchema.parse({ + name: 'account', fields: {}, highlightFields: ['name', 'industry'], + }); + expect(direct.highlightFields).toEqual(['name', 'industry']); + // Transition mirror: old-key readers (current objectui) still see + // compactLayout for metadata authored with the canonical name. + expect(direct.compactLayout).toEqual(['name', 'industry']); + + const aliased = ObjectSchema.parse({ + name: 'account', fields: {}, compactLayout: ['name', 'industry'], + }); + expect(aliased.highlightFields).toEqual(['name', 'industry']); + // Deprecated key preserved on output (ADR-0079 alias pattern). + expect(aliased.compactLayout).toEqual(['name', 'industry']); + + // Canonical key wins when both are present. + const both = ObjectSchema.parse({ + name: 'account', fields: {}, highlightFields: ['a'], compactLayout: ['b'], + }); + expect(both.highlightFields).toEqual(['a']); + }); + + it('rejects the removed detail UI-hints block at create()', () => { + expect(() => + ObjectSchema.create({ + name: 'product', + fields: {}, + // @ts-expect-error — `detail` was removed by ADR-0085 + detail: { hideReferenceRail: true }, + }), + ).toThrow(/detail/); + }); + + it('strips the removed detail block on safeParse (no key on output)', () => { + const result = ObjectSchema.safeParse({ + name: 'product', fields: {}, detail: { renderViaSchema: false }, + }); + expect(result.success).toBe(true); + if (result.success) { + expect((result.data as Record).detail).toBeUndefined(); + } + }); +}); + describe('ObjectSchema.fieldGroups', () => { it('should accept an object without fieldGroups (fully optional)', () => { const result = ObjectSchema.safeParse({ @@ -950,8 +1011,27 @@ describe('ObjectSchema.fieldGroups', () => { ], }); expect(obj.fieldGroups).toEqual([ - { key: 'workflow', label: 'Workflow', icon: 'workflow', defaultExpanded: true }, + { key: 'workflow', label: 'Workflow', icon: 'workflow', collapse: 'none' }, + ]); + }); + + // ADR-0085: deprecated collapse aliases normalize onto the enum at parse. + it('maps deprecated defaultExpanded / collapsible+collapsed onto collapse', () => { + const parsed = ObjectSchema.parse({ + name: 'account', + fields: { a: { type: 'text', group: 'g1' } }, + fieldGroups: [ + { key: 'g1', label: 'G1', defaultExpanded: false }, + { key: 'g2', label: 'G2', collapsible: true, collapsed: true }, + { key: 'g3', label: 'G3', collapsible: true }, + { key: 'g4', label: 'G4', collapse: 'none', collapsed: true }, // canonical wins + ], + }); + expect(parsed.fieldGroups?.map((g) => g.collapse)).toEqual([ + 'collapsed', 'collapsed', 'expanded', 'none', ]); + // Deprecated keys are preserved on output (cross-repo back-compat). + expect(parsed.fieldGroups?.[0].defaultExpanded).toBe(false); }); describe('External Binding (ADR-0015)', () => { diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index 1cd3e7aab3..3918f0458f 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -9,7 +9,7 @@ import { ListViewSchema } from '../ui/view.zod'; /** * API Operations Enum */ -import { ExpressionInputSchema , TemplateExpressionInputSchema } from '../shared/expression.zod'; +import { TemplateExpressionInputSchema } from '../shared/expression.zod'; import { lazySchema } from '../shared/lazy-schema'; import { MetadataProtectionFields } from '../kernel/metadata-protection.zod'; import { ProtectionSchema } from '../shared/protection.zod'; @@ -224,14 +224,22 @@ export const VersioningConfigSchema = lazySchema(() => z.object({ * Deferred (not part of MVP): * - explicit per-field in-group ordering * - nested groups / sub-groups - * - permission-scoped group visibility beyond `visibleOn` - * + * - group-level visibility predicates (a `visibleOn` key existed here + * briefly with no consumer anywhere; removed per ADR-0085 / ADR-0049 + * enforce-or-remove — re-add together with its enforcement when a + * surface actually evaluates it) + * + * Derivation semantics (declared order, empty groups dropped, ungrouped + * trailing bucket, collapse passthrough) are single-sourced in + * `deriveFieldGroupLayout` (field-group-layout.ts, ADR-0085 §5) — UI + * renderers consume that helper instead of re-implementing the rules. + * * @example * ```ts * fieldGroups: [ * { key: 'contact_info', label: 'Contact Information', icon: 'user' }, - * { key: 'billing', label: 'Billing', defaultExpanded: false }, - * { key: 'system', label: 'System', visibleOn: '$user.isAdmin' }, + * { key: 'billing', label: 'Billing', collapse: 'collapsed' }, + * { key: 'system', label: 'System' }, * ] * ``` */ @@ -250,11 +258,27 @@ export const ObjectFieldGroupSchema = lazySchema(() => z.object({ /** Optional description / help text shown under the group header. */ description: z.string().optional().describe('Optional description shown under the group header'), - /** Whether the group is expanded by default. Defaults to `true`. */ - defaultExpanded: z.boolean().optional().default(true).describe('Whether the group is expanded by default'), + /** + * [ADR-0085] Collapse behaviour of the group's rendered section, on every + * surface (form, detail, drawer). One enum, three valid states — replaces + * the old `defaultExpanded` flag AND the UI-dialect `collapsible`/`collapsed` + * boolean pair, which could express contradictions and had drifted between + * spec and renderer (spec declared a key no renderer read; renderers read + * keys the spec rejected). + */ + collapse: z.enum(['none', 'expanded', 'collapsed']).optional().default('none') + .describe("[ADR-0085] Section collapse behaviour: 'none' (always open, no toggle), 'expanded' (collapsible, starts open), 'collapsed' (collapsible, starts closed)."), - /** Optional visibility expression — when false, the entire group is hidden (e.g., "$user.isAdmin", "status == \'closed\'"). */ - visibleOn: ExpressionInputSchema.optional().describe('Visibility predicate (CEL); group is hidden when FALSE.'), + /** + * @deprecated [ADR-0085 → `collapse`] Accepted as a parse-time alias: + * `defaultExpanded: false` maps to `collapse: 'collapsed'`, `true` to + * `'expanded'`, when `collapse` is absent. New metadata sets `collapse`. + */ + defaultExpanded: z.boolean().optional().describe("[DEPRECATED → collapse] true → 'expanded', false → 'collapsed'."), + /** @deprecated [ADR-0085 → `collapse`] UI-dialect alias (pair with `collapsed`); mapped onto `collapse` at parse. */ + collapsible: z.boolean().optional().describe("[DEPRECATED → collapse] Boolean pair with `collapsed`; use the `collapse` enum."), + /** @deprecated [ADR-0085 → `collapse`] UI-dialect alias (pair with `collapsible`); mapped onto `collapse` at parse. */ + collapsed: z.boolean().optional().describe("[DEPRECATED → collapse] Boolean pair with `collapsible`; use the `collapse` enum."), })); export type ObjectFieldGroup = z.infer; @@ -570,7 +594,33 @@ const ObjectSchemaBase = z.object({ startNumber: z.number().int().min(0).optional().describe('Starting number for autonumber (default: 1)'), }).optional().describe('Record name generation configuration (Salesforce pattern)'), titleFormat: TemplateExpressionInputSchema.optional().describe('[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 to a formula field designated as nameField.'), - compactLayout: z.array(z.string()).optional().describe('Primary fields for hover/cards/lookups'), + /** + * [ADR-0085] Semantic role: the object's most important fields, in priority + * order (the first entry wins wherever only one field fits, e.g. child-record + * previews). Cross-surface by definition — drives default list/grid columns, + * cards, hover/lookup previews, and the record-detail highlight strip (first + * 4). Renamed from `compactLayout` (the value is an ordered field list, not + * a layout); Salesforce compact-layout semantics. + */ + highlightFields: z.array(z.string()).optional().describe('[ADR-0085] Ordered most-important fields; first entry wins where only one fits. Drives default columns, cards, previews, detail highlight strip. Renamed from compactLayout.'), + /** + * @deprecated [ADR-0085 → `highlightFields`] Accepted as a parse-time alias: + * copied onto `highlightFields` when that key is absent (both preserved on + * output for cross-repo back-compat, the ADR-0079 alias pattern). New + * metadata sets `highlightFields`. + */ + compactLayout: z.array(z.string()).optional().describe('[DEPRECATED → highlightFields] Accepted as an alias for highlightFields.'), + /** + * [ADR-0085] Semantic role: the field that represents the record's LINEAR + * lifecycle (an ordered pipeline / stage progression). A string names the + * field; `false` declares the object's status-like field NON-linear (an + * unordered state set such as active/suspended/void) and suppresses every + * consumer's stage heuristics. Absent = consumers may heuristically detect + * a stage field (status/stage/state/phase). Consumed by the record-detail + * path/stepper today; kanban default grouping, list badges and report + * bucketing are natural future consumers. + */ + stageField: z.union([z.string(), z.literal(false)]).optional().describe('[ADR-0085] Lifecycle stage field (linear/ordered), or false to declare the status field non-linear and suppress stage heuristics. Absent = heuristic detection allowed.'), /** * Built-in List Views @@ -689,30 +739,13 @@ const ObjectSchemaBase = z.object({ /** Key Prefix */ keyPrefix: z.string().max(5).optional().describe('Short prefix for record IDs (e.g., "001" for Account)'), - /** - * Detail-page UI hints - * - * Per-object overrides for the synthesized record detail page (kind:'auto' - * / slotted defaults). Each flag is consumed by the UI runtime - * (`@object-ui/plugin-detail`'s `buildDefaultPageSchema`) when no explicit - * `Page` covers this object. Authored full Pages should set these regions - * directly instead. - * - * - `renderViaSchema=false` opts the object out of the schema-driven - * rendering path entirely (legacy `RecordDetailView`). - * - `hideReferenceRail=true` suppresses the right-hand reference-rail - * (related-list summary cards) so the form gets full width — useful for - * catalog/atomic objects (Product, Task) where lateral relationships - * aren't the user's main job. - * - `hideRelatedTab=true` suppresses the "Related" tab. By default the - * synth removes the Related tab automatically when the rail is shown to - * avoid duplication; set this flag to force hide/show regardless. - */ - detail: z.object({ - renderViaSchema: z.boolean().optional().describe('Opt this object out of the schema-driven detail renderer'), - hideReferenceRail: z.boolean().optional().describe('Suppress the right-hand reference-rail on the detail page'), - hideRelatedTab: z.boolean().optional().describe('Suppress the Related tab on the detail page'), - }).passthrough().optional().describe('Detail-page UI hints consumed by @object-ui/plugin-detail synth'), + // [ADR-0085] The former `detail: { … }.passthrough()` UI-hints block is + // REMOVED. Presentation intent lives in the cross-surface semantic roles + // above (nameField / highlightFields / stageField / fieldGroups); per-page + // control is an assigned Page. The passthrough block bred silently-inert + // keys (9 read by renderers vs 3 typed; the typed `hideReferenceRail` was + // itself a no-op for spec authors) — see the ADR for the full inventory. + // `renderViaSchema` retires together with the legacy monolith render path. /** * Object Actions @@ -867,6 +900,58 @@ function normalizeNameFieldAlias(input: unknown): unknown { return input; } +/** + * [ADR-0085] Parse-time alias normalization for the semantic-role renames + * (same pattern as `normalizeNameFieldAlias`; deprecated keys are PRESERVED + * on output for cross-repo back-compat): + * + * - `compactLayout` ⇄ `highlightFields`: whichever key is present is mirrored + * onto the other (canonical wins when both exist). The BACK-fill + * (highlightFields → compactLayout) is a transition mirror so renderers + * that still read the old key (current objectui / vendored console) keep + * their default columns for metadata authored with the new name; it is + * removed together with the alias. + * - `fieldGroups[].collapse` derived from the deprecated flags when absent: + * the UI-dialect `collapsible`/`collapsed` pair wins over the old + * `defaultExpanded` (it is what designer-authored metadata actually + * carries); mapping: collapsed:true → 'collapsed'; collapsible:true → + * 'expanded'; collapsible:false → 'none'; defaultExpanded:false → + * 'collapsed'; defaultExpanded:true → 'expanded'. + */ +function normalizeSemanticRoleAliases(input: unknown): unknown { + if (!input || typeof input !== 'object' || Array.isArray(input)) return input; + const obj = input as Record; + let out = obj; + + if (obj.highlightFields == null && Array.isArray(obj.compactLayout)) { + out = { ...out, highlightFields: obj.compactLayout }; + } else if (obj.compactLayout == null && Array.isArray(obj.highlightFields)) { + // Transition mirror for old-key readers (see doc comment above). + out = { ...out, compactLayout: obj.highlightFields }; + } + + if (Array.isArray(obj.fieldGroups)) { + let changed = false; + const groups = (obj.fieldGroups as unknown[]).map((g) => { + if (!g || typeof g !== 'object' || Array.isArray(g)) return g; + const grp = g as Record; + if (grp.collapse != null) return g; + let collapse: string | undefined; + if (typeof grp.collapsible === 'boolean' || typeof grp.collapsed === 'boolean') { + collapse = grp.collapsed === true ? 'collapsed' : grp.collapsible === true ? 'expanded' : 'none'; + } else if (typeof grp.defaultExpanded === 'boolean') { + collapse = grp.defaultExpanded ? 'expanded' : 'collapsed'; + } + if (collapse === undefined) return g; + changed = true; + return { ...grp, collapse }; + }); + if (changed) out = { ...out, fieldGroups: groups }; + } + + return out; +} + /** * Enhanced ObjectSchema with Factory */ @@ -883,10 +968,10 @@ export const ObjectSchema = lazySchema(() => { * so `.shape` / `.create()`'s internal `ObjectSchemaBase.parse` keep working. */ parse(data: unknown, params?: Parameters[1]) { - return baseParse(normalizeNameFieldAlias(data), params); + return baseParse(normalizeSemanticRoleAliases(normalizeNameFieldAlias(data)), params); }, safeParse(data: unknown, params?: Parameters[1]) { - return baseSafeParse(normalizeNameFieldAlias(data), params); + return baseSafeParse(normalizeSemanticRoleAliases(normalizeNameFieldAlias(data)), params); }, /** * Type-safe factory for creating business object definitions.