From c94dd94cb7651ab9daa7c673b5b506ee3467bdf5 Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Thu, 2 Jul 2026 22:46:28 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat(spec):=20ADR-0085=20semantic=20roles?= =?UTF-8?q?=20=E2=80=94=20stageField,=20highlightFields=20rename,=20detail?= =?UTF-8?q?=20block=20removal,=20collapse=20enum,=20shared=20fieldGroups?= =?UTF-8?q?=20derivation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Executes ADR-0085 (#2520) in the spec layer: - ObjectSchema gains stageField: string | false (lifecycle role; false suppresses stage heuristics) — legitimizes the key renderers already read. - compactLayout → highlightFields (ordered most-important fields; drives default columns, cards, previews, detail highlight strip). Old key stays a parse-time alias, preserved on output (ADR-0079 pattern). 35 platform objects + app-todo migrated. - fieldGroups[].collapse enum replaces defaultExpanded and the UI-dialect collapsible/collapsed pair (both aliased at parse for one minor); visibleOn removed (zero consumers, ADR-0049 enforce-or-remove). - detail passthrough UI-hints block removed (zero authors; the typed hideReferenceRail was a proven no-op; renderViaSchema retires with the legacy monolith render path in objectui). - New @objectstack/spec/data deriveFieldGroupLayout — single source of the grouping semantics (declared order, empty groups dropped, ungrouped trailing bucket minus system fields, collapse passthrough incl. aliases). - New @objectstack/lint validateSemanticRoles (wired into os lint): warns on dangling Field.group / empty declared groups / unknown stageField & highlightFields names (ADR-0078 completeness gate). - Liveness ledger updated (detail dropped; highlightFields + stageField registered live; compactLayout marked deprecated alias). spec 6666 tests, lint 112, cli 440, platform-objects 63 all green; api-surface unchanged; check:liveness green; app-todo typecheck green. Co-Authored-By: Claude Fable 5 --- .changeset/adr-0085-semantic-roles.md | 53 ++++++ examples/app-todo/src/objects/task.object.ts | 2 +- packages/cli/src/commands/lint.ts | 17 +- packages/lint/src/index.ts | 8 + .../lint/src/validate-semantic-roles.test.ts | 100 +++++++++++ packages/lint/src/validate-semantic-roles.ts | 160 ++++++++++++++++++ .../src/audit/sys-attachment.object.ts | 2 +- .../src/audit/sys-email-template.object.ts | 2 +- .../src/audit/sys-email.object.ts | 2 +- .../src/audit/sys-import-job.object.ts | 2 +- .../src/audit/sys-job-queue.object.ts | 2 +- .../src/audit/sys-job-run.object.ts | 2 +- .../src/audit/sys-job.object.ts | 2 +- .../src/audit/sys-notification.object.ts | 2 +- .../src/audit/sys-report-schedule.object.ts | 2 +- .../src/audit/sys-saved-report.object.ts | 2 +- .../src/identity/sys-account.object.ts | 2 +- .../src/identity/sys-api-key.object.ts | 2 +- .../sys-business-unit-member.object.ts | 2 +- .../src/identity/sys-business-unit.object.ts | 2 +- .../src/identity/sys-device-code.object.ts | 2 +- .../src/identity/sys-invitation.object.ts | 2 +- .../src/identity/sys-jwks.object.ts | 2 +- .../src/identity/sys-member.object.ts | 2 +- .../identity/sys-oauth-access-token.object.ts | 2 +- .../identity/sys-oauth-application.object.ts | 2 +- .../src/identity/sys-oauth-consent.object.ts | 2 +- .../sys-oauth-refresh-token.object.ts | 2 +- .../src/identity/sys-organization.object.ts | 2 +- .../src/identity/sys-scim-provider.object.ts | 2 +- .../src/identity/sys-session.object.ts | 2 +- .../src/identity/sys-sso-provider.object.ts | 2 +- .../src/identity/sys-team-member.object.ts | 2 +- .../src/identity/sys-team.object.ts | 2 +- .../src/identity/sys-two-factor.object.ts | 2 +- .../identity/sys-user-preference.object.ts | 2 +- .../src/identity/sys-user.object.ts | 2 +- .../src/identity/sys-verification.object.ts | 2 +- .../src/system/sys-secret.object.ts | 2 +- .../src/system/sys-setting-audit.object.ts | 2 +- .../src/system/sys-setting.object.ts | 2 +- packages/spec/api-surface.json | 4 + packages/spec/liveness/object.json | 14 +- .../spec/src/data/field-group-layout.test.ts | 117 +++++++++++++ packages/spec/src/data/field-group-layout.ts | 143 ++++++++++++++++ packages/spec/src/data/index.ts | 4 + packages/spec/src/data/object.test.ts | 91 +++++++++- packages/spec/src/data/object.zod.ts | 149 ++++++++++++---- 48 files changed, 847 insertions(+), 85 deletions(-) create mode 100644 .changeset/adr-0085-semantic-roles.md create mode 100644 packages/lint/src/validate-semantic-roles.test.ts create mode 100644 packages/lint/src/validate-semantic-roles.ts create mode 100644 packages/spec/src/data/field-group-layout.test.ts create mode 100644 packages/spec/src/data/field-group-layout.ts 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/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/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..c8e0f56f53 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,60 @@ 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']); + + 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 +1008,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..38c17c1504 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,50 @@ 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` when the canonical key is absent. + * - `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 }; + } + + 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 +960,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. From 09f42344f8385f345e1a8f6b1aaf9998141f7d7b Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Thu, 2 Jul 2026 22:54:02 +0800 Subject: [PATCH 2/4] docs: teach highlightFields/stageField in place of compactLayout (ADR-0085) Co-Authored-By: Claude Fable 5 --- content/docs/guides/data-modeling.mdx | 4 ++-- content/docs/guides/metadata/object.mdx | 3 ++- content/docs/guides/standards.mdx | 2 +- content/docs/references/ui/component.mdx | 4 ++-- 4 files changed, 7 insertions(+), 6 deletions(-) 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 | From 981bd668d92045f9d9cb26f28028003c82e327f6 Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Thu, 2 Jul 2026 23:29:40 +0800 Subject: [PATCH 3/4] fix(spec): mirror highlightFields back onto compactLayout during transition Browser dogfood of the rename found a live gap: metadata authored with the canonical highlightFields key parsed to output WITHOUT compactLayout, so current renderers that still read the old key (ObjectGrid default columns, card compact views, vendored console) silently lost their curated field list. The alias now mirrors both directions (canonical wins when both are present); the back-fill is removed together with the deprecated key. Verified end-to-end in a running app (app-todo, HMR console against the worktree server): default list columns, mobile card fields, detail highlight strip, stageField stepper, and field-group form sections all render from the renamed keys. Co-Authored-By: Claude Fable 5 --- packages/spec/src/data/object.test.ts | 3 +++ packages/spec/src/data/object.zod.ts | 10 +++++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/spec/src/data/object.test.ts b/packages/spec/src/data/object.test.ts index c8e0f56f53..f7a9157053 100644 --- a/packages/spec/src/data/object.test.ts +++ b/packages/spec/src/data/object.test.ts @@ -902,6 +902,9 @@ describe('ObjectSchema semantic roles (ADR-0085)', () => { 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'], diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index 38c17c1504..3918f0458f 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -905,7 +905,12 @@ function normalizeNameFieldAlias(input: unknown): unknown { * (same pattern as `normalizeNameFieldAlias`; deprecated keys are PRESERVED * on output for cross-repo back-compat): * - * - `compactLayout` → `highlightFields` when the canonical key is absent. + * - `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 @@ -920,6 +925,9 @@ function normalizeSemanticRoleAliases(input: unknown): unknown { 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)) { From 0420f63df5644313bb925aec3e9613f3a215fcc1 Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Thu, 2 Jul 2026 23:44:03 +0800 Subject: [PATCH 4/4] test(dogfood): drop stale visibleOn row from expression ledger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-0085 §3 removed fieldGroups[].visibleOn from ObjectSchema (declared, zero consumers — enforce-or-remove per ADR-0049). The ADR-0060 ratchet correctly flagged the ledger row as a stale cover; drop it until the key returns with a real enforcement path. Co-Authored-By: Claude Fable 5 --- packages/dogfood/test/expression-conformance.ledger.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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',