Skip to content

Commit 730fd9a

Browse files
os-zhuangclaude
andauthored
feat(spec): declare record:reference_rail in ComponentPropsMap — undeclared rail keys refused (#8691) (#8745)
* feat(spec): declare record:reference_rail in ComponentPropsMap — undeclared rail keys refused (#8691) - ui/component.zod.ts: strict RecordReferenceRailProps + ReferenceRailEntrySchema, key set measured from the renderer's read points at the .objectui-sha pin (entries[]{objectName, relationshipField, title?, limit?, displayField?} + component-level hideEmpty); the interface's icon is read by nothing and is a guidance refusal, not a declared key; title is a literal string (raw React child), not I18nLabelSchema - pins: spec-side (component-reference-rail.test.ts) + lint-side dispatch pin (validate-component-props.test.ts) reproducing the card's planted filter in both directions - semantic migration entry under protocol-18 + regenerated registry - changeset: minor + BREAKING + ADR-0087 disposition (the #8666/#8695 precedent) - regenerated: api-surface, export-origins, docs references, strictness ledger, authorable-surface, json-schema.manifest Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E5tUwGM3LQoqErTfkvRW7W * test(spec): ADR-0122 isomorphic pins for the rail schemas (Iso845/Iso846) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E5tUwGM3LQoqErTfkvRW7W --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a4acb8d commit 730fd9a

14 files changed

Lines changed: 523 additions & 17 deletions

File tree

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): declare `record:reference_rail` in `ComponentPropsMap` — undeclared rail keys are refused (#8691)
6+
7+
**BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep
8+
launch-window convention ships it as `minor`; the migration prescription is
9+
registered under protocol major 18, where `os migrate meta` users will look).
10+
11+
`record:reference_rail` had a registered renderer, a `PageComponentType` entry
12+
and a console palette slot, but no row in `ComponentPropsMap` — so the #5068
13+
component-props gate's dispatch skipped it as unregistered and every authored
14+
key rode through. Measured on 17.0.0 GA end to end: a planted entry `filter`
15+
passed tsc, `objectstack validate` and `objectstack build`, shipped verbatim in
16+
`dist/objectstack.json`, and the rendered rail kept counting and listing
17+
unfiltered rows — while the very same build loudly reported
18+
`record:related_list` keys in the same file.
19+
20+
The new row is strict and declares exactly the shape the renderer reads
21+
(measured from its read points at the objectui pin, not from its TS
22+
interface): `entries[]` of `{ objectName, relationshipField, title?, limit?,
23+
displayField? }` plus a component-level `hideEmpty`.
24+
25+
**What is refused:** any key the shape does not declare, with a prescriptive
26+
message — the planted `filter` (the rail issues one fixed query per entry;
27+
`record:related_list` is where `filter` is real), the interface's `icon` (read
28+
by no render path — declaring it would be declared-but-unenforced surface),
29+
entry-level `hideEmpty` (a component-level key), and the neighbouring-surface
30+
spellings `items`/`related``entries`, `object``objectName`, `label`
31+
`title`. `title` is a literal `z.string()` — the renderer paints it as a raw
32+
React child, so an inline locale map is refused rather than shipped as
33+
`[object Object]`.
34+
35+
**What stays accepted:** every declared key byte-identically. `limit` and
36+
`hideEmpty` carry no schema default (the renderer's `3` / `true` fallbacks stay
37+
the renderer's), so a minimal entry round-trips unchanged.
38+
39+
## FROM → TO
40+
41+
```ts
42+
// before — parsed green everywhere; the badge kept counting everything
43+
{
44+
type: 'record:reference_rail',
45+
properties: {
46+
entries: [{
47+
objectName: 'task', relationshipField: 'project_id',
48+
filter: [{ field: 'status', op: 'neq', value: 'completed' }], // silent no-op
49+
icon: 'CheckSquare', // read by nothing
50+
}],
51+
},
52+
}
53+
54+
// after — both keys are publish-time refusals with prescriptions; write only
55+
// what the renderer reads
56+
{
57+
type: 'record:reference_rail',
58+
properties: {
59+
entries: [{ objectName: 'task', relationshipField: 'project_id', limit: 3 }],
60+
hideEmpty: false,
61+
},
62+
}
63+
```
64+
65+
There is deliberately no automatic rewrite: an undeclared key is either a
66+
spelling of a declared one (the rejection names the rename) or names a
67+
capability the rail does not deliver — a per-entry `filter` and an inline
68+
`title` locale map are open capability questions for the console seat, and
69+
blessing either spelling now would be declared-but-unenforced surface
70+
(ADR-0078). `os migrate meta` surfaces the change as a structured TODO
71+
(semantic entry `ui-reference-rail-unknown-keys-refused`, protocol major 18 —
72+
this refusal is not part of the v17.0.0 cut).
73+
74+
<!-- adr-0087: registered ui-reference-rail-unknown-keys-refused -->

content/docs/references/index.mdx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
---
22
title: Protocol Reference
3-
description: Every schema published by @objectstack/spec — 1577 schemas across 14 protocol modules
3+
description: Every schema published by @objectstack/spec — 1579 schemas across 14 protocol modules
44
---
55

66
{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}
@@ -32,8 +32,8 @@ counts are sums of the rows they head. Regenerate with
3232
| [Shared Protocol](/docs/references/shared) | 8 | 31 | Primitives used across every protocol — identifiers, HTTP, expressions, error maps, enums. |
3333
| [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. |
3434
| [System Protocol](/docs/references/system) | 36 | 287 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. |
35-
| [UI Protocol](/docs/references/ui) | 16 | 154 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. |
36-
| **Total** | **198** | **1577** | 14 protocol modules |
35+
| [UI Protocol](/docs/references/ui) | 16 | 156 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. |
36+
| **Total** | **198** | **1579** | 14 protocol modules |
3737

3838
---
3939

@@ -364,7 +364,7 @@ The runtime environment — logging, jobs, cache, metrics, notifications, i18n a
364364

365365
## UI Protocol
366366

367-
**Source:** `packages/spec/src/ui/` · **Import:** `@objectstack/spec/ui` · **16 pages, 154 schemas**
367+
**Source:** `packages/spec/src/ui/` · **Import:** `@objectstack/spec/ui` · **16 pages, 156 schemas**
368368

369369
Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer.
370370

@@ -375,7 +375,7 @@ Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI lay
375375
| [`app.zod.ts`](/docs/references/ui/app) | `ActionNavItem`, `App`, `AppBranding`, `AppContextSelector`, `ComponentNavItem`, `DashboardNavItem`, `GroupNavItem`, `NavigationArea`, `NavigationContribution`, `NavigationItem`, `ObjectNavItem`, `PageNavItem`, `ReportNavItem`, `UrlNavItem` |
376376
| [`bulk-action.zod.ts`](/docs/references/ui/bulk-action) | `BulkActionDef`, `BulkActionExecution`, `BulkActionOperation`, `BulkActionParam` |
377377
| [`chart.zod.ts`](/docs/references/ui/chart) | `ChartAggregate`, `ChartAggregateFunction`, `ChartAnnotation`, `ChartAxis`, `ChartConfig`, `ChartDrillDown`, `ChartGroupBy`, `ChartInteraction`, `ChartSeries`, `ChartType` |
378-
| [`component.zod.ts`](/docs/references/ui/component) | `AIChatWindowProps`, `ElementButtonProps`, `ElementFilterProps`, `ElementFormProps`, `ElementImageProps`, `ElementMetadataViewerProps`, `ElementNumberProps`, `ElementRecordPickerProps`, `ElementTextInputProps`, `ElementTextProps`, `ObjectCalendarProps`, `ObjectFormProps`, `ObjectGridProps`, `ObjectKanbanProps`, `ObjectMasterDetailFormProps`, `ObjectMetricProps`, `PageAccordionProps`, `PageCardProps`, `PageContainerProps`, `PageHeaderProps`, `PageTabsProps`, `RecordActivityProps`, `RecordChatterProps`, `RecordDetailsProps`, `RecordHighlightsField`, `RecordHighlightsProps`, `RecordPathProps`, `RecordRelatedListProps` |
378+
| [`component.zod.ts`](/docs/references/ui/component) | `AIChatWindowProps`, `ElementButtonProps`, `ElementFilterProps`, `ElementFormProps`, `ElementImageProps`, `ElementMetadataViewerProps`, `ElementNumberProps`, `ElementRecordPickerProps`, `ElementTextInputProps`, `ElementTextProps`, `ObjectCalendarProps`, `ObjectFormProps`, `ObjectGridProps`, `ObjectKanbanProps`, `ObjectMasterDetailFormProps`, `ObjectMetricProps`, `PageAccordionProps`, `PageCardProps`, `PageContainerProps`, `PageHeaderProps`, `PageTabsProps`, `RecordActivityProps`, `RecordChatterProps`, `RecordDetailsProps`, `RecordHighlightsField`, `RecordHighlightsProps`, `RecordPathProps`, `RecordReferenceRailProps`, `RecordRelatedListProps`, `ReferenceRailEntry` |
379379
| [`dashboard.zod.ts`](/docs/references/ui/dashboard) | `Dashboard`, `DashboardHeader`, `DashboardHeaderAction`, `DashboardWidget`, `DashboardWidgetOptions`, `GlobalFilter`, `GlobalFilterOptionsFrom`, `WidgetActionType`, `WidgetColorVariant` |
380380
| [`dataset.zod.ts`](/docs/references/ui/dataset) | `Dataset`, `DatasetDimension`, `DatasetMeasure`, `DerivedMeasureOp` |
381381
| [`i18n.zod.ts`](/docs/references/ui/i18n) | `AriaProps`, `I18nLabel`, `InlineLocaleMap` |

content/docs/references/ui/component.mdx

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ Empty Properties Schema
1414
## TypeScript Usage
1515

1616
```typescript
17-
import { AIChatWindowProps, ElementButtonPropsSchema, ElementFilterPropsSchema, ElementFormPropsSchema, ElementImagePropsSchema, ElementMetadataViewerPropsSchema, ElementNumberPropsSchema, ElementRecordPickerPropsSchema, ElementTextInputPropsSchema, ElementTextPropsSchema, ObjectCalendarPropsSchema, ObjectFormPropsSchema, ObjectGridPropsSchema, ObjectKanbanPropsSchema, ObjectMasterDetailFormPropsSchema, ObjectMetricPropsSchema, PageAccordionProps, PageCardProps, PageContainerProps, PageHeaderProps, PageTabsProps, RecordActivityProps, RecordChatterProps, RecordDetailsProps, RecordHighlightsField, RecordHighlightsProps, RecordPathProps, RecordRelatedListProps } from '@objectstack/spec/ui';
18-
import type { ElementNumberProps, ElementRecordPickerProps, ObjectCalendarProps, ObjectFormProps, ObjectGridProps, ObjectKanbanProps, ObjectMasterDetailFormProps, ObjectMetricProps, PageContainerProps, RecordHighlightsField, RecordPathProps } from '@objectstack/spec/ui';
17+
import { AIChatWindowProps, ElementButtonPropsSchema, ElementFilterPropsSchema, ElementFormPropsSchema, ElementImagePropsSchema, ElementMetadataViewerPropsSchema, ElementNumberPropsSchema, ElementRecordPickerPropsSchema, ElementTextInputPropsSchema, ElementTextPropsSchema, ObjectCalendarPropsSchema, ObjectFormPropsSchema, ObjectGridPropsSchema, ObjectKanbanPropsSchema, ObjectMasterDetailFormPropsSchema, ObjectMetricPropsSchema, PageAccordionProps, PageCardProps, PageContainerProps, PageHeaderProps, PageTabsProps, RecordActivityProps, RecordChatterProps, RecordDetailsProps, RecordHighlightsField, RecordHighlightsProps, RecordPathProps, RecordReferenceRailProps, RecordRelatedListProps, ReferenceRailEntrySchema } from '@objectstack/spec/ui';
18+
import type { ElementNumberProps, ElementRecordPickerProps, ObjectCalendarProps, ObjectFormProps, ObjectGridProps, ObjectKanbanProps, ObjectMasterDetailFormProps, ObjectMetricProps, PageContainerProps, RecordHighlightsField, RecordPathProps, RecordReferenceRailProps, ReferenceRailEntry } from '@objectstack/spec/ui';
1919

2020
// Validate data
2121
const result = AIChatWindowProps.parse(data);
@@ -573,6 +573,18 @@ Type: `string`
573573
| **aria** | `{ ariaLabel?: string \| Record<string, string>; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes |
574574

575575

576+
---
577+
578+
## RecordReferenceRailProps
579+
580+
### Properties
581+
582+
| Property | Type | Required | Description |
583+
| :--- | :--- | :--- | :--- |
584+
| **entries** | `{ objectName: string; relationshipField: string; title?: string; limit?: integer; … }[]` || Related collections to summarize — one compact card per entry (icon-less title, total-count badge, top-N preview rows). An empty rail renders nothing, so at least one entry is required. |
585+
| **hideEmpty** | `boolean` | optional | Fold entries whose related count is 0 into a single "+ N empty" expander chip (renderer default: on; set `false` to always render every card). |
586+
587+
576588
---
577589

578590
## RecordRelatedListProps
@@ -597,3 +609,18 @@ Type: `string`
597609

598610
---
599611

612+
## ReferenceRailEntry
613+
614+
### Properties
615+
616+
| Property | Type | Required | Description |
617+
| :--- | :--- | :--- | :--- |
618+
| **objectName** | `string` || Related object name whose records this card summarizes (e.g. "task", "opportunity_quote") |
619+
| **relationshipField** | `string` || Field on the related object that points back to this record (e.g. "account_id") |
620+
| **title** | `string` | optional | Literal card title. Rendered as-is in EVERY locale (no inline locale map — the rail renders it as a raw React child); omit to use the related object's localized label. |
621+
| **limit** | `integer` | optional | Preview rows per card, and the `$top` of the one query this entry issues (renderer default: 3). |
622+
| **displayField** | `string` | optional | Field of the related record rendered in each preview row (renderer fallback when omitted: name / title / subject / label / … / id). |
623+
624+
625+
---
626+

docs/audits/2026-07-unknown-key-strictness-ledger.counts.md

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ regenerate.
2121
| Measure | Value |
2222
|---|---|
2323
| Triaged directories | 5 |
24-
| Object sites in them | 433 |
24+
| Object sites in them | 435 |
2525
| Still-open (strip) sites | 124 |
2626
| Files carrying at least one | 23 |
2727

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

4545
| Dir | Sites | strict | passthrough | catchall | strip |
4646
|---|---|---|---|---|---|
47-
| `ui/` | 168 | 157 | 5 | 0 | 6 |
47+
| `ui/` | 170 | 159 | 5 | 0 | 6 |
4848
| `data/` | 153 | 70 | 1 | 0 | 82 |
4949
| `automation/` | 65 | 42 | 0 | 0 | 23 |
5050
| `security/` | 20 | 7 | 0 | 0 | 13 |
5151
| `studio/` | 27 | 27 | 0 | 0 | 0 |
52-
| **total** | **433** | **303** | **6** | **0** | **124** |
52+
| **total** | **435** | **305** | **6** | **0** | **124** |
5353

5454
## File-level triage — site counts
5555

@@ -66,7 +66,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit
6666
| `app.zod.ts` | 18 |
6767
| `bulk-action.zod.ts` | 3 |
6868
| `chart.zod.ts` | 8 |
69-
| `component.zod.ts` | 37 |
69+
| `component.zod.ts` | 39 |
7070
| `dashboard.zod.ts` | 11 |
7171
| `dataset.zod.ts` | 4 |
7272
| `i18n.zod.ts` | 1 |
@@ -77,7 +77,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit
7777
| `theme.zod.ts` | 6 |
7878
| `view.zod.ts` | 55 |
7979
| `widget.zod.ts` | 1 |
80-
| **total** | **168** |
80+
| **total** | **170** |
8181

8282
### `data/` — sites
8383

@@ -156,15 +156,15 @@ over it is here.
156156

157157
### `ui/` — open
158158

159-
**6 strip of 168**, in 4 file(s).
159+
**6 strip of 170**, in 4 file(s).
160160

161161
| File | Strip | Sites |
162162
|---|---|---|
163163
| `action-params.zod.ts` | 1 | 1 |
164164
| `app.zod.ts` | 1 | 18 |
165165
| `view.zod.ts` | 3 | 55 |
166166
| `widget.zod.ts` | 1 | 1 |
167-
| **total** | **6** | **168** |
167+
| **total** | **6** | **170** |
168168

169169
| Bucket | Sites |
170170
|---|---|

packages/lint/src/validate-component-props.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -616,3 +616,52 @@ describe('validateComponentProps — a STRICT union arm reports as an unknown ke
616616
expect(highlights([{ name: 'status', label: 'Status', readonly: true }])).toEqual([]);
617617
});
618618
});
619+
620+
/**
621+
* #8691 — the rail's row exists, so the gate's dispatch reaches it.
622+
*
623+
* The pre-fix state this pins against: `record:reference_rail` had no
624+
* `ComponentPropsMap` row, so the walker's unregistered-type skip swallowed the
625+
* whole props bag — the issue's planted `filter` produced ZERO findings from
626+
* validate/build while `record:related_list` keys in the same file were loudly
627+
* reported. Remove the map row and every assertion in the first test here goes
628+
* back to that silence (the reverse verification the issue ran on a real app).
629+
*/
630+
describe('validateComponentProps — record:reference_rail is dispatched (#8691)', () => {
631+
it('reports the issue\'s planted entry `filter`, loudly, through the same rule as its siblings', () => {
632+
const findings = validateComponentProps(
633+
stackWith([{
634+
type: 'record:reference_rail',
635+
properties: {
636+
entries: [{
637+
objectName: 'task',
638+
relationshipField: 'project_id',
639+
filter: [{ field: 'status', op: 'neq', value: 'completed' }],
640+
}],
641+
},
642+
}]),
643+
);
644+
expect(findings).toHaveLength(1);
645+
expect(findings[0].rule).toBe(COMPONENT_PROPS_UNKNOWN_KEY);
646+
expect(findings[0].where).toBe('page "probe_page" · record:reference_rail');
647+
expect(findings[0].message).toContain('`filter`');
648+
// The prescription names where `filter` IS real, so the author is routed
649+
// to the component that delivers it rather than left with a bare refusal.
650+
expect(findings[0].message).toContain('record:related_list');
651+
});
652+
653+
it('stays silent on the shape the renderer actually reads', () => {
654+
const findings = validateComponentProps(
655+
stackWith([{
656+
type: 'record:reference_rail',
657+
properties: {
658+
entries: [
659+
{ objectName: 'task', relationshipField: 'project_id', title: 'Tasks', limit: 3, displayField: 'subject' },
660+
],
661+
hideEmpty: false,
662+
},
663+
}]),
664+
);
665+
expect(findings).toEqual([]);
666+
});
667+
});

