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
56 changes: 56 additions & 0 deletions .changeset/record-details-sections-object-form.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
---
"@objectstack/spec": minor
---

feat(spec)!: `RecordDetailsProps.sections` declares the object form every page actually authors, and `hideFields` is declared (#5611)

`record:details` declared a `sections` shape that nothing produced and nothing
consumed, and omitted a key a published platform page depends on. Both are now
declared as delivered.

**BREAKING (authored metadata shape) — `sections` is an object array, not an ID list.**

```ts
// FROM — declared, but written by zero pages and read by zero renderers
sections: ['overview', 'financials']

// TO — what every real page already authors
sections: [
{ label: 'Overview', columns: 2, fields: ['name', 'account', 'owner'] },
{ label: 'Financials', columns: 2, fields: ['budget', 'spent'] },
]
```

One-line fix: replace each section ID with `{ fields: [...] }`, naming the
fields that section should render (add `label` for a heading, `columns` for its
grid width, `name` to make the heading translatable).

**Why this is safe despite being a type change — it was measured, not assumed.**
The ID-list form had **zero** read paths and **zero** producers:

- `objectui`'s `RecordDetailsRenderer` maps every `sections` entry as an object
(`s.name` / `s.label` / `s.title` / `s.fields`) and has no string branch — a
string entry would spread into a character map and render nothing;
- `@object-ui/types`' `RecordDetailsComponentProps` mirror already declared
`Array<{ name?, label?, fields, ... }>`, and the Studio block designer can only
author `{ label, columns, fields }`;
- every page in this repo authors the object form — three showcase pages
(`project-detail`, `task-detail`, `settings`) and the `sys_user` platform page;
- `packages/lint` has modelled it as `nestedSections` (`sections[].fields[]`) all
along.

So the "breakage" applies only to hypothetical stored metadata written against a
declaration nothing ever honoured, and schema validation runs on the publish
path — it does not rewrite data at rest.

**New: `hideFields`.** `z.array(z.string()).optional()` — field names omitted
from the body, applied to `fields` and to every section's `fields`. The
`sys_user` platform page has authored it since it shipped and the renderer reads
it; it was undeclared, so it survived only because per-component `properties` is
never parsed. Declaring it now means the parse gate (#5068) preserves it instead
of silently stripping a live page's hidden-field list.

**Section keys**, each declared because it is delivered end to end:
`fields` (required), `label`, `columns` (1-4), and `name` — the i18n anchor that
resolves `objects.<object>._sections.<name>.label`, which `packages/lint`'s
`translation-section-name-missing` rule tells authors to add.
3 changes: 2 additions & 1 deletion content/docs/references/ui/component.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -291,8 +291,9 @@ const result = AIChatWindowProps.parse(data);
| :--- | :--- | :--- | :--- |
| **columns** | `Enum<'1' \| '2' \| '3' \| '4'>` | ✅ | Number of columns for field layout (1-4) |
| **layout** | `Enum<'auto' \| 'custom'>` | ✅ | Layout mode: auto uses object highlightFields, custom uses explicit sections |
| **sections** | `string[]` | optional | Section IDs to show (required when layout is "custom") |
| **sections** | `{ name?: string; label?: string; columns?: integer; fields: string[] }[]` | optional | Field groups rendered as the detail body, in order. Object form: `{ name?, label?, columns?, fields }`. |
| **fields** | `string[]` | optional | Explicit field list to display (optional, overrides highlightFields) |
| **hideFields** | `string[]` | optional | Field names to omit from the body — applied to `fields` and to every section's `fields` (used to dedupe fields already shown in `record:highlights` or as the page title) |
| **aria** | `{ ariaLabel?: string; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes |


Expand Down
22 changes: 11 additions & 11 deletions docs/audits/2026-07-unknown-key-strictness-ledger.counts.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ regenerate.
| Measure | Value |
|---|---|
| Triaged directories | 5 |
| Object sites in them | 455 |
| Still-open (strip) sites | 196 |
| Object sites in them | 456 |
| Still-open (strip) sites | 197 |
| Files carrying at least one | 30 |

Remaining strip sites by class:
Expand All @@ -33,7 +33,7 @@ Remaining strip sites by class:
| unresolved — needs a per-schema verdict | 33 |
| wire / open — out of forced scope | 107 |
| no door — no carrier, ADR-0049 territory | 14 |
| no gate — carrier live, no parse | 29 |
| no gate — carrier live, no parse | 30 |

## Posture, per triaged directory

Expand All @@ -43,12 +43,12 @@ The `strict` column is the one the campaign schedules against; it counts both th

| Dir | Sites | strict | passthrough | catchall | strip |
|---|---|---|---|---|---|
| `ui/` | 171 | 116 | 5 | 0 | 50 |
| `ui/` | 172 | 116 | 5 | 0 | 51 |
| `data/` | 162 | 54 | 1 | 0 | 107 |
| `automation/` | 75 | 49 | 0 | 0 | 26 |
| `security/` | 20 | 7 | 0 | 0 | 13 |
| `studio/` | 27 | 27 | 0 | 0 | 0 |
| **total** | **455** | **253** | **6** | **0** | **196** |
| **total** | **456** | **253** | **6** | **0** | **197** |

## File-level triage — site counts

Expand All @@ -65,7 +65,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit
| `app.zod.ts` | 18 |
| `bulk-action.zod.ts` | 3 |
| `chart.zod.ts` | 8 |
| `component.zod.ts` | 29 |
| `component.zod.ts` | 30 |
| `dashboard.zod.ts` | 11 |
| `dataset.zod.ts` | 4 |
| `i18n.zod.ts` | 6 |
Expand All @@ -76,7 +76,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit
| `theme.zod.ts` | 6 |
| `view.zod.ts` | 53 |
| `widget.zod.ts` | 9 |
| **total** | **171** |
| **total** | **172** |

### `data/` — sites

Expand Down Expand Up @@ -156,26 +156,26 @@ over it is here.

### `ui/` — open

**50 strip of 171**, in 7 file(s).
**51 strip of 172**, in 7 file(s).

| File | Strip | Sites |
|---|---|---|
| `action-params.zod.ts` | 1 | 1 |
| `app.zod.ts` | 1 | 18 |
| `chart.zod.ts` | 2 | 8 |
| `component.zod.ts` | 29 | 29 |
| `component.zod.ts` | 30 | 30 |
| `i18n.zod.ts` | 5 | 6 |
| `view.zod.ts` | 3 | 53 |
| `widget.zod.ts` | 9 | 9 |
| **total** | **50** | **171** |
| **total** | **51** | **172** |

| Bucket | Sites |
|---|---|
| authorable — the ruling's forced scope | 4 |
| unresolved — needs a per-schema verdict | 0 |
| wire / open — out of forced scope | 3 |
| no door — no carrier, ADR-0049 territory | 14 |
| no gate — carrier live, no parse | 29 |
| no gate — carrier live, no parse | 30 |

### `data/` — open

Expand Down
5 changes: 3 additions & 2 deletions packages/lint/src/validate-page-field-bindings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,9 @@ describe('validatePageFieldBindings — highlights / KPI cards', () => {
});

describe('validatePageFieldBindings — record:details real authored shape', () => {
// Real pages author `sections: [{ label, fields }]`, which RecordDetailsProps
// does not describe (it survives because `properties` is unvalidated).
// Real pages author `sections: [{ label, fields }]` — the shape
// `RecordDetailsProps` now declares too (#5611; it used to declare an ID
// `string[]` that no page and no renderer ever used).
it('walks sections[].fields[]', () => {
const findings = validatePageFieldBindings({
...baseStack(),
Expand Down
15 changes: 10 additions & 5 deletions packages/lint/src/validate-page-field-bindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,8 +164,11 @@ export interface ComponentFieldSpec {

export const COMPONENT_FIELD_SPECS: Readonly<Record<string, ComponentFieldSpec>> = {
'record:highlights': { props: ['fields'] },
// `sections`/`hideFields` are not in RecordDetailsProps, but every real page
// authors them (they survive because `properties` is unvalidated).
// `sections` (object form) and `hideFields` are what every real page authors,
// and since #5611 they are what `RecordDetailsProps` declares — this model and
// the spec agree. (Before that, `sections` was declared as an ID `string[]`
// and `hideFields` not at all; both survived only because `properties` is
// unvalidated.)
'record:details': { props: ['fields', 'hideFields'], nestedSections: ['sections'] },
'record:path': { props: ['statusField'] },
'element:number': { props: ['field'] },
Expand Down Expand Up @@ -208,9 +211,11 @@ export function componentFieldRefs(
const sections = Array.isArray(props[key]) ? (props[key] as unknown[]) : [];
for (let si = 0; si < sections.length; si++) {
const section = sections[si];
// A `sections` that is a plain `string[]` (the shape `RecordDetailsProps`
// actually declares — section IDs) yields nothing here, which is right:
// those are not field names.
// A non-object entry yields nothing here, which is right: lint runs on
// unvalidated `properties`, so it must survive off-spec input rather than
// throw on it. (Until #5611 the ID `string[]` this skips was the shape
// `RecordDetailsProps` declared; it declares the object form now, so this
// is a defensive guard rather than a divergence from the spec.)
if (!isRec(section)) continue;
refs.push(...fieldRefsFrom(section.fields, `${basePath}${sep}${key}[${si}].fields`));
}
Expand Down
1 change: 1 addition & 0 deletions packages/spec/authorable-surface.json
Original file line number Diff line number Diff line change
Expand Up @@ -7703,6 +7703,7 @@
"ui/RecordDetailsProps:aria",
"ui/RecordDetailsProps:columns",
"ui/RecordDetailsProps:fields",
"ui/RecordDetailsProps:hideFields",
"ui/RecordDetailsProps:layout",
"ui/RecordDetailsProps:sections",
"ui/RecordHighlightsProps:aria",
Expand Down
84 changes: 79 additions & 5 deletions packages/spec/src/ui/component.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,14 +149,88 @@ describe('RecordDetailsProps', () => {
expect(result.sections).toBeUndefined();
});

it('should accept custom layout with sections', () => {
const details = { columns: '3' as const, layout: 'custom' as const, sections: ['sec-1', 'sec-2'] };
expect(() => RecordDetailsProps.parse(details)).not.toThrow();
});

it('should reject invalid column value', () => {
expect(() => RecordDetailsProps.parse({ columns: '5' })).toThrow();
});

// #5611: `sections` is the OBJECT form — the only form any page authors and
// the only form any renderer reads. These fixtures are lifted verbatim from
// the real pages so the schema is pinned to authored reality, not to a shape
// invented here. Before this change every one of them was an `invalid_type`
// rejection at `sections[0]` (the old `z.array(z.string())`), and the whole
// `hideFields` key was silently stripped.
it('accepts the showcase section shape verbatim (project-detail.page.ts:49)', () => {
const details = {
layout: 'custom' as const,
sections: [
{ label: 'Overview', columns: 2, fields: ['name', 'account', 'owner', 'status'] },
{ label: 'Financials', columns: 2, fields: ['budget', 'spent'] },
{ label: 'Timeline', columns: 2, fields: ['start_date', 'end_date'] },
],
};
const result = RecordDetailsProps.parse(details);
expect(result.sections).toHaveLength(3);
expect(result.sections?.[0]).toEqual({
label: 'Overview',
columns: 2,
fields: ['name', 'account', 'owner', 'status'],
});
// `columns: 1` is authored too (task-detail.page.ts:76).
expect(() =>
RecordDetailsProps.parse({ sections: [{ label: 'Details', columns: 1, fields: ['notes'] }] }),
).not.toThrow();
});

it('accepts a section with no columns (sys-user.page.ts:118)', () => {
const result = RecordDetailsProps.parse({
sections: [{ label: 'Identity', fields: ['name', 'image'] }],
});
expect(result.sections?.[0].columns).toBeUndefined();
expect(result.sections?.[0].fields).toEqual(['name', 'image']);
});

it('accepts an untitled section and a `name`-anchored one', () => {
// No label: the renderer draws it borderless. No name: it is untranslatable
// by construction, which is what `translation-section-name-missing` reports.
expect(() => RecordDetailsProps.parse({ sections: [{ fields: ['notes'] }] })).not.toThrow();
// `name` is the i18n anchor a lint rule tells authors to add, so the schema
// must accept it — the rule and the schema cannot disagree.
const named = RecordDetailsProps.parse({
sections: [{ name: 'identity', label: 'Identity', fields: ['name'] }],
});
expect(named.sections?.[0].name).toBe('identity');
});

it('requires `fields` on every section', () => {
const r = RecordDetailsProps.safeParse({ sections: [{ label: 'Empty' }] });
expect(r.success).toBe(false);
expect(r.success === false && r.error.issues[0].path).toEqual(['sections', 0, 'fields']);
});

it('rejects the retired ID-list form rather than silently half-reading it', () => {
const r = RecordDetailsProps.safeParse({ layout: 'custom', sections: ['overview'] });
expect(r.success).toBe(false);
expect(r.success === false && r.error.issues[0].code).toBe('invalid_type');
expect(r.success === false && r.error.issues[0].path).toEqual(['sections', 0]);
});

it('rejects an out-of-range section column count', () => {
expect(() =>
RecordDetailsProps.parse({ sections: [{ label: 'Wide', columns: 5, fields: ['a'] }] }),
).toThrow();
});

it('preserves hideFields verbatim (sys-user.page.ts:106)', () => {
// Undeclared until #5611, so a non-strict `z.object` dropped it on the
// floor: the platform page's hidden-field list survived only because
// nothing ever parsed these props.
const hideFields = ['id', 'banned', 'ban_reason', 'ban_expires', 'email', 'role'];
const result = RecordDetailsProps.parse({
hideFields,
sections: [{ label: 'Audit', fields: ['created_at', 'updated_at'] }],
});
expect(result.hideFields).toEqual(hideFields);
});
});

describe('RecordRelatedListProps', () => {
Expand Down
61 changes: 56 additions & 5 deletions packages/spec/src/ui/component.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,10 +76,13 @@ import { FeedItemType, FeedFilterMode } from '../data/feed.zod';
// gate, not to close schemas nobody calls — filed as #5068, which also
// records the two constraints that stop it being a drive-by: `type` is an open
// union (unregistered types like `record:line_items` are authored in the wild),
// and real pages already author shapes these schemas do not declare
// (`record:details` `sections[].fields[]` / `hideFields[]`, the record picker's
// `labelField` — see `packages/lint/src/validate-page-field-bindings.ts`, which
// has documented the untyped bag all along).
// and real pages already author shapes these schemas do not declare (the record
// picker's `labelField` — see `packages/lint/src/validate-page-field-bindings.ts`,
// which has documented the untyped bag all along). `record:details`
// `sections[]` / `hideFields[]` WAS the largest such divergence and is now
// closed: #5611 re-declared `sections` in the object form every page actually
// authors and declared `hideFields`, so wiring the gate no longer turns three
// showcase pages and the `sys_user` platform page into hard parse errors.
//
// When #5068 lands, this file becomes `authorable` and the ratchet applies. The
// verdict is pinned in `component.test.ts` and in the `ui/` tables of
Expand Down Expand Up @@ -157,8 +160,56 @@ export const PageCardProps = z.object({
export const RecordDetailsProps = z.object({
columns: z.enum(['1', '2', '3', '4']).default('2').describe('Number of columns for field layout (1-4)'),
layout: z.enum(['auto', 'custom']).default('auto').describe('Layout mode: auto uses object highlightFields, custom uses explicit sections'),
sections: z.array(z.string()).optional().describe('Section IDs to show (required when layout is "custom")'),
/**
* Field groups rendered as the detail body, IN ORDER.
*
* Declared as the object form because that is the only form anything
* delivers or authors (#5611). Until 17.x this key was `z.array(z.string())`
* — "section IDs" — which no page in this repo, and no read path in
* `objectui`, has ever used: `RecordDetailsRenderer` maps every entry as an
* object (`s.name` / `s.label` / `s.fields`) with no string branch anywhere,
* `@object-ui/types`' `RecordDetailsComponentProps` mirror declares the
* object form, and the Studio block designer can only author
* `{label, columns, fields}`. The ID-list spelling was a declaration with no
* producer and no consumer, so it is gone rather than unioned in: one shape,
* not two de-facto contracts (Prime Directive #12).
*/
sections: z.array(z.object({
/**
* Stable section identifier, snake_case. This is the i18n anchor: the
* heading resolves through `objects.<object>._sections.<name>.label`, so a
* section WITHOUT a name renders its authored `label` in every locale.
* `packages/lint`'s `translation-section-name-missing` rule exists to tell
* authors to add it, which is why it is declared here — a key one rule
* demands must not be a key the schema rejects.
*/
name: z.string().optional().describe('Stable section identifier for i18n lookup (snake_case) — resolves `objects.<object>._sections.<name>.label`; a nameless section renders its authored label in every locale'),
/** Heading text. Omit for an untitled section, which renders borderless. */
label: I18nLabelSchema.optional().describe('Section heading (omit for an untitled, borderless section)'),
/**
* Field-grid width for THIS section; falls back to the renderer's own
* derivation when omitted.
*
* An int range rather than `z.union([z.literal(1), …])` — same accepted set
* (1-4), but the docs generator renders numeric literals as QUOTED strings
* (`'1' | '2'`, see `FormSectionSchema.columns` in `references/ui/view.mdx`),
* which would tell an author to write `columns: '2'` where this key requires
* `2`. Shipping a reference that misdocuments the key is the exact harm
* #5611 is fixing, so the shape that documents itself truthfully wins.
*/
columns: z.number().int().min(1).max(4).optional().describe('Field-grid columns for this section (1-4). Omitted → the renderer derives the width.'),
/** Field names shown in this section, in order. */
fields: z.array(z.string()).describe('Field names rendered in this section, in order'),
})).optional().describe('Field groups rendered as the detail body, in order. Object form: `{ name?, label?, columns?, fields }`.'),
fields: z.array(z.string()).optional().describe('Explicit field list to display (optional, overrides highlightFields)'),
/**
* Field names to omit from the body, applied to both `fields` and every
* section's `fields`. Authored by the published `sys_user` platform page and
* read by `RecordDetailsRenderer`; it was simply never declared, so the
* (unvalidated) props bag carried it. Declared now so the enforcement to come
* does not silently strip a live platform page's hidden-field list.
*/
hideFields: z.array(z.string()).optional().describe('Field names to omit from the body — applied to `fields` and to every section\'s `fields` (used to dedupe fields already shown in `record:highlights` or as the page title)'),
/** ARIA accessibility */
aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes'),
});
Expand Down
Loading