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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions .changeset/adr-0085-semantic-roles.md
Original file line number Diff line number Diff line change
@@ -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).
4 changes: 2 additions & 2 deletions content/docs/guides/data-modeling.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion content/docs/guides/metadata/object.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand Down
2 changes: 1 addition & 1 deletion content/docs/guides/standards.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
4 changes: 2 additions & 2 deletions content/docs/references/ui/component.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 |


Expand Down
2 changes: 1 addition & 1 deletion examples/app-todo/src/objects/task.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
{
Expand Down
17 changes: 16 additions & 1 deletion packages/cli/src/commands/lint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
}

Expand Down
5 changes: 4 additions & 1 deletion packages/dogfood/test/expression-conformance.ledger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
8 changes: 8 additions & 0 deletions packages/lint/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
100 changes: 100 additions & 0 deletions packages/lint/src/validate-semantic-roles.test.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
Loading