packages/spec/api-surface/ui.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,7 +290,10 @@
290290
"RecordHighlightsField (type)",
291291
"RecordHighlightsProps (const)",
292292
"RecordPathProps (type)",
293+
"RecordReferenceRailProps (type)",
293294
"RecordRelatedListProps (const)",
295+
"ReferenceRailEntry (type)",
296+
"ReferenceRailEntrySchema (const)",
294297
"Report (type)",
295298
"ReportChart (type)",
296299
"ReportChartParsed (type)",

packages/spec/authorable-surface/ui.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1007,6 +1007,8 @@
10071007
"ui/RecordPathProps:aria",
10081008
"ui/RecordPathProps:stages",
10091009
"ui/RecordPathProps:statusField",
1010+
"ui/RecordReferenceRailProps:entries",
1011+
"ui/RecordReferenceRailProps:hideEmpty",
10101012
"ui/RecordRelatedListProps:actions",
10111013
"ui/RecordRelatedListProps:add",
10121014
"ui/RecordRelatedListProps:aria",
@@ -1019,6 +1021,11 @@
10191021
"ui/RecordRelatedListProps:showViewAll",
10201022
"ui/RecordRelatedListProps:sort",
10211023
"ui/RecordRelatedListProps:title",
1024+
"ui/ReferenceRailEntry:displayField",
1025+
"ui/ReferenceRailEntry:limit",
1026+
"ui/ReferenceRailEntry:objectName",
1027+
"ui/ReferenceRailEntry:relationshipField",
1028+
"ui/ReferenceRailEntry:title",
10221029
"ui/Report:_lock",
10231030
"ui/Report:_lockDocsUrl",
10241031
"ui/Report:_lockReason",

packages/spec/export-origins/ui.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,7 +290,10 @@
290290
"RecordHighlightsField": "src/ui/component.zod.ts#RecordHighlightsField (type)",
291291
"RecordHighlightsProps": "src/ui/component.zod.ts#RecordHighlightsProps (const)",
292292
"RecordPathProps": "src/ui/component.zod.ts#RecordPathProps (type)",
293+
"RecordReferenceRailProps": "src/ui/component.zod.ts#RecordReferenceRailProps (type)",
293294
"RecordRelatedListProps": "src/ui/component.zod.ts#RecordRelatedListProps (const)",
295+
"ReferenceRailEntry": "src/ui/component.zod.ts#ReferenceRailEntry (type)",
296+
"ReferenceRailEntrySchema": "src/ui/component.zod.ts#ReferenceRailEntrySchema (const)",
294297
"Report": "src/ui/report.zod.ts#Report (type)",
295298
"ReportChart": "src/ui/report.zod.ts#ReportChart (type)",
296299
"ReportChartParsed": "src/ui/report.zod.ts#ReportChartParsed (type)",

packages/spec/json-schema.manifest/ui.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,9 @@
120120
"ui/RecordHighlightsField",
121121
"ui/RecordHighlightsProps",
122122
"ui/RecordPathProps",
123+
"ui/RecordReferenceRailProps",
123124
"ui/RecordRelatedListProps",
125+
"ui/ReferenceRailEntry",
124126
"ui/Report",
125127
"ui/ReportChart",
126128
"ui/ReportNavItem",

0 commit comments

Comments
 (0)