@object-ui/core 17.2.0 → 17.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/CHANGELOG.md +1436 -0
  2. package/README.md +31 -12
  3. package/dist/actions/ActionEngine.d.ts +53 -31
  4. package/dist/actions/ActionEngine.js +87 -66
  5. package/dist/actions/ActionRunner.d.ts +215 -24
  6. package/dist/actions/ActionRunner.js +334 -17
  7. package/dist/actions/actionErrorDetail.d.ts +26 -0
  8. package/dist/actions/actionErrorDetail.js +37 -0
  9. package/dist/actions/actionKeys.d.ts +96 -20
  10. package/dist/actions/actionKeys.js +168 -18
  11. package/dist/actions/actionResponse.d.ts +42 -0
  12. package/dist/actions/actionResponse.js +104 -0
  13. package/dist/actions/index.d.ts +3 -0
  14. package/dist/actions/index.js +3 -0
  15. package/dist/actions/serverActionHandler.d.ts +143 -0
  16. package/dist/actions/serverActionHandler.js +137 -0
  17. package/dist/data-scope/ViewDataProvider.d.ts +37 -12
  18. package/dist/data-scope/ViewDataProvider.js +83 -5
  19. package/dist/data-scope/element-data-source.d.ts +113 -0
  20. package/dist/data-scope/element-data-source.js +216 -0
  21. package/dist/data-scope/index.d.ts +2 -1
  22. package/dist/data-scope/index.js +1 -0
  23. package/dist/evaluator/ExpressionEvaluator.js +4 -2
  24. package/dist/evaluator/declaredPredicate.d.ts +119 -0
  25. package/dist/evaluator/declaredPredicate.js +152 -0
  26. package/dist/evaluator/fieldRules.d.ts +42 -2
  27. package/dist/evaluator/fieldRules.js +101 -11
  28. package/dist/evaluator/index.d.ts +2 -0
  29. package/dist/evaluator/index.js +2 -0
  30. package/dist/evaluator/listConditional.d.ts +18 -3
  31. package/dist/evaluator/listConditional.js +0 -0
  32. package/dist/evaluator/optionRules.js +5 -1
  33. package/dist/evaluator/predicateInput.d.ts +132 -0
  34. package/dist/evaluator/predicateInput.js +129 -0
  35. package/dist/index.d.ts +7 -1
  36. package/dist/index.js +26 -1
  37. package/dist/protocols/DndProtocol.d.ts +42 -1
  38. package/dist/protocols/KeyboardProtocol.d.ts +31 -1
  39. package/dist/protocols/SharingProtocol.d.ts +11 -1
  40. package/dist/protocols/index.d.ts +0 -1
  41. package/dist/protocols/index.js +8 -1
  42. package/dist/registry/Registry.d.ts +24 -0
  43. package/dist/theme/ThemeEngine.d.ts +0 -8
  44. package/dist/theme/ThemeEngine.js +22 -103
  45. package/dist/theme/index.d.ts +1 -1
  46. package/dist/theme/index.js +7 -1
  47. package/dist/types/index.d.ts +42 -8
  48. package/dist/utils/chart-series.d.ts +258 -5
  49. package/dist/utils/chart-series.js +423 -20
  50. package/dist/utils/compare-to.d.ts +40 -13
  51. package/dist/utils/compare-to.js +4 -32
  52. package/dist/utils/dashboard-filters.d.ts +54 -8
  53. package/dist/utils/dashboard-filters.js +261 -12
  54. package/dist/utils/dataset-format.d.ts +79 -15
  55. package/dist/utils/dataset-format.js +130 -9
  56. package/dist/utils/dataset-pivot.d.ts +78 -0
  57. package/dist/utils/dataset-pivot.js +78 -0
  58. package/dist/utils/dom-props.d.ts +137 -0
  59. package/dist/utils/dom-props.js +153 -0
  60. package/dist/utils/filter-converter.d.ts +39 -11
  61. package/dist/utils/filter-converter.js +87 -7
  62. package/dist/utils/normalize-list-view.d.ts +26 -6
  63. package/dist/utils/normalize-list-view.js +30 -8
  64. package/dist/utils/number-display.d.ts +151 -0
  65. package/dist/utils/number-display.js +71 -0
  66. package/dist/utils/predicate-fields.d.ts +96 -0
  67. package/dist/utils/predicate-fields.js +163 -0
  68. package/dist/utils/predicate-record.d.ts +32 -0
  69. package/dist/utils/predicate-record.js +120 -0
  70. package/dist/utils/record-title.js +0 -0
  71. package/dist/utils/resolve-view-id.d.ts +31 -0
  72. package/dist/utils/resolve-view-id.js +45 -0
  73. package/dist/utils/sort-query.d.ts +55 -0
  74. package/dist/utils/sort-query.js +35 -0
  75. package/package.json +5 -5
  76. package/dist/protocols/NotificationProtocol.d.ts +0 -71
  77. package/dist/protocols/NotificationProtocol.js +0 -89
  78. package/dist/utils/merge-views-into-objects.d.ts +0 -19
  79. package/dist/utils/merge-views-into-objects.js +0 -39
package/CHANGELOG.md CHANGED
@@ -1,5 +1,1431 @@
1
1
  # @object-ui/core
2
2
 
3
+ ## 17.5.0
4
+
5
+ ### Minor Changes
6
+
7
+ - ee66e2e: Close `ActionDef` — delete the `[key: string]: any` index signature and converge `visible` / `disabled` on the spec's unified shape.
8
+
9
+ `ActionDef` accepted any key of any type, so a typo (`targt`) and a retired spec
10
+ key (`execute`) both type-checked and the runner then silently bound no handler
11
+ — the objectstack#2169 "Mark Done does nothing" shape. Step 1
12
+ (objectstack#4075) made that audible with a dev-mode warning; step 2 promoted
13
+ the 18 spec-owned keys to real fields. This is **step 3**, executing the
14
+ maintainer's 2026-08-06 ruling now that its upstream half shipped in
15
+ `@objectstack/spec` 17.0.0-rc.6 (objectstack#5970).
16
+
17
+ - **`visible` and `disabled` now have ONE shape, derived from the spec** —
18
+ `boolean | string(CEL) | { dialect, source }`. The ruling was "统一形状,spec
19
+ 采纳": boolean is the degenerate literal verdict, the string is CEL shorthand,
20
+ the envelope is the full form. `visible` loses its hand-written `| boolean`
21
+ (the spec adopted that arm, so restating it locally would be a second
22
+ contract), and `disabled` gains the envelope arm it never had — it was
23
+ `string | boolean`, which is why the envelope the spec emits could only be
24
+ read through a cast.
25
+ - **The index signature is gone.** `tsc` now rejects an unknown or retired key
26
+ at any site that authors an action literal in code.
27
+ - **Five keys the deletion surfaced, promoted to real fields.** `to`,
28
+ `external`, `newTab`, `replace` — the `navigation` alias's own spelling, ruled
29
+ legitimate by step 1 and listed in `NAVIGATION_ALIAS_KEYS` ever since, but
30
+ declared only as data; and `description`, which every action renderer forwards
31
+ (`check:action-forward-parity` requires it) and the param-collection dialog
32
+ reads for its subtitle (objectui#4192). These were the only two `TS2353`s the
33
+ deletion produced across the whole workspace.
34
+ - **`ActionContext` keeps its index signature**, deliberately. It is a runtime
35
+ data bag whose keys are genuinely open; `ActionDef` is a declared metadata
36
+ contract. That asymmetry is the point, and it is now pinned in both
37
+ directions.
38
+
39
+ **Breaking edge, deliberate — same class as step 2's, one step further.** An
40
+ `ActionDef` literal carrying a key this interface does not declare is now a
41
+ compile error where it previously compiled and did nothing at runtime. That
42
+ includes the retired `execute` (rename it to `target`; `os migrate meta --from
43
+ 16` rewrites it) and plain typos. Values that were only ever absorbed silently
44
+ are the ones that stop compiling, so the failure moves to where it can be fixed
45
+ rather than appearing as a button that does nothing.
46
+
47
+ **What did NOT retire with the index signature**, contrary to step 1's
48
+ expectation: the dev-mode `warnOnUnknownActionKeys` shim and `executeScript`'s
49
+ `execute` rename prescription both stay. `tsc` only ever sees actions authored
50
+ as TypeScript, while stored `sys_metadata` rows are rehydrated UNPARSED
51
+ (objectstack#3903) — which is the population `execute: 'markDone'` actually
52
+ lives in. The two mechanisms cover disjoint populations; retiring the runtime
53
+ half would have re-opened the gap it was written for.
54
+
55
+ - 3fc2971: A null-keyed group renders as an explicit bucket instead of silently vanishing from a chart (objectui#4466)
56
+
57
+ `buildChartSeries`' single-dimension branch passed rows through verbatim, so a row whose category VALUE is `null` reached recharts with a null category and drew no mark. The visible outcome was not an empty chart but a quietly wrong one: rows `[{user_id: null, event_count: 51}, {user_id: 'Dev Admin', event_count: 2}]` drew exactly ONE bar — the dominant group, 51 of 53 events, dropped while the y-axis scale still accommodated it, so the chart understated its own data and the axis proved the data had been there. With every group null it drew axes, gridlines and an axis title with zero marks and no empty state, which is the shipped first-boot state of the built-in System Overview board's "Events by User" (every seeded `sys_audit_log` row is written with `user_id = NULL`).
58
+
59
+ The mapping lives in the shared series layer, so dashboard widgets and standalone `ObjectChart` get one answer rather than a per-chart patch in the recharts wrapper. It resolves the two-answers disagreement the card names as well: an empty result set keeps the designed empty state, a non-empty result always draws bars — the null bucket included.
60
+
61
+ `@object-ui/core` gains `NULL_CATEGORY_LABEL` and `ChartSeriesOptions`; `buildChartSeries` and `findChartSeriesRow` each take an optional trailing `options`. Both additive — every existing call site compiles and behaves identically, and a result with no null category is still returned by array identity. The two helpers are a pair on purpose: the caller matches a clicked segment against rows that still carry the raw `null`, so `findChartSeriesRow` reads the bucket label back to that row and the newly-visible bar keeps its drill-through instead of resolving to `-1`.
62
+
63
+ The label goes through the i18n channel (`chart.nullCategory`, en `(None)` / zh `(未指定)`, all ten packs), passed down by the renderer: `@object-ui/core` is React-free and cannot read the locale bundle, so it takes the resolved string the same way `dimensionOptionTranslator` takes a resolver. Its English constant is the floor for a provider-less host, not the mechanism.
64
+
65
+ `hasNoCategoryKey` (framework#4033) is untouched and now documented against this: a row that does not carry the category key AT ALL is a different defect — a dimension grouped by but never projected — and keeps its explanatory placeholder. The bucket deliberately never ADDS the key to such a row, which is what keeps that guard's signal alive. Key absent → the placeholder; key present with a null value → the bucket.
66
+
67
+ - dde7283: `chatbot` and `chatbot-enhanced` now pass only whitelisted DOM props to their host element (objectui#4431)
68
+
69
+ Both registrations destructured `schema` and `className` and forwarded everything else. `SchemaRenderer` hands a registered component the authored node's own keys, the contents of its `props` container, the ARIA it resolved and the host's trailing props — so all of it became attributes on the chat root `div`, because React passes unknown lowercase attributes through in silence and stringifies object values. Measured through the real SDUI path with a data-source adapter attached: **14 non-DOM attributes on each widget**, including `datasource="[object Object]"` (the injected adapter, which only appears on a deployment that really loads data) and a camelCase `arialabel` sitting next to the resolved `aria-label`, so the element carried each ARIA value twice under two spellings — one of them meaningless to assistive technology.
70
+
71
+ Both are now consume-or-whitelist: configuration is read off `schema` as before, the evaluated `disabled` verdict is consumed by name, and only `toDomProps`' output reaches the element. The resolved `aria-label` / `aria-describedby`, `role`, `id`, `tabIndex` and the `data-*` family still arrive — dropping them would have been an accessibility regression dressed as a leak fix, so the pin asserts the delivered set exactly, not just the absent one. `chatbot-floating` is untouched: its content mounts through a portal and its root never spread.
72
+
73
+ `@object-ui/core` gains the shared executor this migration needs (`utils/dom-props.ts`): `toDomProps` for the SDUI widget contract, plus `pickDomProps` — the mechanism — for a package whose own contract declares a different key set. That is the objectui#4409 dependency direction: plugin packages declare `@object-ui/core` and must not grow a dependency on `@object-ui/fields` to reach a whitelist.
74
+
75
+ `@object-ui/fields` keeps its own key list and its compile-time bindings, and now executes them through core's mechanism. Its behaviour is unchanged and its exported `DomProps<P>` is the same structural type. The two lists differ for measured reasons and no longer can drift silently: `name` and `disabled` are legal only on form controls, which is what every field widget renders and what `FieldWidgetComponentProps` declares, while `role` is resolved by `SchemaRenderer` for every SDUI node and is not part of the field contract. A new assertion binds every shared key in both directions, with `role` named as the single deliberate exception.
76
+
77
+ - f279deb: fix(core): bare-string filter options — docs/examples stop teaching it, the runtime lift warns (objectui#4356)
78
+
79
+ `globalFilters[].options` had two de-facto contracts. `@objectstack/spec`'s `GlobalFilterSchema` accepts only `{ value, label }` pairs, while `normalizeFilterOptions` also lifted a bare-string shorthand (`options: ['EMEA', 'APAC']`) — so a dashboard authored that way rendered correctly in objectui and was refused the moment it reached the platform's validation. That is the "one strict contract beats N dialects" divergence AGENTS.md #0.1 names, with the renderer's tolerance hiding the producer's bug instead of surfacing it.
80
+
81
+ Maintainer ruling of 2026-08-12 on objectstack#7917, verbatim 「7917 ②」: **the spec stays strict; the runtime lift retires behind a deprecation window sized by a stored-dashboard survey.** This is Phases 0 and 1 of that window, shipped together. Phase 2 (removing the lift) is scheduled on objectstack#7917 and is deliberately not here.
82
+
83
+ **Phase 1 — the lift now says so out loud.** `normalizeFilterOptions` still lifts a bare string, unchanged and mechanically lossless (`'EMEA'` becomes `{ value: 'EMEA', label: 'EMEA' }`), because stored dashboards carry the shorthand and dropping it silently would turn a rendering filter into an empty one. It now also logs a deprecation warning naming the offending filter, quoting the offending values, and printing the canonical replacement. The warning fires **once per offending filter per session** — `resolveDashboardFilterDefs` runs on every dashboard render, and a warning that floods the console is a warning that gets muted — and it is dev-mode only, matching the `warnOnDeprecatedObjectParams` convention in `actions/actionKeys.ts`. It does not fire for canonical object options, and a mixed array names only its bare members, since partial migrations happen. A silent lift can never be retired, because nothing would ever show that the last shorthand document is gone (ADR-0078).
84
+
85
+ **Phase 0 — objectui stopped teaching the form.** The stored-dashboard survey on objectstack#7917 found the shorthand's source: objectui's own docs and its schema-catalog corpus — which the catalog's `package.json` declares an AI RAG/few-shot retrieval source — still authored it, so the stored population was still growing. All seven non-test occurrences are corrected to the pair form: `content/docs/guide/dashboard-filters.md` (a code block **and** a prose passage that presented the shorthand as an equal alternative), `content/docs/plugins/plugin-dashboard.mdx`, `packages/plugin-dashboard/README.md`, and the three `examples/schema-catalog` `filtered-dashboard*.json` entries. Warning authors while the docs still taught the form would have been a contradiction users report as a bug.
86
+
87
+ **Guardrail.** The schema catalog previously asserted only that its entries were structurally well-formed and rendered without throwing — which is exactly how a spec-invalid example got in. Every `globalFilters[]` entry in every `plugin-dashboard` catalog example is now parsed with the real `@objectstack/spec` `GlobalFilterSchema`, with a non-vacuity control so a broken sweep cannot read as green.
88
+
89
+ New export: `resetDashboardFilterWarnings()`, the warn-once memo reset, matching `resetActionKeyWarnings`. Graded `minor` for that additive export — measured, the emitted `.d.ts` gains exactly one declaration and narrows nothing.
90
+
91
+ - eb7f586: Dashboard dataset measures follow the display locale (objectui#4566).
92
+
93
+ `formatMeasure` and `formatDimensionValue` in `@object-ui/core` formatted every
94
+ value with a bare `undefined` locale tag at all three of their `Intl` sites.
95
+ `undefined` is not "the user's locale", it is the MACHINE's — neither of the
96
+ repo's two locale channels. A German session read a dashboard KPI as `1,234.5`
97
+ next to a grid cell rendering the same number as `1.234,5`, and inverted
98
+ separators read as a different number, not as an unstyled one.
99
+
100
+ Both functions take the display locale as a new OPTIONAL LAST parameter, and
101
+ `DatasetWidget` threads `useDisplayLocale()` into every site it formats through:
102
+ the KPI, the grouped table's measure and dimension cells, and the cross-tab's
103
+ header labels and cells.
104
+
105
+ **English output does not move**, and that is the discriminator against the
106
+ sibling fix. These sites already went through `Intl` with default grouping, so
107
+ the only thing that changes is WHOSE locale is used:
108
+
109
+ | | before | after |
110
+ | ----------------- | ----------- | --------------------- |
111
+ | en, 1234.5 `0.0` | `1,234.5` | `1,234.5` (unchanged) |
112
+ | de, 1234.5 `0.0` | `1,234.5` | `1.234,5` |
113
+ | de, 1234.5 EUR | `€1,234.50` | `1.234,50 €` |
114
+ | de, 0.6083 `0.0%` | `60.8%` | `60,8%` |
115
+
116
+ Contrast objectui#4553, where `formatPercent` had never grouped at all and
117
+ moving en `1235%` → `1,235%` WAS the fix.
118
+
119
+ Omitting the new argument reproduces the previous output byte for byte, so
120
+ callers that do not thread a locale yet are unaffected.
121
+
122
+ Two behaviours are deliberately preserved rather than "improved" alongside the
123
+ locale fix, both measured:
124
+
125
+ - **Integers stay verbatim.** The integer branch renders no separator and no
126
+ decimal mark, so a locale has nothing to change there — and routing it through
127
+ `Intl` WOULD change it (a locale with its own numbering system re-digits it,
128
+ and `1e21` expands to 22 digits).
129
+ - **The percent sign stays a literal suffix.** `Intl`'s `style: 'percent'`
130
+ re-scales by 100, and that round trip loses precision at the top of the range
131
+ (en `100,000,000,000,000,000,000,000%` becomes
132
+ `99,999,999,999,999,990,000,000%`). The consequence — a German list cell
133
+ writing `1.234,5 %` with a no-break space where a dashboard measure writes
134
+ `1.234,5%` — is filed separately rather than smuggled in behind a locale fix.
135
+
136
+ `@object-ui/core` is `minor` because two of its ENTRY exports gained an optional
137
+ parameter (measured in the built `.d.ts`). `@object-ui/plugin-dashboard` is
138
+ `patch`: its published declarations are unchanged — `buildPivot`'s new optional
139
+ parameter is internal, as that function is not on the package's `exports`
140
+ surface.
141
+
142
+ - e901131: `DatasetResultField` is now `@objectstack/spec`'s `AnalyticsResult.fields[]` element itself, not a hand-written restatement of it
143
+
144
+ `packages/core/src/utils/dataset-format.ts` declared its own six-key interface for the analytics result column, under a doc comment describing the server's contract. The key set happened to match the spec today, so nothing was broken — but it was the last surviving member of the derive-don't-restate family (#3613 / #3753 on the parameter side, #3752 on the adapter return side), and it was the member with no compile-time tripwire: three surfaces (`plugin-dashboard`'s `DatasetWidget`, `plugin-report`'s `DatasetReportRenderer`, app-shell's `DatasetPreview`) consume this name AS the real column type, so the next spec column key would simply never appear here and no build would complain. It is now `AnalyticsResult['fields'][number]`, so it cannot lag the contract again.
145
+
146
+ **Consumer-visible type tightening (the reason this is a minor, not a patch).** The restatement had relaxed `type` to optional; the contract requires it. Anything that assigned a column literal without `type` — or a bare `{ name, label?, format? }` — to `DatasetResultField` will now fail to compile, and the fix is to supply the `type` the server always sends. Nothing in this repo needed changing: every value of this type originates in `ObjectStackAdapter.queryDataset`, which already declares the spec element, and no consumer reads `.type` at all, so the widening had bought no caller anything while advertising a `string | undefined` the wire never produces. Marked `minor` per the repo's bump policy, which reserves `major` for following `@objectstack/spec` across a major.
147
+
148
+ The exported name is unchanged and the `PercentScale` re-export from this module is untouched, so existing import paths keep working. `packages/core/tsconfig.typetests.json` (chained off the package's `type-check`) compiles the new parity test, so the pins are checked by CI rather than merely written down — including a negative pin that goes red if the hand-written interface is ever restored, and the `ChartResultField` superset relationship the module's comment claims.
149
+
150
+ - d9d3463: Retire four zero-consumer declared surfaces (dead-surface sweep batch 3, #4328). Each was
151
+ measured as declared-but-never-read at the branch point, and each is removed rather than
152
+ left as an authoring surface whose values nothing acts on.
153
+
154
+ Breaking for anyone who typed against the removed declarations, marked `minor` per this
155
+ repository's version-alignment convention (the major tracks `@objectstack`, never an
156
+ API-break count):
157
+
158
+ - `@object-ui/core` no longer exports `mergeViewsIntoObjects`. It was a second copy left
159
+ behind by the move of that step to the provider layer, and it had drifted: it ignored a
160
+ view container's default `list` and keyed views by the authored bare key instead of the
161
+ composer's `<object>.<key>` identity. The live implementation — `MetadataProvider`'s, in
162
+ `@object-ui/app-shell` — is unchanged and remains the only one. (#3775)
163
+ - `@object-ui/types`' `RoleDefinition` no longer declares `permissions`. A role's grants
164
+ live in `ObjectPermissionConfig.roles`, keyed by object; that is the only home any
165
+ consumer reads (`resolveRoles` walks `inherits` and matches on `name`). The removed
166
+ field was _required_, so five fixtures across three packages had been declaring an empty
167
+ array for a value nothing would ever look at. Role-attached grants are now a compile
168
+ error rather than silently ignored data. (#4288)
169
+ - `@object-ui/react`'s `RecordContextValue` no longer declares `loading` / `error`. Both
170
+ had zero producers and zero consumers — no host passed them, no `record:*` renderer read
171
+ them — and only the provider's memo dependency list still named them. Record-level
172
+ loading and error state stays where it is actually expressed: each renderer's own data
173
+ source. (#3773)
174
+
175
+ No behaviour change, no request-count change:
176
+
177
+ - `@object-ui/data-objectstack` drops five `metadataCache.invalidate('views:<object>')`
178
+ calls across `updateViewConfig` / `createView` / `updateView` / `deleteView`. No read
179
+ path has ever populated that key — `listViews` fetches directly, uncached — so all five
180
+ were permanent no-ops. The invalidations of the keys that do have readers
181
+ (`view:<object>:<viewId>` for `getView`, `view-overrides:<object>` for
182
+ `listViewOverrides`) are untouched and now pinned. (#3778)
183
+
184
+ - 38ab505: Retire the `global_nav` Studio designer surfaces, and track the `@objectstack` family at `17.0.0-rc.6` (objectstack#7100 / objectstack#6888).
185
+
186
+ ## The retirement
187
+
188
+ `global_nav` was an `ACTION_LOCATIONS` member no running-app surface ever rendered. The console's ⌘K palette (`app-shell/src/chrome/CommandPalette.tsx`) builds its groups from nav items, objects, dashboards, pages, reports, recent items, record search and theme; it holds no reference to `global_nav`, to `actionRendersAt`, or to any action-metadata source. An action declaring `locations: ['global_nav']` therefore never reached a user.
189
+
190
+ The Studio designer previewed it anyway — a mock frame reading `⌘K · Command palette` with the author's button inside it. That is the sharp edge the maintainer's 2026-08-09 ruling on objectstack#6888 named: an authoring tool promising a surface the product does not have teaches authors, and every AI copying this corpus, to declare dead metadata. `@objectstack/spec` `17.0.0-rc.6` retired the member (7 members → 6) with a named rejection message; this release removes the designer surfaces that outlived it.
191
+
192
+ - `metadata-admin/previews/ActionPreview.tsx` — the mock command-palette placement frame is gone. The metadata strip above it still ECHOES whatever `locations` the draft declares, deliberately: reporting what a (possibly stale) draft says is honest, whereas the frame CLAIMED the platform renders it.
193
+ - `metadata-admin/inspectors/ActionDefaultInspector.tsx` — the `global_nav` entry is gone from `LOCATION_LABELS`. That map is typed `Record< ActionLocation, string >`, so the retirement reached it as a compile error rather than as a silently stale dropdown — the mechanism objectui#3017 installed, firing as designed.
194
+ - `metadata-admin/previews/block-config.ts` — the `record:quick_actions` location dropdown no longer offers it, and both locale tables drop the now-orphaned `…option.location.global_nav` key.
195
+ - `@object-ui/components`' `action:bar` doc comment is aligned. The component's published enum is `[...ACTION_LOCATIONS]`, so it followed the retirement on its own; only the prose was stale.
196
+
197
+ `@object-ui/core`'s `ActionEngine.getActionsForLocation` is **unchanged and still answers a literal string match**. Narrowing it to the six live members would put a second rejection point beside the schema's — the tolerant-consumer shape the strict-contract rule forbids, inverted. Enforcement stays where it belongs: the parameter type is now six-membered so no type-correct caller can spell the retired value, and `ActionLocationSchema` rejects it by name at authoring and publish time.
198
+
199
+ ## The dependency move
200
+
201
+ All 37 `@objectstack/*` declarations across 30 `package.json` files move from `^17.0.0-rc.5` to `^17.0.0-rc.6`, and `pnpm-lock.yaml` resolves one copy of each family package at rc.6. The siblings move with `spec` because `client` / `formula` / `lint` pin it **exactly** — leaving them behind would keep two copies of the spec in the tree, the split brain objectui#3560 called out.
202
+
203
+ Bumping the pin and repairing the fallout cannot be split: at rc.5 the `Record< ActionLocation, string >` above is missing a key, at rc.6 it has an excess one.
204
+
205
+ ## Breaking, in FROM → TO form
206
+
207
+ - **`@object-ui/types`' `Theme` now binds the spec's `Theme`, not `ThemeInput`.** rc.6 retired every `…Input` alias and moved the bare name onto the `z.input` side (`X` = `z.input`, `XParsed` = `z.infer`). The runtime shape and this package's exported name are unchanged — `Theme` was, and still is, the AUTHORING shape where `mode` is optional. Re-pointing at `ThemeParsed` would have been the silent swap.
208
+ - **`SpecReport` / `SpecReportChart` re-point to `ReportParsed` / `ReportChartParsed`, and `SpecReportInput` / `SpecReportChartInput` to `Report` / `ReportChart`.** Same rename, same rule: each local alias keeps the SIDE it had at rc.5.
209
+ - **`@object-ui/types` no longer re-exports `I18nObject`, `LocaleConfig`, `PluralRule`, `DateFormat` or `NumberFormat`** — all five were retired by rc.6. They were dead re-exports here: nothing in this repo imported them from `@object-ui/types` (`@object-ui/i18n`'s formatter vocabulary in `utils/spec-formatters.ts` is locally declared and never bound the spec symbols). `I18nLabel` survives and is unchanged as a name.
210
+ - **`I18nLabel` itself widened from `string` to `string | Record< string, string >`** — rc.6 folded the retired `I18nObject`'s per-locale map into it and ships `resolveI18nLabel(label, locale)` as the shared resolver. Every read in this repo that lands in a text slot now goes through that resolver, so an inline map renders its locale instead of `[object Object]`. Reads the compiler cannot see are audited separately in objectui#4163.
211
+ - **`@object-ui/types`' `GlobalFilterSchema` derives via `.safeExtend`, not `.extend`.** rc.6's `GlobalFilterSchema` carries a refinement and zod 4 refuses `.extend()` on a refined object outright, which threw at module load. `.safeExtend` is zod's prescribed replacement and KEEPS the refinement, so the spec's cross-field rule now also runs on this package's dialect — which is the intended behaviour, since the pinned divergences widen individual fields and were never meant to switch off a whole-object rule.
212
+
213
+ - 92250d6: One home for the number-display policy — and a percent stops meaning two different things between a list cell and a dashboard measure
214
+
215
+ `formatDisplayNumber`, `shouldGroupDisplayNumber` and `DisplayNumberFormatOptions` move from `@object-ui/i18n` into `@object-ui/core`. `@object-ui/i18n` re-exports all three under the same names, so every existing import path keeps working unchanged and both spellings resolve to the same function object; nothing published was removed.
216
+
217
+ The move is what fixes the bug. `@object-ui/core`'s `formatMeasure` needed exactly this policy and could not import it — `core` is the React-free engine and is a runtime dependency of React-free consumers (the `object-ui` VS Code extension, `@object-ui/data-objectstack`), while `i18n` depends on `i18next`/`react-i18next` and peer-depends on React. So `formatMeasure` carried a parallel `Intl` implementation, recorded at both ends as deliberate duplication, and the two drifted in the one place a hand-built string and `Intl` disagree. A German session read `1.234,5 %` from a list cell and `1.234,5%` from a dashboard measure showing the same number. The function is pure, so the boundary was never a property of the code — only of where the code sat; moving it down removes the obstacle instead of working around it. `core` imports nothing from `i18n`, so the new edge adds no cycle.
218
+
219
+ **Behaviour change — a measure's percent sign now follows the locale.** `formatMeasure` appended a literal `%` in every locale; it now renders the locale's own percent convention, the same one the list-cell `formatPercent` has used since the fix to its own machine-locale defect. Measured to change output in de, fr, es, ru, sv, cs, fi (a no-break space appears before the sign), tr (the sign moves to the FRONT: `%1.234,5`) and ar (its own percent sign plus U+061C). English, Japanese and Chinese are byte-identical — their convention is a bare trailing sign — which is why this was invisible in an English session.
220
+
221
+ **No numeral moves, in any locale, at any magnitude.** The obvious route to the locale's convention is `Intl`'s `style: 'percent'`, but that style expects a fraction, so a value already in percentage points would have to be divided by 100 for `Intl` to multiply it straight back — and that round trip is lossy. Measured, it moves 27,581 of 1,200,013 ordinary-magnitude en-US forms at rounding ties (`0.175` at two decimals becomes `0.17%` instead of `0.18%`), plus `MAX_SAFE_INTEGER` and everything from 1e23 up, where `100,000,000,000,000,000,000,000%` becomes `99,999,999,999,999,990,000,000%`. The percentage points are formatted directly instead, through a new `style: 'percentPoints'` on `DisplayNumberFormatOptions`; that route was measured to produce a byte-identical percent affix to `style: 'percent'` across all 171 locale tags tested while moving none of those 1,200,013 forms. Callers holding a fraction keep using `style: 'percent'`, whose behaviour is unchanged — naming the two cases apart is what stops the next caller from reaching for the lossy one.
222
+
223
+ `@object-ui/i18n`'s entry declaration is byte-identical, but the declaration it points at now lives in `@object-ui/core` and the package gains that dependency, so it takes the same minor bump rather than a patch.
224
+
225
+ - c1d939f: One `SchemaNode`, and one label vocabulary — the union wins, and labels resolve where the locale lives
226
+
227
+ Two packages published a type called `SchemaNode` and they were not the same type. `@object-ui/core` hand-declared `interface SchemaNode { type: string; … [key: string]: any }`; `@object-ui/types` exported `type SchemaNode = BaseSchema | string | number | boolean | null | undefined`, whose own doc comment names `'Plain string'` a valid node. Both were exported under one name from packages the same consumers import together, so which declaration a call site got depended on which package it happened to import from — #4548's canary measured 19 of 35 errors as exactly that collision. Core's declaration is now a re-export of types', so there is one declaration left to disagree with. Core's entry surface is unchanged: `dist/index.d.ts` is byte-identical across the change.
228
+
229
+ Reconciling it exposed a real defect rather than a mechanical narrowing, which is why the first attempt was withdrawn instead of forced. The spec bridges write `spec.label` — the spec's `I18nLabel`, an INLINE locale map like `{ en: 'Owner', 'zh-CN': '负责人' }` — into `node.label`, and `BaseSchema.label` declared `string`. Under core's old index signature that assignment was invisibly `any`; under one honest `SchemaNode` it is a type error. `BaseSchema.label` and `.description` therefore now accept `string | I18nLabel`, and the two bridge assignments compile with their expressions untouched.
230
+
231
+ Resolution happens at READ time, in the renderer, against the display locale — not at the bridge. Resolving at the bridge was measured unimplementable: it is a plain class method that cannot call a hook, `BridgeContext` declares no locale, and `updateContext()` has zero callers, so a bridge-resolved label would freeze one audience's language into the node tree with no re-translation channel. React's own invalidation re-translates for free at the read site.
232
+
233
+ The widening turned every blind `schema.label`-as-string read into a named compiler error, and that inventory is the audit: it named four sites repo-wide, all one class — the label reaching a React child position, where a map does not render as `[object Object]` but THROWS `Objects are not valid as a React child`, failing the whole subtree. Three are `@object-ui/components` renderers (`filter-builder`, `sidebar-group`, `dropdown-menu`), which now resolve with the spec's own `resolveI18nLabel` against `useDisplayLocale()`. The fourth is `plugin-dashboard`'s `DashboardGridLayout` heading, which resolves with `pickLocalized` against the active UI language — matching the widget-title resolution already in that same component rather than putting two resolvers and two disagreeing locale channels in one render; the two resolvers are limb-for-limb twins with a parity test pinning them.
234
+
235
+ One interface now carries both label vocabularies two properties apart — `label`/`description` are the spec's INLINE map, `ariaLabel` is the KEYED bundle reference — and each accepts the other's shape vacuously. That confusability is objectui#4167's known hazard, inherent to the spec's `I18nLabel` design; both shapes are named with cross-referenced doc comments stating which resolver owns which slot, and a pin asserts the two unions do not collapse into each other.
236
+
237
+ Finally, the spec bridges declare their return type as `BaseSchema` instead of the union. Both bridges end in a single `return node` on an object literal, so the union described nothing real while forcing a narrowing at every read — 272 mechanical errors across five suites in the first round. That change is a type annotation only; the emitted JavaScript is byte-identical.
238
+
239
+ - 2459a3e: Retire `ActionEngine`'s event-mapping API (objectui#3368). `ActionEngine.addMapping()`,
240
+ `ActionEngine.dispatch()`, the private `mappings` registry behind them, and the exported
241
+ `ActionMapping` interface are removed under enforce-or-remove: all four were public surface
242
+ of `@object-ui/core` with zero production callers. Nothing in the repo ever registered a
243
+ mapping, so `dispatch()` had no reachable caller either, and every call site was in the
244
+ engine's own test file.
245
+
246
+ Breaking for anyone who typed against or called the removed declarations, marked `minor`
247
+ per this repository's version-alignment convention (the major tracks `@objectstack`, never
248
+ an API-break count). Actions are still entered by name (`executeAction`), by location
249
+ (`getActionsForLocation`), by shortcut (`handleShortcut`) and in bulk (`executeBulk`) —
250
+ only the event-keyed entry point is gone, and no runtime behaviour changes because no
251
+ runtime path reached it.
252
+
253
+ The three ways the retired condition gate had drifted from the `visible` contract that
254
+ `getActionsForLocation` implements die with the path rather than being fixed on it: it
255
+ entered on a raw truthy check (`condition: false` dispatched anyway), typed `condition` as
256
+ `string` only (a `{ dialect: 'cel', source }` envelope could not reach the canonical
257
+ `@objectstack/formula` engine), and evaluated without `throwOnError` (a throwing predicate
258
+ failed OPEN, the opposite of `visible`'s fail-closed posture). Aligning the contract of an
259
+ API nobody calls would only have widened behaviour nobody uses.
260
+
261
+ - fe52a04: `rowHeightToDensityMode` answers only for the five spec row heights — the coerce-to-`comfortable` fallback is gone
262
+
263
+ Two surfaces narrow a list view's `rowHeight` onto the renderer's three-step
264
+ density vocabulary, and since objectui#4352 they answered differently for the
265
+ same off-spec input: `@object-ui/react`'s spec bridge declined to answer, while
266
+ `@object-ui/core`'s `rowHeightToDensityMode` rehabilitated anything unknown into
267
+ `comfortable`. One metadata-driven system, two answers for one input
268
+ (objectui#4440).
269
+
270
+ The strict answer wins, per AGENTS.md #0.1: a renderer-side rehabilitation of
271
+ off-spec metadata is a second de-facto contract, and one strict contract beats N
272
+ dialects — a bad `rowHeight` gets fixed at the producer, where the schema already
273
+ rejects it. The five mappings themselves are untouched (`compact`/`short` →
274
+ `compact`, `medium` → `comfortable`, `tall`/`extra_tall` → `spacious`), and the
275
+ table keeps its `Record< RowHeight, … >` typing, so a row height added upstream
276
+ still fails the build here.
277
+
278
+ **Breaking semantics, deliberately graded `minor`** (this repo never publishes
279
+ `major` — its major tracks `@objectstack`). Two things change:
280
+
281
+ - **Published type.** `rowHeightToDensityMode` is exported from
282
+ `@object-ui/core`, and its return widens from `DensityMode` to
283
+ `DensityMode | undefined`. A host assigning the result straight into a
284
+ `DensityMode` now has to say what an off-spec row height should mean to it.
285
+ - **Rendered output, for input the spec already rejects.** `ListView` — the one
286
+ in-repo caller — used to render an off-spec `rowHeight` one step looser than an
287
+ ABSENT one (`comfortable`, 40px rows, vs `compact`, 32px). It now renders it
288
+ exactly like an absent one, `compact`, which is also `ObjectGrid`'s own default.
289
+ A sweep of this repo, the `objectstack` example apps and one downstream app
290
+ found zero authored off-spec values, and the legacy `densityMode` alias cannot
291
+ produce one (`DENSITY_MODE_TO_ROW_HEIGHT` is typed
292
+ `Record< DensityMode, RowHeight >`).
293
+
294
+ Also closed while retiring the branch: the lookup guarded membership with `in`,
295
+ which walks the prototype chain, so `rowHeight: 'toString'` returned
296
+ `Object.prototype.toString` — a function — from something typed `DensityMode`. It
297
+ is an own-property check now.
298
+
299
+ - bb68488: Stop declaring 14 symbols under names `@objectstack/spec` owns at `17.0.0-rc.6`
300
+ (objectui#4167, objectstack#4115).
301
+
302
+ The rc.6 bump published nine names this repo already declared locally, on top of
303
+ four that predate it — `check:spec-symbols` reported all thirteen at once, and a
304
+ fourteenth (`GlobalFilterSchema`) appeared during the bump itself. Each was
305
+ triaged on its own rather than blanket-renamed, because the right answer differs
306
+ per symbol: five bind to the spec, three are renamed because the spec's
307
+ same-named export means something else, five arrive by derivation, and one is a
308
+ declared dialect with a written reason.
309
+
310
+ **Breaking for importers of `@object-ui/react`, `@object-ui/app-shell` and
311
+ `@object-ui/types`** — three exported names changed, because the spec exports the
312
+ same name for a _different_ thing:
313
+
314
+ | package | was | now | what the spec's same-named export actually is |
315
+ | :-------------------- | :----------------- | :----------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------- |
316
+ | `react` / `app-shell` | `MetadataState` | `MetadataCacheState` | a metadata item's LIFECYCLE state — `'draft' \| 'active' \| 'deprecated' \| 'archived'` (`MetadataStateSchema`, `@objectstack/spec/system`) |
317
+ | `react` / `app-shell` | `resolveI18nLabel` | `resolveKeyedI18nLabel` | a resolver for the INLINE per-locale map (`{ en: 'Owner', 'zh-CN': '负责人' }`) against a BCP-47 locale |
318
+ | `types` | `DateRangePreset` | `FilterBuilderDateRangePreset` | the thirteen HISTORICAL dashboard filter-bar presets; this one is the filter-builder set, which adds eight FUTURE windows the dashboard schema rejects |
319
+
320
+ `resolveI18nLabel` is the one where the collision had already started costing
321
+ something. rc.6 widened `I18nLabel` from `string` to
322
+ `string | Record< string, string >`, so the same authored value now reaches
323
+ either resolver — and each answers wrongly, silently, for the other's input: the
324
+ keyed one returns `undefined` for `{ en: 'Owner' }` (no `key`, no
325
+ `defaultValue`), and the spec's reads `key` / `defaultValue` / `params` as locale
326
+ tags. The rc.6 bump PR met this and aliased the spec's import as
327
+ `resolveInlineI18nLabel` in five files, with hand-written comments at two of
328
+ them. That is a review convention, which is what objectstack#4115 exists to
329
+ replace with a rule — so `Keyed` is now the counterpart of that `Inline`, and the
330
+ name says which vocabulary it resolves at every call site.
331
+
332
+ **Eleven keep their names and are now imported or derived from the spec** instead
333
+ of re-declared: `DATE_RANGE_PRESETS`, `NavigationMode`, `AddressValue`,
334
+ `BreakpointColumnMap`, `BreakpointOrderMap`, `KanbanConfig`, `CalendarConfig`,
335
+ `GanttConfig`, plus the three renamed above at their new names.
336
+
337
+ **Four of the copies were losing information, not just duplicating it.**
338
+
339
+ - **`GanttConfig` declared six keys and called itself canonical; rc.6's
340
+ `GanttConfigSchema` declares seventeen.** The eleven it never mentioned —
341
+ `parentField`, `typeField`, `baselineStartField`, `baselineEndField`,
342
+ `groupByField`, `resourceView`, `assigneeField`, `effortField`, `capacity`,
343
+ `quickFilters`, `autoZoomToFilter` — are all read by
344
+ `plugin-gantt/src/ObjectGantt.tsx`, through a local `GanttConfigEx`
345
+ intersection that existed only because this type did not carry them. It now
346
+ derives from the spec, with `timeSegments` (shift segmentation) as the one
347
+ genuinely local extension; the schema is `$loose` upstream, so that key is
348
+ legal metadata rather than a second dialect.
349
+ - **`GanttConfig.tooltipFields` carried the comment "not part of the upstream
350
+ GanttConfigSchema".** It is, as of rc.6, so the key now arrives from the spec.
351
+ - **`AddressValue` declared five of the spec's seven parts** — `countryCode` and
352
+ `formatted` were missing, under a comment already claiming to be "the part
353
+ names of `AddressSchema`". The widget still renders five inputs; binding the
354
+ type stops it from asserting the platform cannot store the other two, and makes
355
+ the `{ ...address }` write-through say so.
356
+ - **`DATE_RANGE_PRESETS` was `Object.keys(PRESET_RANGES)`,** a third copy of a
357
+ vocabulary the spec extracted in objectstack#4614 precisely to collapse — its
358
+ own doc comment names this module as one of the three. It is now the spec's
359
+ array by reference, and the local date-macro bounds table is pinned complete
360
+ against it with `satisfies`, so a preset the schema gains without bounds here
361
+ is a compile error rather than a filter that validates clean and then selects
362
+ nothing.
363
+
364
+ `NavigationMode` was one hop from the spec already (`NavigationConfig['mode']`);
365
+ it is bound directly, with a both-directions type pin that it stays the same type
366
+ as the config's own `mode`. `KanbanConfig` / `CalendarConfig` /
367
+ `BreakpointColumnMap` / `BreakpointOrderMap` were exact hand copies of `$strict`
368
+ schemas and are now re-exports — "still exact" is the argument for binding them,
369
+ since a copy with nothing to protect can only drift.
370
+
371
+ `GlobalFilterSchema` is the one ALLOW entry. It is the same spread-composition
372
+ dialect as `SelectOptionSchema` next to it, and it collided only because rc.6's
373
+ new refinement forced `.extend()` to be respelled as a `.shape` spread — which
374
+ moved a derivation the guard could see into an object literal it deliberately
375
+ does not descend into. The dialect is unchanged and its three divergences are
376
+ pinned; which side moves on the refinement itself is objectui#4165.
377
+
378
+ `@objectstack/spec` moves from `devDependencies` to `dependencies` in
379
+ `@object-ui/layout`: its public type surface now references the spec.
380
+
381
+ ### Patch Changes
382
+
383
+ - ee26e65: Analytics: the dimension label net's fetch-and-memo glue is written once, not once per surface
384
+
385
+ PR #4388 (objectui#4330) put the same React glue on two surfaces — the dashboard's `DatasetWidget` and plugin-report's dataset block. The resolution RULES were never duplicated (both call the same `@object-ui/core` helpers), but the wiring around them was: read the object schema through the host's authenticated `apiFetch`, keep the fetched metadata locale-free in state, derive the label maps in a render memo. Two copies meant two statements of the same two bug fixes, which is a drift surface rather than a defect — nothing a user could hit today, filed as objectui#4389 so it was retired deliberately.
386
+
387
+ It is now split along the layer that can actually hold each half. `@object-ui/core` gains the React-free parts — `loadDimensionFieldMeta` (the base-object read composed with the dimension walk), `deriveDimensionLabelMaps` (the locale-applying derivation) and `dimensionOptionTranslator` (binding the bundle resolver to the object that OWNS a terminal field, which for a dotted path is the relationship target). `@object-ui/react` gains `useDatasetDimensionLabels` / `useDatasetDimensionMeta`, the React wiring that cannot live in core, beside the `useViewData` / `useElementDataSource` / `useDiscovery` hooks that already read `SchemaRendererContext` the same way. Both plugins consume it; the dashboard keeps its chart-only per-category colour and category-order derivation layered locally, since a table renders no palette.
388
+
389
+ The card originally proposed `@object-ui/core` as the whole glue's home. That home was disproven by measurement and retired in the card's PM RULING #2: `SchemaRendererContext` is defined in `@object-ui/react`, which depends on core, so core importing it back is a cycle — and core is React-free by declaration, by content, and by the topology in AGENTS.md. objectui#3367 had already ruled this direction for the same family (core-canonical logic, react re-exports).
390
+
391
+ Behaviour is unchanged by construction: same read count, same best-effort fallback, same memoization boundary. The two bug fixes are now stated once and pinned at the shared hook — the read rides the host's authenticated `apiFetch` (objectui#4121, pinned by asserting that a new channel re-issues the read, i.e. that it really is in the effect's deps), and the fetched metadata stays locale-free (objectui#4030 / PR #4324, pinned by switching language at runtime and asserting the labels flip with no second metadata read). All 39 assertions PR #4388 landed across both surfaces pass unchanged, and their files are byte-identical to before.
392
+
393
+ - 5900ac5: Analytics surfaces now run resolved select-option labels through the locale bundle — the chart legend and the related list on one page stop disagreeing
394
+
395
+ A dashboard widget grouped by a `select` field rendered the option's authored English label while the related list beside it rendered the translation. The decisive evidence in objectui#4030 is the stored value `orion`: the chart read `Orion Engineered Carbons`, a string with no resemblance to the value and matching the object's `label` byte for byte. So the analytics path had already RESOLVED the option label — it simply never ran the result through the i18n bundle before display. (`domestic → Domestic` differs from its value by case alone, which is why the first diagnosis, "the report groups by stored value", was wrong.)
396
+
397
+ There is exactly one resolution channel and this change reuses it rather than adding a chart-side dialect: `fieldOptionLabel` from `useObjectLabel`, i.e. `{ns}.fieldOptions.<object>.<field>.<value>` — the convention `@objectstack/spec` names objectui as the reader of, and the one list, form, kanban and record-picker surfaces already translate select options through. The bundle is applied ONCE, at the output of the label net that landed in objectui#4053/#4263, on the shared option list every consumer reads: chart axis and legend, the table/pivot cells of a dotted dimension, that table's CSV export, per-category colours and the declared category order. `@object-ui/core` gains `localizeFieldOptions` (the pure mirror of `translateOptions`), an optional translator on `buildDimensionLabelMap`, and `resolveDimensionFieldMeta` — the same single relationship walk `resolveDimensionFieldOptions` performs, now keeping the object that OWNS the terminal field, because for `crm_account.industry` the bundle key is `crm_account`, not the dataset's base object.
398
+
399
+ Two properties the fix is shaped around. The rows reach this net keyed either way — by stored value when the server did not resolve the dimension, by the English label when it did (ADR-0021) — and the reported screen is the second case, so the map answers to both keys and lands on the same translated display. And identity is untouched: `relabelDimensions` still rewrites display only, so a drilled chart segment clicked as `欧励隆` filters by `orion`, bucket ids and pivot totals keep their raw keys, and an option with no bundle entry (or an `en` console) renders exactly the authored label it renders today.
400
+
401
+ The per-locale work moved from the metadata fetch into the render, so switching language now re-labels in place instead of waiting for a refetch.
402
+
403
+ Not covered, and unchanged here: a LOCAL select dimension on a table/pivot, whose label the server resolves and whose client-side net is deliberately off (objectui#4263), and a dashboard global filter's own field label, which has no object name in its metadata to key a bundle lookup with — tracked on objectui#4030.
404
+
405
+ - aca27fa: The multi-dimension pivot branch buckets a null first-dimension value instead of dropping its bar (objectui#4497)
406
+
407
+ `buildChartSeries`' pivot branch (2+ dimensions, single measure) bucketed rows by `String(xRaw ?? '')` but wrote the RAW value into the emitted row, so a null first-dimension value produced `{status: null, Low: 3}` and reached recharts with a null category — which draws no mark. Measured at the DOM: a two-group pivot drew ONE bar, and an all-null pivot drew axes and gridlines with zero bar rectangles and no empty state. That is the same mechanism objectui#4466 fixed one branch below, on the branch that card deliberately left pinned as-is until the pivot's own bucketing had been measured.
408
+
409
+ The pivot now maps a null/undefined first-dimension VALUE to the same bucket label the single-dimension branch uses — `ChartSeriesOptions.nullCategoryLabel`, defaulting to `NULL_CATEGORY_LABEL`. One doctrine, one predicate, two call sites; no new export, and every existing call site compiles and behaves identically.
410
+
411
+ The bucket KEY is untouched, which is what keeps this a display fix: `String(xRaw ?? '')` still decides which rows share a bar, so every existing grouping is byte-identical and only the label the bucket carries changes. Rows that lack the category key entirely are still not bucketed — that shape is a dimension grouped by but never projected (framework#4033), a different defect with a different answer.
412
+
413
+ Drill-through needed no change, which was measured rather than assumed: the pivot's emitted rows are AGGREGATED, so they are not index-aligned with `drillRawRows` and the one production caller (`DatasetWidget.handleChartDrill`) already drills by SEARCHING the raw rows through `findChartSeriesRow`. Those raw rows still carry their null, and objectui#4466's label-matching covers the multi-dimension arm as well as the single-dimension one, so the newly-visible bar resolves to the right record. Pinned at both levels so a regression in either half surfaces as the dead click it would be.
414
+
415
+ - 613b167: A dataset dimension on a dotted relationship path now renders its option labels instead of the raw stored enum
416
+
417
+ A `DatasetDimension` whose `field` is a relationship path (`crm_account.industry`) got no select-option resolution at all: the chart plotted `education`, `finance`, `manufacturing` — the database column, unresolved — while the **same underlying field** reached as a **local** dimension rendered `Education`, `Finance`, `Manufacturing` beside it on the same dashboard. Nothing errored, so the widget just quietly showed database enum values to end users; on a non-English deployment those are words that appear nowhere else in the UI, since every form and list shows the translated label.
418
+
419
+ The label lookup read options as `baseObject.fields[<path>]`, which only ever matches the local spelling. For a dotted path the options live on the **related** object, so the lookup missed and the renderer fell through to the stored value.
420
+
421
+ The object-resolution step of that one lookup now walks the path: each segment before the last must be a declared relationship (`lookup` / `master_detail`, target read from `reference` / `reference_to` / `referenceTo` / `reference_to_object`), and the terminal field's options are read off the object that actually owns it. This is the same lookup for both spellings rather than a dotted-path variant beside it — a single-segment path never enters the walk and resolves exactly as before, so the local and joined paths cannot drift apart. Multi-hop paths (`crm_account.owner.department`) resolve too, which is the shape the dataset designer already emits.
422
+
423
+ Hops ride the caller's existing `GET /meta/object/:name` channel — the same authenticated read that fetched the base object — so no new fetch layer is introduced, and objects are fetched once per resolution even when several dimensions share a prefix. Every failure stays best-effort: a segment that is not a relationship, a target that cannot be loaded, or a terminal field with no options yields no mapping and the raw value survives, exactly as it does today.
424
+
425
+ Applies to both surfaces that carried this lookup: dashboard dataset widgets (`DatasetWidget`) and the chart view's dataset path (`ObjectChart`).
426
+
427
+ Scope: this ends at "the label is in hand". Whether that label then passes through the i18n bundle is a separate gap tracked upstream as objectstack#5076.
428
+
429
+ - abb0f81: A dashboard date filter's default has one spelling again — the bare preset name — and the `{ preset }` object becomes a documented legacy alias with a retirement window
430
+
431
+ `@objectstack/spec` 17.0.0-rc.6 added a cross-field refinement to `GlobalFilterSchema` holding a `type: 'date'` filter's `defaultValue` to three spellings: a preset NAME (`last_7_days`), an ISO date (`2026-01-15`), or a date-macro token (`{today}`). objectui's derived schema had widened `defaultValue` to `z.any()` and did not carry the refinement, so it accepted `{ preset: 'last_7_days' }` — metadata the platform refuses. That is the tolerant-consumer shape where the designer goes green and the save fails server-side, and it is now closed: the refinement is adopted, the widening is retired, and the object form is refused with the spec's own message.
432
+
433
+ Per the maintainer ruling on objectui#4165, the spec stays strict and the bare preset name is the single canonical spelling. `{ preset }` is handled as an ADR-0089 legacy alias rather than by a permanently tolerant schema: `liftLegacyGlobalFilterDefault` / `liftLegacyDashboardFilterDefaults` (new exports on `@object-ui/types`) convert it to the bare name, `@object-ui/core`'s `resolveDashboardFilterDefs` applies the lift when it reads a stored dashboard, and the console's dashboard designer applies it as the document enters the editable draft so the next save persists the canonical spelling. The retirement window is recorded at the read site: the alias may be removed in `@object-ui/types` 18.0.0, and every lift warns on the console so a surviving legacy document is visible rather than silently tolerated.
434
+
435
+ No stored dashboard has to change for this release. The lift means a document carrying the object form keeps loading and rendering exactly as before — measured, not assumed: a legacy declaration already resolved correctly, because `{ preset }` also happens to be the runtime value shape objectui's own date filters use, and that coincidence is why the object form went unnoticed for so long. What changes is that the declaration is now canonicalized on read and rewritten on save, so the two spellings converge instead of accreting.
436
+
437
+ The other two divergences in this schema — the bare-string `options` shorthand and the optional `optionsFrom.labelField` — are unaffected. Carrying the spec's refinement while keeping them needed a new composition: a refined object schema in zod 4 rejects `.extend()` and `.omit()` outright and types every `.safeExtend()` override as `never`, so objectui's schema now spreads the spec's shape and re-attaches the spec's object-level rules by delegating to the spec schema itself. Nothing restates the spec's grammar, and a refinement the spec adds later flows in with no change here.
438
+
439
+ - 7e4f0e5: fix(dashboard,i18n): KPI cards and dashboard filters resolve authored labels instead of dropping them (#4032)
440
+
441
+ A `type: 'metric'` dashboard widget rendered raw English while every other widget
442
+ type on the same dashboard rendered the translation, and dashboard filter chips
443
+ rendered `[object Object]` or the raw stored value. Both come from the same
444
+ cause: authored labels reaching a render site that could not read the
445
+ vocabulary `@objectstack/spec` actually admits.
446
+
447
+ - **KPI cards rejoin the widget translation channel.** The self-contained
448
+ `metric` branch built its own label from the raw `widget.title`, so the
449
+ `{ns}.dashboards.{dash}.widgets.{id}.title` value the renderer had already
450
+ resolved was computed and thrown away. It now reads that channel like every
451
+ other widget header.
452
+ - **The three private `resolveLabel` copies** (`DashboardRenderer`,
453
+ `MetricWidget`, `MetricCard`) are gone. Each read the retired
454
+ `{ key, defaultValue }` key-reference form and ended `defaultValue || key`, so
455
+ handed the inline per-locale map the spec admits today they returned nothing —
456
+ a KPI card with a map title rendered the literal string `metric`. All three
457
+ now use `pickLocalized`, the resolver already used for this vocabulary
458
+ elsewhere in the package.
459
+ - **Dashboard filter labels and static option labels resolve per locale.**
460
+ `DashboardFilterDef.label` widens to `string | I18nLabel`, the filter bar
461
+ resolves before rendering (fixing `[object Object]: All` in the trigger, and
462
+ in `aria-label` / `placeholder`), and the `def.label || def.name` gate now
463
+ tests the RESOLVED string — an object is always truthy, so it never reached
464
+ the fallback before.
465
+ - **Option labels are no longer discarded.** `normalizeFilterOptions` coerced a
466
+ map label to the raw stored value in every locale, English included, so
467
+ `{ value: 'domestic', label: { en: 'Domestic', … } }` displayed as `domestic`.
468
+ The pair shape is still normalized; the label vocabulary is preserved for the
469
+ render side to resolve.
470
+ - **`DashboardComponentSchema.globalFilters` is bound to the spec's
471
+ `GlobalFilter`** instead of restated by hand. The restatement was both too
472
+ narrow (`label?: string`, which is what made these read sites invisible to
473
+ `tsc`) and too wide (it declared a bare-string option shorthand the spec
474
+ rejects at publish).
475
+
476
+ Plain-string labels are unaffected and render byte-identically.
477
+
478
+ - 49ae9f4: Pivot buckets encode an empty dimension value as JSON `null`, so it no longer collides with a row whose value is literally the placeholder character
479
+
480
+ objectstack#5473 / objectstack#5665 replaced the pivot's delimiter-joined ids
481
+ with `JSON.stringify`, because every delimiter that had been tried — an empty
482
+ string, a plain space, a control character — assumed the data would not contain
483
+ it, and each assumption failed on ordinary data. This closes the last place the
484
+ same assumption survived: the ids were JSON, but the VALUES fed into them were
485
+ spelled `String(row[d] ?? '∅')`, so an absent dimension value became the
486
+ ordinary string `"∅"` and shared a bucket with a row whose value literally is
487
+ that character (U+2205). One bucket, later row overwriting the earlier one — the
488
+ cell showed a different row's measure, the overwritten row was unreachable, and
489
+ drill-through followed the same wrong index into the wrong records, all without
490
+ an error. The trigger requires that character to appear as a dimension value, so
491
+ this is the assumption being removed rather than a defect users hit today.
492
+
493
+ An empty value now encodes as JSON `null`, which `JSON.stringify` renders as a
494
+ bare `null` that no string can spell. The normalization lives in
495
+ `@object-ui/core` as `pivotDimensionValue` (absent ⇒ `null`, everything else ⇒
496
+ its string form) rather than at each call site, because a placeholder spelled by
497
+ a caller is a placeholder that can collide again — which is exactly how this one
498
+ survived the previous fix. `pivotBucketId` accepts `Array<string | null>`
499
+ accordingly; that is a widening, so existing callers passing `string[]` are
500
+ unaffected.
501
+
502
+ Both renderers' bucket keys move together, which the fix requires: a bucket id
503
+ and the subtotal map keyed by it are built from the same expression, so changing
504
+ one alone would split the headers while the subtotal map still merged, landing
505
+ every column subtotal under the wrong header. In `plugin-dashboard`'s
506
+ `DatasetWidget` that is the row bucket id, the column bucket id, the cell key,
507
+ and both the `rowTotalById` and `colTotalById` lookups; in `plugin-report`'s
508
+ `DatasetReportRenderer` the single `bucketId` helper already feeds all five.
509
+
510
+ The dashboard's column bucket id also stops being a bare string and becomes a
511
+ one-element tuple through the same shared encoder. It was the one id in the
512
+ family still built by hand, on the reasoning that a single value needs no
513
+ boundary — true of the boundary, false of everything else the encoder does, and
514
+ it is why the across axis kept carrying this collision after the row ids were
515
+ fixed.
516
+
517
+ No display change: these placeholders only ever entered ids, never labels. An
518
+ unset dimension still renders through `formatDimensionValue` exactly as before,
519
+ and data containing neither an absent value nor that character buckets
520
+ identically — the ids are opaque lookup keys, never parsed back into a value,
521
+ never shown, never persisted.
522
+
523
+ - d6aa172: Retire `params.newTab` on a url action — `openIn: 'new-tab'` is the sanctioned spelling
524
+
525
+ `ActionRunner`'s navigator read a legacy `params.newTab` escape hatch below `openIn` and above the external-URL heuristic. That read is removed, executing the objectstack#6828 maintainer ruling of 2026-08-10, whose contract half shipped in objectstack PR #7375: the url-side readings of an object-form `params` are retired, not renamed.
526
+
527
+ Nothing that ever validated can regress. `params` is declared as `z.array(ActionParamSchema)`, so an object-form `params` has always failed the props parse — the fallback could only fire on a stack the spec refuses. The removal also closes a collision hazard: a params dialog declaring a field named `newTab` had the user's own collected input silently steering navigation.
528
+
529
+ `openIn: 'self' | 'new-tab'`, the legacy `navigate.newTab` modifier on the `navigation` shape, and the external/relative default are all unchanged.
530
+
531
+ - 9461dd3: Form actions no longer carry a record id across an object boundary (#4292).
532
+
533
+ `ActionRunner.executeForm` forwarded `/forms/:name?recordId=<id>` unconditionally,
534
+ and that URL says nothing about which object the id belongs to — so the form route
535
+ resolved it against the FormView's own target object. When an action fired from a
536
+ record of a DIFFERENT object and ids collide across objects (per-table integer
537
+ keys), the form silently prefilled and, since the route learned to honour the param,
538
+ `PATCH`ed a same-id record of the wrong object.
539
+
540
+ - **Producer**: the id is forwarded only when the firing context record's object
541
+ (`context.objectName`) matches the target view's object; on a mismatch no id is
542
+ forwarded, preserving create semantics. When it IS forwarded, the object travels
543
+ with it as `?recordObject=`.
544
+ - **Consumer**: `/forms/:name` refuses — no record read, no write — when
545
+ `recordObject` disagrees with the FormView's object. A URL without the param
546
+ behaves exactly as before, so existing deep links are unaffected.
547
+
548
+ - Updated dependencies [f650253]
549
+ - Updated dependencies [3d9769a]
550
+ - Updated dependencies [92876f0]
551
+ - Updated dependencies [d9d3463]
552
+ - Updated dependencies [2a40f69]
553
+ - Updated dependencies [bec3e14]
554
+ - Updated dependencies [1f9b905]
555
+ - Updated dependencies [abb0f81]
556
+ - Updated dependencies [38ab505]
557
+ - Updated dependencies [7e4f0e5]
558
+ - Updated dependencies [c1d939f]
559
+ - Updated dependencies [bb68488]
560
+ - Updated dependencies [ab04728]
561
+ - @object-ui/types@17.5.0
562
+
563
+ ## 17.4.0
564
+
565
+ ### Minor Changes
566
+
567
+ - c3b01a7: Give composite and grouped field widgets a real accessible name: the form renderer now associates its label by IDREF for widgets that declare `labelling: 'group'`, instead of emitting a `<label for>` that nothing labelable answers (objectui#3961).
568
+
569
+ Six widgets rendered a visible group label that named **nothing** in the accessibility tree. Measured in a real form, one field per row, reading each label's `for` against the DOM:
570
+
571
+ ```
572
+ address for=…-form-item -> MISSING byLabelText=0 role+name=0
573
+ geolocation for=…-form-item -> MISSING byLabelText=0 role+name=0
574
+ checkboxes for=…-form-item -> div byLabelText=0 role+name=0
575
+ radio for=…-form-item -> div[radiogroup] byLabelText=0 role+name=0
576
+ rating for=…-form-item -> div byLabelText=0 role+name=0
577
+ file for=…-form-item -> div[role=button] byLabelText=0 role+name=0
578
+ ```
579
+
580
+ Two shapes, one outcome. `address` / `geolocation` spread the host's id onto their first sub-input and then replaced it with that input's own unique id (objectui#3343, correct in itself), so the `for` named an id no element carried — clicking "Shipping Address" did nothing and the group label was absent from the accessibility tree entirely. `checkboxes` / `radio` / `rating` / `file` kept the id, but on a `div`: a `<label for>` on a non-labelable element is inert HTML — `HTMLLabelElement.control` is `null`, so it activates nothing and contributes no name. A screen reader heard "Street Address", "City", "Alpha", "Beta" — never which group they belonged to.
581
+
582
+ The fix is the WAI-ARIA group pattern, driven by a DECLARATION rather than by the host guessing at widget DOM:
583
+
584
+ - `@object-ui/core` — `ComponentMeta` gains `labelling?: 'control' | 'group'`. Additive and optional; absent means `'control'`, which is every existing component's behaviour.
585
+ - `@object-ui/components` — the form renderer reads it. For a `'group'` field the `<FormLabel>` publishes an `id` and drops its `for`, and the widget receives `aria-labelledby`. The single-control path is unchanged down to the attribute: no id on the label, no `aria-labelledby` key on the widget, so no field acquires a second naming channel. `ui/form.tsx` is untouched (Shadcn no-touch) — both halves travel as ordinary props, since `<FormLabel>` spreads props after its own `htmlFor`.
586
+ - `@object-ui/fields` — the six audited widgets declare `labelling: 'group'`. `address` / `geolocation` move the host id (and only the id) from their first sub-input to the group container; `checkboxes` / `rating` answer a host-supplied `aria-labelledby` with `role="group"`; `radio` keeps Radix's more specific `radiogroup`; `file` takes the name on its dropzone with no invented group layer, because it has exactly one control that merely happens not to be labelable.
587
+
588
+ No new key in the widget props contract: `aria-*` is already declared on it and forwarded by `toDomProps`, the same channel `aria-required` (objectui#3290) travels.
589
+
590
+ Deliberately unchanged: sub-labels keep naming their own inputs (`aria-labelledby` overrides `<label for>`, so putting the group name on the first sub-input would have replaced "Street Address" with the field name — the concatenated-name outcome this issue rejected), `aria-describedby` stays on the first focusable sub-input where focus can reach it (objectui#3318), the sub-input ids of objectui#3343 do not move, and standalone rendering — the inline grid editor, a bare SDUI node, where nobody hands down an id and there is no host label to point at — emits no role and no IDREF at all.
591
+
592
+ A widget that does not declare itself keeps the old `for`, which the label-association tests report as an association resolving to a non-labelable element. Silence was the failure mode being fixed; the default path stays loud.
593
+
594
+ - 48132f7: Track the `@objectstack` family at `17.0.0-rc.5` (objectui#3560).
595
+
596
+ The pin moves from `^17.0.0-rc.2` to `^17.0.0-rc.5` across all 37 declarations in
597
+ 30 `package.json` files, and the sibling `@objectstack/*` packages (`client` /
598
+ `formula` / `lint`) move with it — they pin `@objectstack/spec` **exactly**, so
599
+ leaving them behind would keep a second copy of the spec in the tree and have
600
+ `@objectstack/lint` validating against schemas that still accept the keys rc.3–rc.5
601
+ retire. `pnpm-lock.yaml` now resolves one copy of each of the six family packages
602
+ (`spec` / `client` / `core` / `formula` / `lint` / `sdui-parser`), all at rc.5.
603
+
604
+ Bumping the pin and repairing the fallout cannot be split: the pin alone reddens
605
+ CI, and the code alone targets a shape that is not in effect yet.
606
+
607
+ ## A live bug this upgrade fixes
608
+
609
+ **`ObjectStackDataSource.delete()` never emitted its mutation event, and resolved
610
+ `undefined` instead of a boolean.** `@objectstack/client`'s `DeleteDataResult`
611
+ declared a key called `deleted` — a key no schema has ever declared and no server
612
+ path has ever returned on `DELETE /data/:object/:id`. So `result.deleted`
613
+ compiled and read `undefined` at runtime: the guard never fired, a successful
614
+ delete notified no subscriber, and every consumer's cache stayed stale.
615
+ objectstack#5638 corrected the interface to the schema's `success`; following the
616
+ rename is what restores both behaviours. Nothing in this repo had to change shape
617
+ for it — the code was already asking the right question of the wrong key.
618
+
619
+ ## Breaking, in FROM → TO form
620
+
621
+ - **The five `@objectstack/spec/ui` interaction-config modules are gone** —
622
+ touch / dnd / keyboard / animation / offline, 32 defs and 64 exports
623
+ (objectstack#4988, PR objectstack#5321). None of them had an authoring door: no
624
+ metadata document could ever carry one of these blocks, so a stack that parsed
625
+ before the retirement parses byte-for-byte the same after it. `@object-ui/types`
626
+ drops the 32 `export type` re-exports. The vocabulary each one's only real
627
+ consumer needs is now declared by that consumer, which is the remedy the spec's
628
+ own retirement ledger prescribes ("declare that union locally — it is your
629
+ client's policy, not the platform's"):
630
+
631
+ - `@object-ui/react`'s `useOffline` owns `OfflineStrategy`, `ConflictResolution`,
632
+ `PersistStorageType`, `EvictionPolicyType`, `OfflineConfig`,
633
+ `OfflineCacheConfig`, `OfflineSyncConfig`;
634
+ - `@object-ui/core`'s `DndProtocol` / `KeyboardProtocol` own `DndConfig`,
635
+ `DragItem`, `DropZone`, `DragConstraint`, `DragHandle`, `DropEffect`,
636
+ `KeyboardNavigationConfig`, `KeyboardShortcut`, `FocusManagement`,
637
+ `FocusTrapConfig`;
638
+ - `@object-ui/types`' `mobile` module owns `SpecGestureConfig`,
639
+ `SwipeGestureConfig`, `PinchGestureConfig`, `LongPressGestureConfig`,
640
+ `TouchTargetConfig`, `TouchInteraction` (plus a new `SPEC_GESTURE_TYPES`
641
+ runtime tuple), so `@object-ui/mobile`'s import paths are unchanged.
642
+
643
+ Every shape is moved verbatim — same keys, same members, same optionality — so
644
+ no hook or bridge changes behaviour. Consumers importing these names from
645
+ `@object-ui/types` must import them from the owning package instead. Note the
646
+ spec's _surviving_ `ConnectorConflictResolution` (`/integration`, connector sync)
647
+ and `ConflictResolutionStrategy` (`/api`, route merge policy) are **different
648
+ concepts** — do not re-point at them.
649
+
650
+ - **`@object-ui/types` no longer re-exports `NotificationAction` or `EmbedConfig`**
651
+ (objectstack#5015, PR objectstack#5300). Both were published `ui` vocabulary with
652
+ no authoring door; no notification action was ever parsed from metadata and no
653
+ iframe route ever read an embed config. The presentation enums
654
+ (`NotificationType` / `NotificationSeverity` / `NotificationPosition`) and
655
+ `SharingConfig` **survive** and are untouched — public form sharing still gates
656
+ the anonymous endpoints on `allowAnonymous` + `publicLink`.
657
+ `@object-ui/core`'s `SharingProtocol` keeps `resolveEmbedConfig` /
658
+ `generateEmbedCode` against a locally declared `EmbedConfig`, so its surface is
659
+ unchanged.
660
+ - **`ThemeEngine` stops emitting nine retired CSS variable groups**
661
+ (objectstack#5021 option 2, PR objectstack#5289). `theme.animation`,
662
+ `theme.zIndex` and five typography groups (`fontSize` / `fontWeight` /
663
+ `lineHeight` / `letterSpacing`, plus `fontFamily.heading` / `fontFamily.mono`)
664
+ are tombstones the schema now rejects by name, so `--duration-*`, `--timing-*`,
665
+ `--z-*`, `--font-size-*`, `--font-weight-*`, `--line-height-*`,
666
+ `--letter-spacing-*`, `--font-heading` and `--font-mono` had become structurally
667
+ dead code — no author could produce the input that reached them.
668
+ `generateAnimationVars` and `generateZIndexVars` are removed from
669
+ `@object-ui/core`, and `@object-ui/types` drops `Animation` / `ZIndex` /
670
+ `AnimationSchema` / `ZIndexSchema`. **`theme.customVars` is the declared — and
671
+ since #5021 the only — door**: each entry is emitted verbatim as
672
+ `--<key>: <value>`, so a `--z-modal` or a `--duration-fast` goes there now.
673
+ LIVE emission is untouched byte for byte: `colors`, `borderRadius`, `shadows`,
674
+ `typography.fontFamily.base` (→ `--font-sans`) and `customVars`.
675
+ - **`@object-ui/types`' `HttpMethodSchema` now binds the spec's
676
+ `HttpMethodSubsetSchema`, and `HttpMethod` binds `HttpMethodSubset`**
677
+ (objectstack#5832, PR objectstack#5976 — objectui#3499). The spec renamed its
678
+ 5-value UI subset because `schemaNameFromExportKey` strips the `Schema` suffix,
679
+ so the 5-value and 7-value enums both published as `shared/HttpMethod` and the
680
+ later write won — the emitted JSON Schema and reference page described only one
681
+ of them. **The runtime domain is unchanged and this repo's exported names are
682
+ unchanged**; this follows the rename without touching cross-package semantics.
683
+ Deliberately NOT re-pointed at the spec's bare `HttpMethod`: that is the 7-value
684
+ enum, and widening to it would let `method: 'HEAD'` compile and then throw in
685
+ `HttpRequestSchema.parse()`.
686
+ - **`dashboard.widgets[].actionUrl` / `actionType` / `actionIcon` / `aria` are
687
+ refused, not stripped** (objectstack#5010, ADR-0049 enforce-or-remove). A
688
+ dashboard widget has no action button and never had one — every action the
689
+ dashboard dispatches comes from `header.actions[]` — and no renderer ever applied
690
+ the widget `aria`, so it promised accessibility compliance it did not deliver.
691
+ A stale dashboard now gets a named error telling it where the affordance moved,
692
+ instead of silently losing it. Run `os migrate meta --from 16` to rewrite.
693
+
694
+ ### Patch Changes
695
+
696
+ - 6719877: `condition: false` now actually prevents the action from executing (objectui#3872)
697
+
698
+ `ActionRunner.execute` — the engine's public execution entry, shared by every
699
+ action surface — gated conditional execution on `if (action.condition)`, i.e. on
700
+ the raw value's TRUTHINESS. Truthiness cannot answer the question the gate needs
701
+ answered ("did the author declare a condition?"), and on this key it answered in
702
+ the over-permissive direction: `condition: false` fell on `if (false)`, so the
703
+ whole block was skipped, `evaluateCondition` was never consulted, and the action
704
+ executed. Measured with a call-counting handler:
705
+
706
+ - `condition: false` — handler ran: **true**, result `{ success: true }`
707
+ - `condition: { dialect: 'cel', source: 'false' }` — handler ran: false, result `{ success: false, error: 'Action condition not met' }`
708
+
709
+ Two spellings of the same statement, opposite outcomes. `false` is the most
710
+ explicit "never execute this" metadata can carry — and what a template that
711
+ switches an action off emits — so the direction matters: the action really ran,
712
+ possibly writing. This is the over-permission half of the objectui#3492 family
713
+ (the `disabled` gate one line below is objectui#3848, whose defect pointed the
714
+ other way).
715
+
716
+ The gate now asks whether a `condition` gate is DECLARED before evaluating.
717
+ "Nothing to evaluate" is read from core's single predicate normalizer
718
+ (`toPredicateInput`, which maps `''`, `null`, an empty-`source` envelope and
719
+ non-predicate values to `undefined`), plus the whitespace-only string, which the
720
+ normalizer wraps rather than collapses — objectui#3850's ruling on the scope of
721
+ "empty predicate", the same one the `disabled` gate already applies. Once the
722
+ door asks the right question the verdict needs no boolean branch of its own:
723
+ `evaluateCondition` returns a boolean argument verbatim.
724
+
725
+ **Behaviour change surface, deliberately one-directional and one row wide.**
726
+ Exactly one shape changes verdict — a declared boolean `false`, from executing to
727
+ refused (`{ success: false, error: 'Action condition not met' }`, the message the
728
+ key already used). Everything else is byte-identical: `condition: true`, an
729
+ absent `condition`, and truthy expressions/envelopes still execute; falsy
730
+ expressions, falsy CEL envelopes and falsy `${…}` templates are still refused,
731
+ as before; the three empty predicates (`''`, whitespace-only, an empty-`source`
732
+ envelope) still execute, now because nothing was declared rather than because
733
+ `if ('')` happened to be falsy; and non-predicate junk (`0`, `{}`) still
734
+ executes — a value that is not a predicate must not decide an action's fate,
735
+ which is the fail-open posture this module already committed to for `disabled`.
736
+ So this change can only start refusing execution, never start allowing it — the
737
+ mirror image of objectui#3848's fix.
738
+
739
+ `ActionDef.condition` is widened to `string | boolean` to match what the gate now
740
+ honours (and the `disabled` key beside it). This is not a lenient consumer
741
+ alias: the boolean was always accepted at runtime through the interface's index
742
+ signature, it was simply ignored.
743
+
744
+ The value handed to the evaluator is deliberately left RAW rather than normalized
745
+ first, for the same reason as objectui#3848 with the sign flipped:
746
+ `toPredicateInput` wraps unconditionally, so an already-templated `'${x}'`
747
+ becomes `'${${x}}'`, fails to parse, returns verbatim and coerces to a constant
748
+ `true` — on `disabled` that blocks everything, on `condition` it would EXECUTE
749
+ everything. That normalizer defect is objectui#3871; a tripwire next to the new
750
+ pins goes red the day it is fixed.
751
+
752
+ - 56ff091: An empty `disabled` predicate no longer refuses to run the action (objectui#3848)
753
+
754
+ `ActionRunner.execute` — the engine's public execution entry, shared by every
755
+ action surface — gated on `action.disabled != null && action.disabled !== false`
756
+ and handed the value straight to `evaluateCondition`. That function documents one
757
+ default for "there is no condition here": return `true`, meaning
758
+ _visible/enabled_. On `disabled`, `true` means BLOCKED. So every empty predicate
759
+ was read as "disabled": the handler was never invoked and the caller got
760
+ `{ success: false, error: 'Action is disabled' }` — a state the metadata never
761
+ declared. Measured with a call-counting handler:
762
+
763
+ - `disabled: ''` — handler ran: false
764
+ - `disabled: ' '` (whitespace only) — handler ran: false
765
+ - `disabled: { dialect: 'cel', source: '' }` (the empty envelope `objectstack build` can emit) — handler ran: false
766
+
767
+ After objectui#3842 / objectui#3849 fixed the renderer halves, this was live
768
+ user-visible behaviour: the button became clickable and clicking it returned
769
+ `Action is disabled` — the renderer and the execution entry disagreeing about one
770
+ predicate value, the shape objectui#3314 already paid for once.
771
+
772
+ The gate now asks whether a `disabled` gate is DECLARED — whether there is a
773
+ condition to reach a verdict on — before evaluating. "Nothing to evaluate" is
774
+ read from core's single predicate normalizer (`toPredicateInput`, which maps
775
+ `''`, `null`, an empty-`source` envelope and non-predicate values to
776
+ `undefined`), plus the whitespace-only string, which the normalizer wraps rather
777
+ than collapses and which `evaluateCondition` itself calls "no condition"
778
+ (`evalRowPredicate` applies the same blank-source rule).
779
+
780
+ **Behaviour change surface, deliberately one-directional.** Only values with
781
+ nothing to evaluate change, and only from blocked to allowed: `''`,
782
+ whitespace-only, an empty-`source` envelope, and non-predicate junk (`0`, `{}`,
783
+ which previously coerced to "disabled"). `disabled: true`, a truthy expression
784
+ and a truthy CEL envelope still block; `disabled: false` and an absent `disabled`
785
+ still run; no expression- or envelope-valued predicate changes verdict. The
786
+ existing `catch { isDisabled = false }` fail-open posture is untouched, and this
787
+ change can only stop blocking things, never start.
788
+
789
+ The value handed to the evaluator is deliberately left RAW rather than normalized
790
+ first. `evaluateCondition(toPredicateInput(x))` is not interchangeable with
791
+ `evaluateCondition(x)` for a string that is already a `${…}` template:
792
+ `toPredicateInput` assumes a bare expression and wraps unconditionally, so
793
+ `'${x}'` becomes `'${${x}}'`, fails to parse, returns verbatim, and coerces to a
794
+ constant `true` — a template-spelled predicate evaluated that way is ALWAYS
795
+ "disabled", whatever it says. That normalizer defect is filed as objectui#3871
796
+ (it is live at the action renderers and `ActionEngine`, while `SchemaRenderer` and
797
+ `page:header` evaluate the raw value and pin the correct verdict); a tripwire next
798
+ to the new pins goes red the day it is fixed. Two rows therefore still differ
799
+ between the execution and renderer paths, each recorded with its owning issue:
800
+ the empty envelope (objectui#3850 owns the renderer half's scope ruling) and the
801
+ `${…}` spelling (objectui#3871).
802
+
803
+ - 4bc6c23: Converge dashboard widget `compareTo` on the executor's `{ kind, dimension? }` contract, and make the dataset path actually render a comparison
804
+
805
+ `CompareToConfig` was a three-branch union (`'previousPeriod' | 'previousYear' | { offset }`). `@objectstack/spec` collapsed it to the shape the analytics executor already implements — `DatasetCompareTo`, a plain strict object `{ kind: 'previousPeriod' | 'previousYear'; dimension?: string }` (objectstack#5011) — so this renderer now reads that one shape:
806
+
807
+ - `shiftFilterByCompareTo` / `compareToTrendLabelKey` dispatch on `compareTo.kind`. The `{ offset }` duration shift is gone: `{ offset: '1y' }` is `kind: 'previousYear'`, while `'7d'` / `'1M'` have no faithful target and are restated by the author on the widget's own `filter` plus `kind: 'previousPeriod'`. No trend label key is retired — the offset arm resolved to `vsPreviousPeriod`, which survives as the `previousPeriod` fallback.
808
+ - `DatasetWidget` no longer discards part of `compareTo`. It used to forward only the object form because the two string forms had no meaning downstream; with one shape there is nothing to discard, and a stale string is now invalid metadata rejected where it is authored rather than silently reinterpreted here.
809
+ - **The comparison now actually runs on the dataset path.** A widget states its window in its own `filter` (a date macro, or the dashboard date-range filter merged in), but the executor shifts a `timeDimensions` entry carrying a `dateRange` — so a dataset widget asking for a comparison got "compareTo needs a dated window to shift" and rendered none. When (and only when) a comparison is requested, the resolved filter's bounded date windows are lowered into `selection.timeDimensions[].dateRange` and moved out of `runtimeFilter` (a copy left behind would intersect the shifted window with the current one and empty every comparison column). Which dimension gets shifted stays the executor's decision: every window found is lowered under the name the author wrote, and zero or two candidates surfaces the executor's own error, listing them.
810
+ - The `<measure>__compare` columns that come back are now shown: a delta + window label on KPI widgets, a comparison column on tables, and a `variant: 'comparison'` overlay series on charts — the same treatment and the same `dashboard.trend.*` labels the inline object-provider widgets already use.
811
+
812
+ - e06810e: `PageComponentSchema.dataSource` is now consumed instead of discarded — a
813
+ `list-view` page component can reference a **saved view by name** for the first
814
+ time, and writing the binding no longer breaks the component
815
+ (objectstack#5576).
816
+
817
+ The spec declares a per-element data binding on every page component —
818
+ `dataSource: { object, view?, filter?, sort?, limit? }` — and objectui read none
819
+ of it. `ViewDataProvider.resolveElementDataSource` forwarded
820
+ `filter`/`sort`/`limit` and dropped `view` entirely, and had no caller outside its
821
+ own test; nothing mapped `object` onto the `objectName` a list actually reads. So
822
+ "reference a saved view by name" was published, validated and inert, and every
823
+ page that wanted a saved view's columns/filter/sort had to inline a second copy of
824
+ them — the drift the binding exists to remove.
825
+
826
+ Writing the binding also **broke** the block, for a reason unrelated to `view`:
827
+ `SchemaRenderer` spread the schema's `dataSource` metadata onto the component as a
828
+ React prop, and that is the prop name the host uses to inject the data-source
829
+ ADAPTER. The plain `{ object, view }` object shadowed the adapter, so the first
830
+ `dataSource.find(…)` threw `dataSource.find is not a function` and `list-view`
831
+ rendered "Couldn't load records" — a spec-compliant component failing next to
832
+ identical ones that omitted the binding.
833
+
834
+ - `@object-ui/react` — `SchemaRenderer` no longer spreads `schema.dataSource` as a
835
+ prop (it is metadata, like `visibleWhen`); renderers read it off `schema`. An
836
+ explicit React `dataSource` prop is unaffected. New
837
+ `useElementDataSource(schema, dataSource?)` hook resolves a binding, fetching
838
+ the named saved view from the object definition's `listViews` and the metadata
839
+ overlay's `listViews()`.
840
+ - `@object-ui/core` — new `isElementDataSourceConfig` / `collectSavedViews` /
841
+ `resolveSavedView` / `composeElementDataSource`, and `resolveElementDataSource`
842
+ now honours `view` through an optional `DataFetcher.fetchViews`, reporting an
843
+ unresolvable view as an error instead of silently returning every record.
844
+ `resolveViewId` moved here from `@object-ui/app-shell` (re-exported there) so
845
+ one matcher serves both the object page and a page component.
846
+ - `@object-ui/plugin-list` — `list-view` maps the binding onto the props
847
+ `ListView` reads. `dataSource.*` keys are authoritative, view-supplied values
848
+ are a baseline the component's own keys override, and `filter` AND-combines at
849
+ every level (the spec calls the binding's filter "additional criteria"), so a
850
+ binding can narrow a saved view but never widen it. A `view` name that does not
851
+ resolve renders a configuration error naming the object's actual views and
852
+ issues no query — it never falls back to the object's default view, because that
853
+ turns a typo into a silently wider answer.
854
+
855
+ - ab3ad4f: An empty predicate is no longer a declared gate anywhere (objectui#3850, objectui#3862)
856
+
857
+ "Is a gate DECLARED on this key — is there a condition to reach a verdict on?" was
858
+ answered three times in this repo, with three different scopes, and the widest
859
+ answers sat on `disabled`, where the mistake is not benign:
860
+
861
+ - `hasDeclaredVisibilityGate` (the action face) asked `!= null && !== ''`, so every
862
+ OBJECT counted — including `{ dialect: 'cel', source: '' }`. That envelope is not
863
+ a hand-written curiosity: `@objectstack/spec`'s `ExpressionInputSchema` normalizes
864
+ every authored predicate into one, so "the author left the predicate empty"
865
+ compiles to exactly it. The verdict path normalized the same value back to
866
+ `undefined`, and `evaluateCondition(undefined)` answers `true` — "no condition, so
867
+ visible/enabled". On `visible` that `true` means SHOW, so the two mistakes
868
+ cancelled; on `disabled` it means GREY, so they compounded: a button disabled
869
+ forever that no author asked to disable (objectui#3850, the residue objectui#3842
870
+ left behind).
871
+ - `SchemaRenderer` asked `disabled !== undefined` inline, one notch wider again, so
872
+ `disabled: null` greyed out too — on the GENERIC rendering path, since that block
873
+ runs for every node type, and not as an internal flag either: `_disabled` is
874
+ forwarded to the component as a real `disabled` prop (objectui#3862).
875
+ - `ActionRunner`'s execution gates asked "does this normalize to something
876
+ evaluable?" — the scope that turned out to be right (objectui#3848 / objectui#3872).
877
+
878
+ There is now ONE definition, `hasDeclaredPredicate`, exported from
879
+ `@object-ui/core` (`evaluator/declaredPredicate.ts`, beside the `toPredicateInput`
880
+ normalizer it is derived from): a gate is declared when normalization still leaves a
881
+ condition to evaluate. `''`, a whitespace-only string, an empty-`source` envelope
882
+ and any non-predicate value (`0`, `{}`) are NOT declared; `false` IS (a verdict is
883
+ not a missing gate — objectui#3812). `hasDeclaredVisibilityGate` keeps its name as a
884
+ re-export of it, so the five member-action renderer call sites, `DeclaredActionsBar`
885
+ and `record-quick-actions` are unchanged and inherit the scope;
886
+ `SchemaRenderer`'s `disabled` / `disabledOn` chain and `ActionRunner`'s two gates
887
+ read the same function. No consumer got a local "and also check for empty" test —
888
+ that fourth dialect is what objectui#3842 / objectui#3849 spent two PRs merging away.
889
+
890
+ Measured behaviour change, `action:button` and the generic path, before → after:
891
+
892
+ | value | `visible` | `disabled` | `enabled` | `SchemaRenderer` `disabled` prop |
893
+ | --------------------------------------------------------- | -------------- | ---------- | --------- | -------------------------------- |
894
+ | `''` | shown → shown | on → on | on → on | forwarded → absent |
895
+ | `null` | shown → shown | on → on | on → on | forwarded → absent |
896
+ | `{ dialect: 'cel', source: '' }` | shown → shown | GREY → on | on → on | forwarded → absent |
897
+ | `{ source: '' }` | shown → shown | GREY → on | on → on | forwarded → absent |
898
+ | `' '` (whitespace) | HIDDEN → shown | on → on | GREY → on | forwarded → absent |
899
+ | `0` / `{}` (not predicates) | shown → shown | GREY → on | on → on | forwarded → absent |
900
+ | `true` / `false` / bare CEL / `${…}` / non-empty envelope | unchanged | unchanged | unchanged | unchanged |
901
+
902
+ Every row moves toward "there is no gate here", never away from it, and no value
903
+ that HAS a verdict changes it — the verdict is still read from the raw value, only
904
+ the gate in front of it narrowed. Two rows are behaviour changes rather than the
905
+ equivalence the ruling expected, and are pinned as such: the whitespace string moves
906
+ on `visible` / `enabled` (it used to normalize to `'${ }'`, which evaluates falsy,
907
+ so a predicate that says nothing HID the action from everyone), and non-predicate
908
+ junk stops greying controls out (fail-open, the posture `ActionRunner` already
909
+ committed to).
910
+
911
+ One blank spelling is knowingly still outside the scope: an envelope whose `source`
912
+ is blank but not EMPTY (`{ dialect: 'cel', source: ' ' }`) — the normalizer folds a
913
+ `source` of `''` and does not trim, so the string spelling of a blank predicate is
914
+ trimmed and the envelope spelling is not, and `disabled` still greys out for that one
915
+ value. The ruling enumerated three empty spellings; this is a fourth, measured and
916
+ filed as objectui#3960 rather than widened in here.
917
+
918
+ One chain is deliberately untouched: `SchemaRenderer`'s `visible` / `visibleWhen` /
919
+ `visibleOn` / `visibility` / `hidden` / `hiddenOn` legs keep `!== undefined`, because
920
+ narrowing them would change ALIAS PRECEDENCE, not just emptiness. The `hidden` legs
921
+ are not negated and therefore carry this same defect with the polarity that makes the
922
+ node vanish — measured, out of this ruling's scope, filed as objectui#3955.
923
+
924
+ - c2fd122: fix(actions): forward `bodyShape` end-to-end so a declared body wrap is honoured
925
+
926
+ Sibling of the `bodyExtra` fix, same failure shape one key over. `bodyShape` is
927
+ the spec's body-WRAPPING declaration for a `type: 'api'` action — `'flat'` (the
928
+ default) or `{ wrap: key }` to nest the collected params under `key`, the shape
929
+ better-auth's `organization/update` needs. The console `apiHandler` read it
930
+ unconditionally while **no** action renderer forwarded it, so an author who
931
+ declared `bodyShape: { wrap: 'data' }` on an `action:button` / `:group` / `:icon`
932
+ / `:menu` action got a FLAT body on the wire: the endpoint received the params at
933
+ the top level, and the declaration read as honoured because it parsed and
934
+ published.
935
+
936
+ The four declared-action renderers now forward the key, and `ActionSchema`
937
+ declares it (typed by derivation from the spec, so the union cannot drift).
938
+ `ActionRunner.executeAPI` — the fallback path taken when no host registered an
939
+ `api` handler — now reads it too, closing a second asymmetry in which the same
940
+ action changed body shape depending on which host executed it. The wrap covers
941
+ the collected params only; `bodyExtra` and other top-level keys stay flat, which
942
+ is the spec's own wording for the key and what both console read-sites already
943
+ did.
944
+
945
+ `element:button` deliberately does **not** forward it: its whitelist mirrors
946
+ spec's `InlineActionSchema` pick list field for field, and that pick list does
947
+ not include `bodyShape` — it is not inline vocabulary.
948
+
949
+ - 1d723e3: fix(core): stop re-wrapping an already-`${…}` predicate, so action-face `visible` / `disabled` finally honour it (objectui#3871)
950
+
951
+ `toPredicateInput` — the one normalizer every action surface and the action
952
+ engine share — wrapped **every** string as `${string}`, assuming a bare
953
+ expression. But `${…}` is a spelling this repo documents for a predicate
954
+ (AGENTS.md §4) and one the normalizer's own output type lists as valid, so an
955
+ already-normalized value was wrapped a second time: `'${x}'` became `'${${x}}'`,
956
+ which cannot match the evaluator's single-template fast path and does not parse.
957
+ The author's expression then decided nothing, and which constant came back
958
+ depended on the caller's error policy:
959
+
960
+ - **fail-soft** legs (every `disabled` / `enabled` leg; `visible` on
961
+ `action:icon`, `action:group` and the related-list toolbar) got the unparsed
962
+ string back, so `Boolean(…)` was a constant `true`: `disabled: '${…}'` greyed
963
+ the action out permanently, `visible: '${…}'` showed one the author had gated
964
+ away, and `enabled: '${…}'` never disabled anything.
965
+ - **fail-closed** legs (`throwOnError: true` — `visible` on `action:button`,
966
+ `action:bar`, `action:menu`, `DeclaredActionsBar`, and
967
+ `ActionEngine.getActionsForLocation`) got a **throw**, which each site turns
968
+ into "hidden": an action gated with a template predicate was invisible even
969
+ while its gate held, and the fail-closed warning blamed the author's
970
+ expression.
971
+
972
+ A string that already carries `${` is now returned untouched (the same guard
973
+ covers the envelope branch, where an unwrapped `source` reaches the identical
974
+ wrap), which makes the normalizer idempotent. Every affected surface goes from a
975
+ constant verdict to the predicate's real one — converging the action face with
976
+ `SchemaRenderer` and `page:header`, which read the raw value and have always
977
+ been right about this spelling (objectui#3314's shape). Bare expressions and
978
+ `{ dialect: 'cel' }` envelopes are untouched.
979
+
980
+ `ActionRunner`'s two execution gates already read the raw value and are
981
+ unchanged; the objectui#3871 tripwires they carried have been replaced by pins
982
+ of the now-converged behaviour.
983
+
984
+ Whether `${…}` should be _authorable_ on an action predicate at all is a
985
+ separate, spec-side question (`@objectstack/spec`'s `PredicateInput` models a
986
+ bare string and a dialect envelope): if it is to be rejected, that belongs in
987
+ publish-time validation, not in a consumer that silently invents a verdict.
988
+
989
+ - 0109f54: Blank predicates and non-predicate values are no longer gates, at the last three entries that still judged them (objectui#3955, objectui#3957, objectui#3960)
990
+
991
+ objectui#3850 sank "is a predicate gate DECLARED here?" into one definition,
992
+ `@object-ui/core`'s `hasDeclaredPredicate`. Three places were left out of that
993
+ ruling's placement clause, each with the same shape of defect: the evaluator's
994
+ single default for "there is nothing here to evaluate" is `true`, meaning
995
+ _visible/enabled_, and wherever a too-wide "declared" test hands it an empty
996
+ predicate on an INVERTED key, that `true` turns a control off for a value the
997
+ metadata never used to say anything.
998
+
999
+ **`SchemaRenderer`'s `hidden` / `hiddenOn` legs (objectui#3955)** asked
1000
+ `!== undefined` and did NOT negate the verdict, so an empty predicate meant HIDE
1001
+ and the node disappeared — on the generic rendering path, since that block runs
1002
+ for every schema type. Harder to diagnose than the `disabled` twin objectui#3862
1003
+ fixed: a greyed-out control is still on screen, while a node that never rendered
1004
+ is indistinguishable from metadata that meant to hide it. Both legs now read the
1005
+ shared definition.
1006
+
1007
+ **The "blank" criterion now covers the envelope spelling (objectui#3960).** The
1008
+ definition trimmed a whitespace-only STRING and not an envelope's whitespace-only
1009
+ `source`, because `toPredicateInput` folds a `source` of `''` and does not trim.
1010
+ So `{ dialect: 'cel', source: ' ' }` was a declared gate whose verdict came from
1011
+ core's own CEL entry calling that exact value "no predicate" (`if (!source.trim())
1012
+ return true`) — `disabled` greyed out forever and `ActionRunner.execute` answered
1013
+ `{ success: false, error: 'Action is disabled' }` with the handler never invoked.
1014
+ Blankness is now decided once for both spellings, at the definition. The
1015
+ NORMALIZER's contract is deliberately unchanged: "what shape does the evaluator
1016
+ accept" is not the same question as "is there a condition", and moving the trim
1017
+ there would have flipped verdicts for every
1018
+ `useCondition(toPredicateInput(…))` call site, including container-level `visible`
1019
+ reads that never asked this question at all.
1020
+
1021
+ **`ActionEngine.getActionsForLocation`'s `visible` filter (objectui#3957)** was the
1022
+ last consumer answering the question with a range of its own — three empty
1023
+ spellings folded by hand, everything else coerced with `Boolean(raw)`. It now reads
1024
+ the shared definition and the coercion branch is gone, so one value no longer gets
1025
+ two answers depending on whether an action was surfaced by the engine or rendered
1026
+ standalone (the invariant objectui#3314 established). Its fail-CLOSED posture on a
1027
+ predicate that THROWS is untouched (`throwOnError: true` + `warnHiddenPredicate`):
1028
+ "the predicate faulted" and "there is no predicate" are different facts.
1029
+
1030
+ Behaviour changes, before → after. Observation-class: each needs an author to write
1031
+ an empty/blank predicate or a non-predicate value, and there is no known user path
1032
+ today.
1033
+
1034
+ | value | `ActionEngine` `visible` | `SchemaRenderer` `hidden` | `disabled` (action face + generic path) | `ActionRunner.execute` `disabled` |
1035
+ | ----------------------------------------------------------- | ------------------------ | ------------------------- | --------------------------------------- | --------------------------------- |
1036
+ | `''` / `null` | shown → shown | HIDDEN → rendered | unchanged | unchanged |
1037
+ | `' '` (blank text) | HIDDEN → shown | HIDDEN → rendered | unchanged | unchanged |
1038
+ | `0` / `NaN` | HIDDEN → shown | HIDDEN → rendered | unchanged | unchanged |
1039
+ | `{}` / `[]` | shown → shown | HIDDEN → rendered | unchanged | unchanged |
1040
+ | `{ dialect: 'cel', source: '' }` | shown → shown | HIDDEN → rendered | unchanged | unchanged |
1041
+ | `{ dialect: 'cel', source: ' ' }` | shown → shown | HIDDEN → rendered | GREY → on | refused → runs |
1042
+ | `{ source: ' ' }` (no dialect) | HIDDEN → shown | HIDDEN → rendered | GREY → on | refused → runs |
1043
+ | `true` / `false` / bare CEL / `${…}` / a non-blank envelope | unchanged | unchanged | unchanged | unchanged |
1044
+
1045
+ Every row moves toward "there is no gate here", never away from it, and no value
1046
+ that HAS a verdict changes it — a declared `false` is still a verdict, not a
1047
+ missing gate (objectui#3812), and blankness is `trim()`, not "short": `{ dialect:
1048
+ 'cel', source: ' x ' }` is a predicate. One alias precedence changes with the
1049
+ `hidden` legs and is pinned rather than claimed as an equivalence: an undeclared
1050
+ `hidden` no longer short-circuits the chain, so a declared `hiddenOn` is finally
1051
+ consulted.
1052
+
1053
+ `SchemaRenderer`'s four `visible*` legs keep `!== undefined` deliberately, as
1054
+ objectui#3850 ruled: their `true` is negated, so an empty predicate already lands
1055
+ on "shown", and narrowing them would change alias precedence rather than fix
1056
+ anything.
1057
+
1058
+ - 7e5bb5d: fix(actions): forward `bodyExtra` end-to-end through the action chain
1059
+
1060
+ An action's static request body (`bodyExtra`) was dropped one hop before the
1061
+ `ActionRunner`: every action renderer forwards an explicit whitelist of keys, and
1062
+ none of them listed `bodyExtra`. Since `@objectstack/spec` 17 made it the only way
1063
+ a `type: 'api'` action can carry a payload (`params` keeps its single meaning as
1064
+ the parameter definition array), and the ADR-0087
1065
+ `inline-action-api-params-to-body-extra` conversion rewrites older object-form
1066
+ `params` pages onto it at load, a previously-working published page validated,
1067
+ published and then POSTed an empty body.
1068
+
1069
+ `element:button`, `action:button`, `action:group`, `action:icon` and `action:menu`
1070
+ now forward the key; `ActionRunner.executeAPI` merges it into the request body
1071
+ **last** (so a constant always overrides a same-named user param, matching the
1072
+ console `apiHandler`); `ActionSchema` declares it; and a non-array `params` on a
1073
+ `type: 'api'` action keeps working for one version window with a dev-mode
1074
+ deprecation warning naming `bodyExtra`.
1075
+
1076
+ - fbc23e0: Action params that inherit a field's options now keep the keys that field declared
1077
+
1078
+ A field-backed action param (`{ field: 'tier' }`) had its inherited option list
1079
+ rebuilt entry by entry as `{ label, value }`, which silently dropped every other
1080
+ key the field's options declared — most consequentially the per-option
1081
+ `visibleWhen` predicate (ADR-0058). A select field whose options narrow by
1082
+ predicate in an object form therefore offered the FULL list in an action dialog,
1083
+ including the entries the predicate exists to hide, with no diagnostic on either
1084
+ side; `color` / `icon` / `disabled` were lost the same way. Options authored
1085
+ inline on the param were never affected — they always passed through verbatim,
1086
+ which is the asymmetry this restores.
1087
+
1088
+ The resolver now preserves each inherited entry and only does its two real jobs:
1089
+ expanding bare strings into label/value pairs and translating the label through
1090
+ `fieldOptionLabel`. The option widgets already filter on `visibleWhen`, so a
1091
+ role-gated option (`'admin' in current_user.positions`) inherited by a dialog
1092
+ param now narrows the offered set and clears a seeded value the predicate hides.
1093
+
1094
+ `ActionParamDef.options` (`@object-ui/core`) and the resolver's `RawActionParam`
1095
+ are widened to match: `ActionParamOption` names the two keys the param layer
1096
+ reads and carries the rest of a field's option vocabulary through.
1097
+
1098
+ - 6bb454a: `evalRowPredicate`: the fail-closed report now names the engine's failure reason, and the ROW always wins over host scope (objectui#3792, objectui#3796)
1099
+
1100
+ Two defects in one function, `packages/core/src/evaluator/listConditional.ts`,
1101
+ shared by every surface that gates on a row predicate: the row kebab, the bulk
1102
+ selection bar, kanban conditional formatting, and — since objectui#3521 — the
1103
+ record page header.
1104
+
1105
+ **The safest path said the least (objectui#3792).** `evalRowPredicate` has two
1106
+ diagnostic routes, and their information content was inverted. The single-eval
1107
+ fast route lets the canonical helper warn, so it prints the engine's own reason
1108
+ (`Reason: [runtime] No such key: owner_id`). The `warnOnError: true` route — the
1109
+ fail-CLOSED one, taken by every caller that makes a button disappear — runs
1110
+ `evalFieldPredicate` twice with `{ warn: false }` to tell a fault from a genuine
1111
+ `false`, and that silence discarded the reason along with the duplicate warning.
1112
+ So the more decisively a surface hid a control, the less it said about why: the
1113
+ console line named the predicate but not the defect, and `No such key: owner_id`
1114
+ or `no such overload: string == null` is usually the whole answer.
1115
+
1116
+ `FieldPredicateDiagnostic` gains an optional `onFault(reason)` passback:
1117
+ `evalFieldPredicate` hands out the engine's verbatim text (kind tag, message and
1118
+ source excerpt — the exact string it prints after `Reason:`) even when its own
1119
+ warning is suppressed. It fires per fault, deliberately independent of the
1120
+ once-per-predicate warning dedupe, so a caller doing its own warn-once
1121
+ bookkeeping keeps control of it; the verdict is unaffected, and callers that pass
1122
+ nothing are unchanged. `evalRowPredicate` threads that reason into its labelled
1123
+ warning, and the legacy-dialect route does the same with the message its engine
1124
+ throws (`Reason: [legacy] …`), so both dialect paths report a fault the same way.
1125
+ Warn-once semantics are unchanged — the reason is deliberately not part of the
1126
+ dedupe key.
1127
+
1128
+ **Wording: not a list function, and not for a long time.** The same message
1129
+ called every caller a "**list** conditional predicate". A hidden button on a
1130
+ record page header reporting "a list conditional predicate" sends its author to
1131
+ the list view to look for a control that was never there. Both messages this
1132
+ module emits (evaluation failure and the legacy-dialect deprecation) now read
1133
+ "conditional predicate".
1134
+
1135
+ **The row is the subject — on both dialect paths (objectui#3796).** The scope
1136
+ merge pinned `data` after the host-scope spread but not `record`, leaving
1137
+ `record` to each engine's own binding — and the two engines disagreed. The legacy
1138
+ evaluator re-pinned `record` to the row (row won); the CEL engine takes its
1139
+ `extra` bag over its `record` binding, so a host scope carrying a `record` key
1140
+ won there instead. One function, one predicate text, two subjects — selected by
1141
+ whether the string happens to contain `===` or `${…}`, the dialect-routing
1142
+ markers, which no author picks deliberately. `record` is now pinned after the
1143
+ spread exactly as `data` is, which fixes both paths at the merge site rather than
1144
+ relying on either engine's precedence.
1145
+
1146
+ No host injects a `record` key today — `ExpressionProvider` binds
1147
+ `current_user` / `user` / `ctx` / `os` / `app` / `data` / `features` — so nothing
1148
+ observable changes for existing apps; this closes the edge before a host adds one
1149
+ (`ctx.record` already exists, which is exactly how it would arrive). A row FIELD
1150
+ named `record` is likewise no longer able to become the row root; it stays
1151
+ addressable as `data.record`.
1152
+
1153
+ Shipped as a patch: no new exported symbol, one optional field added to an
1154
+ already-exported options interface, and no existing call signature changed.
1155
+
1156
+ - 523be48: `object-timeline` and `record:line_items` now apply the filter / sort / row cap they are given, so a named `dataSource.view` narrows them instead of contributing nothing
1157
+
1158
+ These were the two residual gaps in objectstack#7121's per-block coverage table
1159
+ (objectstack#7137). Both blocks are object-bound lists, both accepted the spec's
1160
+ per-element `dataSource` binding, and neither had a read site for `filter` or
1161
+ `sort` anywhere in its fetch:
1162
+
1163
+ - `object-timeline`'s entire query was
1164
+ `find(objectName, { options: { $top: 100 } })`.
1165
+ - `record:line_items`' was the parent FK plus a fixed `$top: 500`.
1166
+
1167
+ So `dataSource: { object, view: 'hot' }` resolved the view — a typo still reported
1168
+ a configuration error, it never degraded into an unfiltered query — and then
1169
+ dropped everything the view said. The rendered rows could be **wider than the view
1170
+ they named**, silently, which is exactly the class of mistake AI-authored metadata
1171
+ hides best: the page looks like it works. objectstack#7121 deliberately left the
1172
+ keys unmapped and recorded the gap rather than writing composed values onto schema
1173
+ keys nobody read; this closes it at the fetch instead.
1174
+
1175
+ What each block now reads:
1176
+
1177
+ - **`object-timeline`** — `$filter: schema.filter`,
1178
+ `$orderby: convertSortToQueryParams(schema.sort)`, and
1179
+ `$top: schema.limit ?? 100`, matching the form `object-gantt` / `object-map` /
1180
+ `object-calendar` already use. Its registry mapping gains
1181
+ `filter` / `sort` / `limit`; `columns` stays unmapped, because a timeline
1182
+ projects the fields its `timeline` config names.
1183
+ - **`record:line_items`** — the composed filter is **AND-combined** with the parent
1184
+ relationship condition through `mergeFilterNodes`, never substituted for it, the
1185
+ same way `record:related_list` composes its own since objectstack#7118: a
1186
+ line-items panel is always scoped to the record it sits on, so an _additional_
1187
+ criterion can only narrow this parent's children and can never surface another
1188
+ parent's rows. `sort` becomes the load order and `limit` the row cap (default
1189
+ 500). `columns` stays unmapped — here they are `GridColumn[]` driving an editable
1190
+ grid, not a field-name projection, so a view's column list would be the wrong
1191
+ _shape_ rather than merely a wider answer.
1192
+
1193
+ **Behaviour change worth knowing about:** the timeline's default window is now a
1194
+ real cap. `{ options: { $top: 100 } }` nested the limit under a key that is not a
1195
+ `QueryParams` field and that no adapter in this repo reads (`convertQueryParams`
1196
+ maps `params.$top`), so the intended window never reached the wire and a timeline
1197
+ over a large object fetched whatever the server chose to return. It is now sent as
1198
+ `$top`, and authorable via `limit` or a view's `pagination.pageSize`.
1199
+
1200
+ `@object-ui/core` gains `convertSortToQueryParams`, the sort→`$orderby` lowering
1201
+ the three sibling blocks each inline privately. It is shared rather than copied
1202
+ twice more, and is slightly more faithful to the declared contract than those
1203
+ copies: a sort entry that omits `order` means ascending instead of being dropped
1204
+ (the string spelling `"amount"` already meant ascending in the same copies), and
1205
+ nothing orderable yields `undefined` rather than a truthy empty `{}`. Migrating
1206
+ the three existing copies onto it is objectstack#7148 and is not done here.
1207
+
1208
+ - Updated dependencies [d229dfa]
1209
+ - Updated dependencies [c2fd122]
1210
+ - Updated dependencies [48132f7]
1211
+ - Updated dependencies [7e5bb5d]
1212
+ - Updated dependencies [e6fdbdc]
1213
+ - Updated dependencies [7e2b7e9]
1214
+ - Updated dependencies [c1e1e6b]
1215
+ - @object-ui/types@17.4.0
1216
+
1217
+ ## 17.3.0
1218
+
1219
+ ### Minor Changes
1220
+
1221
+ - 5781fb1: `@object-ui/core` now ships the server-action dispatcher factory —
1222
+ `createServerActionHandler({ fetch, baseUrl, resolveObject, ... })` — so any
1223
+ consumer of the runner (standalone renderers, SDUI hosts, embedded usage) can
1224
+ run `action.body` script actions by registering the produced handler, instead
1225
+ of dead-ending on the built-in `executeScript`'s "must be executed server-side"
1226
+ error with no supported way to make it run (objectui#2904, the follow-up
1227
+ objectui#2896 deferred).
1228
+
1229
+ The factory is deliberately opinion-free about the three things core has no
1230
+ business deciding — auth (`fetch` is an injected authenticated wrapper), origin
1231
+ (`baseUrl` string or thunk; no bundler env convention), and fallback object
1232
+ scope (`resolveObject`) — and owns everything protocol-shaped, once:
1233
+
1234
+ - name-based action identity (ADR-0110 D1 — `target` is a binding expression,
1235
+ never an identity);
1236
+ - the record-id resolution dance, also exported as
1237
+ `resolveServerActionRecordId` (`_rowRecord`, `recordIdField`, toolbar
1238
+ selection fallback with its single/zero-select guards, aggregate
1239
+ `_selectedIds` bypass), replaceable wholesale via `resolveRecordId` for
1240
+ hosts with their own policy (record pages);
1241
+ - a re-entrancy guard per action+record;
1242
+ - the `/actions` response-envelope rule: `interpretActionResponse`,
1243
+ `readActionPayload` and `actionErrorDetail` moved from `@object-ui/app-shell`
1244
+ internals into core and are now public exports.
1245
+
1246
+ `@object-ui/app-shell`'s two hand-rolled copies of this POST —
1247
+ `useConsoleActionRuntime.serverActionHandler` and `RecordDetailView`'s — are
1248
+ collapsed into one console wrapper (`createConsoleServerActionHandler`) that
1249
+ layers the browser-only choreography (popup pre-open dance, zero-roundtrip
1250
+ `newTabUrl` fast path, `redirectUrl` convention) over the core factory. The
1251
+ copies had already drifted twice (objectstack#3913 — envelope; framework#3935 —
1252
+ identity, fixed in one copy only): RecordDetailView now also dispatches by
1253
+ declarative `name` instead of `target || name`, and no longer leaks the
1254
+ client-side `_rowRecord` stash to the server.
1255
+
1256
+ - f833d3a: Retire `validation` from the action-param contract — it was declared on both
1257
+ halves, read by neither, and rejected outright by the server (objectui#3201).
1258
+
1259
+ FROM: `validation?: string` was declared on the AUTHORING type
1260
+ (`@object-ui/types`' `ActionParam`) and on the RESOLVED type (`@object-ui/core`'s
1261
+ `ActionParamDef`). TO: it is declared on neither.
1262
+
1263
+ **Breaking for anyone who declared it — but it never did anything.** This is
1264
+ marked `minor`, not `major`, per the repo's version-alignment policy (objectui's
1265
+ major tracks `@objectstack`'s, so objectui's own breaking changes ship as `minor`
1266
+ with the breaking semantics spelled out here).
1267
+
1268
+ **Migration: delete it.** If you authored `validation: '...'` on an action param,
1269
+ it never took effect, and publishing that metadata to the server is a hard parse
1270
+ failure — so any metadata that reached production either never carried the key or
1271
+ never parsed. Removing it changes no runtime behaviour; it only moves the error
1272
+ from "silent no-op, then rejected at publish" to a `tsc` error at the keystroke.
1273
+
1274
+ Why it could not work as authored:
1275
+
1276
+ - `ActionParamSchema` in `@objectstack/spec/ui` is `.strict()` and does not list
1277
+ `validation`, so an authored key is a PARSE REJECTION on the server:
1278
+ `Unrecognized key(s) on this action param: \`validation\``. Meanwhile `tsc`
1279
+ against the public type accepted it — the type vouched for a key the platform
1280
+ itself refuses.
1281
+ - Nothing read it on the resolved side either: it was never a key of
1282
+ `resolveActionParams()`'s `RawActionParam`, the runtime field metadata a
1283
+ field-backed param inherits from carries no `validation` to source one from,
1284
+ and `paramToField()` never mapped it — so it could not reach the field widgets,
1285
+ whose rules `buildValidationRules()` builds from `required` / `minLength` /
1286
+ `maxLength` / `pattern`.
1287
+
1288
+ Removed rather than implemented, on ADR-0049 enforce-or-remove. Giving it meaning
1289
+ would mean first deciding what an "expression" is here (CEL? a formula? a regex?)
1290
+ and adding it to `@objectstack/spec`, which is where such a capability has to
1291
+ start — not accreted renderer-side around a key the contract does not have.
1292
+
1293
+ This also retires the last named exception in objectui#3174's drift guard
1294
+ (`packages/types/src/__tests__/page-nav-misc-spec-parity.test.ts`), which carried
1295
+ `validation` as the one key `ActionParam` added on top of the spec's set. The
1296
+ rule it pins — **the authoring type declares exactly the spec's authorable
1297
+ keys** — is now literal: the guard asserts the local-only key set is empty, so
1298
+ any future addition fails the build instead of being waved through.
1299
+
1300
+ - d22ae31: Track `@objectstack/spec` 17.0.0-rc.2 (objectui#3235, #3208, #3287, #3264).
1301
+
1302
+ The pin moves from `^17.0.0-rc.1` to `^17.0.0-rc.2` across the workspace, and
1303
+ the sibling `@objectstack/*` packages (`client` / `core` / `formula` / `lint`)
1304
+ move with it — they pin `@objectstack/spec` **exactly**, so leaving them behind
1305
+ kept a second copy of the spec in the tree and would have had `@objectstack/lint`
1306
+ validating against rc.1 schemas that still accept keys rc.2 retires.
1307
+
1308
+ Breaking semantics, in FROM → TO form:
1309
+
1310
+ - **`app.homePageId` is retired — an app's landing page is now its first
1311
+ navigation item.** An app that pinned a landing page with `homePageId` will
1312
+ open on the first reachable navigation entry (by `order`) instead; the root
1313
+ landing still follows `isDefault`. To restore a specific landing page, reorder
1314
+ `navigation` so the intended entry comes first. Stored metadata is migrated by
1315
+ `os migrate meta --from 16`. The key is a hard error now, not a stripped one:
1316
+ the spec ships a tombstone that names the migration.
1317
+ Upstream retired it because of its SHAPE, not its usage — it was an ID
1318
+ cross-reference with no referential integrity, so a `homePageId` that pointed
1319
+ at nothing silently fell back to the first navigation item anyway
1320
+ (objectstack#4667, premise corrected in #4709). If the capability returns, it
1321
+ returns as a flag on the navigation item itself, which cannot dangle.
1322
+ - **`@object-ui/types`' `HttpMethod` now resolves to the spec's
1323
+ `HttpMethodType`.** Shape is verbatim identical — the same 5-value UI subset —
1324
+ and `@object-ui/types` still exports it as `HttpMethod`, so no consumer
1325
+ changes. The spec renamed its `./ui` export because `HttpMethod` named two
1326
+ different types depending on the import path (`./shared` / `./api` carry a
1327
+ 7-value enum including `HEAD` / `OPTIONS`); objectui deliberately keeps the
1328
+ 5-value one (objectstack#4691).
1329
+ - **`AppContextSelector.includeAll` / `placement` are gone.** Neither ever did
1330
+ anything in this renderer: context selectors are mandatory-scope, so no "All"
1331
+ row was ever rendered, and `placement: 'topbar'` put nothing in the topbar.
1332
+ Both carried schema defaults, which is why the liveness lint structurally
1333
+ could not flag them — removal was the only channel that reaches an author
1334
+ (framework#4509).
1335
+ - **`NavigationArea.visible` / `order` / `requiredPermissions` are gone.** An
1336
+ area is a layout grouping, not an access boundary. Gating moved down to the
1337
+ navigation ITEM, where `visible` and `requiredPermissions` are unchanged and
1338
+ still enforced. `AppSchemaRenderer`'s area switcher no longer hides an area, so
1339
+ an area whose items are all gated away renders as visible-but-empty rather
1340
+ than disappearing.
1341
+ - **`@object-ui/core` no longer exports `NotificationProtocol`**
1342
+ (`resolveNotificationConfig`, `specNotificationToToast`, `mapSeverityToVariant`,
1343
+ `mapPosition`, `ToastNotification`). It bridged `@objectstack/spec/ui`'s
1344
+ `Notification` / `NotificationConfig`, which objectstack#4610 deleted with no
1345
+ successor. Use `resolveNotificationConfig` from `@object-ui/react`
1346
+ (`NotificationContext`), which owns the live `NotificationSystemConfig` and is
1347
+ what every notification surface already read. Note that the spec's _other_
1348
+ `Notification` — `@objectstack/spec/api` — is the REST inbox row, a different
1349
+ contract, and is deliberately NOT aliased in as a replacement.
1350
+ - **The `email_template` client-side validator now uses
1351
+ `EmailTemplateDefinitionSchema`.** It was pointing at the removed
1352
+ `EmailTemplateSchema`, so authored templates were being checked against the
1353
+ wrong contract: the live one is keyed `name` + `locale` (not `id`) and splits
1354
+ the body into `bodyHtml` / `bodyText` (not `body` + `bodyType`)
1355
+ (objectstack#4616 / #4807).
1356
+
1357
+ Fixes that are not breaking, but were only found because rc.2 stopped being
1358
+ lenient — each had been passing vacuously:
1359
+
1360
+ - **`view` drafts are actually validated now.** The client validator named the
1361
+ aggregated container schema while this admin authors first-class `ViewItem`s,
1362
+ and the container used to strip `viewKind` / `config` in silence — so no view
1363
+ draft ever had one of its own keys checked. It now validates each shape
1364
+ against its own schema (objectui#3312).
1365
+ - **The console's worked examples were wrong**, and being stripped rather than
1366
+ refused: `view.list.object` (the container root already declares it),
1367
+ `job.concurrency` / `job.timeoutMs` (no such keys; the spelling is `timeout`,
1368
+ already in ms), `email_template.from` / `.to` (a template is not a send —
1369
+ the sender override is `fromOverride`, an object), and
1370
+ `datasource.capabilities` / `.healthCheck` (objectstack#4583 removed the
1371
+ former; the latter was never a datasource key). These are the drafts an
1372
+ author — or a model generating metadata — copies.
1373
+ - Action key inventory re-derived: `ActionSchema` gained the package-lock
1374
+ envelope (`_lock*` / `_package*` / `_provenance`), so a packaged action no
1375
+ longer reports them as unknown keys.
1376
+ - The schema-diff panel labels the new `default_mismatch` finding.
1377
+ - Test fixtures pinning the retired `managedBy: 'system'` bucket now use
1378
+ `engine-owned`. Protocol 17 split that value (objectstack#3355), so it
1379
+ resolved to the default-writable fallback and a batch of "stays locked"
1380
+ assertions had quietly stopped asserting anything.
1381
+
1382
+ ### Patch Changes
1383
+
1384
+ - 18cd432: `ActionEngine.getActionsForLocation` now evaluates a `{ dialect: 'cel', source }` action `visible` predicate on the canonical `@objectstack/formula` engine instead of the legacy JS evaluator. The method used to unwrap the envelope into a `${source}` string before calling `evaluateCondition`, which only routes to the CEL engine while its argument is still an envelope — so the engine silently demoted every CEL predicate to the legacy path while every action renderer (`action-button`, `action-menu`, `action-bar`, … via `toPredicateInput`) ran the same predicate on CEL (#2661). Because `ExpressionInputSchema` normalizes even a bare authored string into a CEL envelope, that was the common case, and the two engines disagree: `null < null` faults in CEL (fail-closed hide) but is `false` in JS, and CEL-only builtins such as `today()` do not exist on the JS path at all — so one `visible:` predicate could hide a button in the renderer while the engine kept it (or the reverse). Normalization now goes through a shared `toPredicateInput` helper exported from `@object-ui/core`, pinned to the renderer-side twin by a parity suite. Bare strings, non-`cel` dialects, empty `source`, and boolean predicates all behave exactly as before (#3314).
1385
+ - d915c47: Relation fields (`lookup` / `master_detail` / `user` / `tree`) are now usable in action and conditional-formatting predicates: they bind as the stored foreign key on every surface, and the fields a predicate reads are included in the query projection (#3501).
1386
+
1387
+ Before this, one predicate over one relation field had four different fates, decided by things its author does not control. `$expand` **replaces** the id in place with the whole related record, and a view expands exactly the relations it shows as COLUMNS — so `record.owner == "U1"` was **true** where the column was absent, **false** where it was displayed, and a **fault** where the field was neither displayed nor projected (a list's `$select` was built from its columns alone, and CEL treats an absent key as a fault, not as null). A fault is fail-CLOSED on the row kebab and the selection bar and fail-OPEN on the lenient paths, so the same authoring mistake hid the button from everyone on one surface and showed it to everyone on the next, with nothing on screen to point at either. The server, meanwhile, only ever sees the id — so client and server could not agree, which is the one thing ADR-0036 / ADR-0058 exist to guarantee.
1388
+
1389
+ Two changes close it. `toPredicateRecord` (new, `@object-ui/core`) collapses expanded relation values back to their ids when a record is bound for evaluation — driven by the object's own field types, not by sniffing for an `id` key, so a `json` field that happens to carry one is untouched. It is threaded through `evalRowPredicate` / `resolveConditionalFormatting` (via a new `fields` option), `useRowPredicate`, `partitionBulkRows`, and both `page:header` evaluators, with the object schema supplied by `ObjectGrid` / `ListView` / `ObjectKanban` / the record context. Kanban card formatting is threaded the same way, so a rule cannot match on the grid view of a list and silently never match on its board. Display is unaffected — a detail-page title still renders the related record's name, and the schema-only `kanban-ui` entry point (which has no object schema to offer) keeps using the payload verbatim. `collectPredicateFieldRefs` / `listViewPredicates` (new) harvest the `record.x` / `data.x` references out of a view's conditional formatting, row-action defs, bulk-action defs, promoted object actions and `userActions` overrides, and add them to `$select` — intersected with the object's declared fields plus the platform columns every object carries (`isProjectableField`), because an unknown key is not ignored by every backend. No `$expand` is added: a predicate wants the foreign key, which is what an unexpanded relation already is.
1390
+
1391
+ - 509104a: Fix matrix report cells showing another bucket's numbers when dimension values run together.
1392
+
1393
+ The cross-tab in `DatasetReportRenderer` built its bucket ids by joining dimension values with the EMPTY string, so adjacent values had no boundary at all: `"x"` + `"yz"` and `"xy"` + `"z"` were the same bucket on both axes, and the later row silently overwrote the earlier one. Its cell key then joined the two bucket ids with a plain space, while dimension values contain spaces constantly ("New York", "In Progress"), so `"New"` × `"York Q1"` and `"New York"` × `"Q1"` also met in one key. A merged bucket showed a different row's measure, the overwritten row's value was unreachable, the per-row and per-column subtotals matched the wrong header, and drill-through followed the same wrong index into another record's list — none of it with an error.
1394
+
1395
+ Bucket ids and cell keys are now encoded with `JSON.stringify`, which carries the boundary in its own quoting rather than in a character the data is assumed never to contain. All four lookups in the renderer (row headers, column headers, row subtotals, column subtotals) share the one encoder, so they agree by construction.
1396
+
1397
+ The encoders moved to `@object-ui/core` as `pivotBucketId` / `pivotCellKey` and are now shared with the dashboard `DatasetWidget`, which carried the same defect and fixed it separately: two packages each hand-rolling the same key is why one fix left the other broken. The dashboard keeps its existing exports and behaviour.
1398
+
1399
+ - a4cff5b: Conditional-rule predicates that fail to evaluate are no longer silent
1400
+ (objectstack#5149, appeal 2). `evalFieldPredicate` — the canonical funnel for
1401
+ `visibleWhen` / `readonlyWhen` / `requiredWhen`, view-level `visibleOn`, legacy
1402
+ `condition`, per-option `visibleWhen`, screen-field predicates and list
1403
+ conditional formatting — now logs **one `console.warn` per predicate text**
1404
+ when evaluation fails (parse error, unbound identifier, engine fault), carrying
1405
+ the predicate source, the engine's failure reason, and the field/rule locator
1406
+ the call site provides. Renderer call sites thread that locator
1407
+ (`visibleWhen of field 'amount'`), so a broken predicate identifies itself in
1408
+ the browser console instead of being indistinguishable from an absent one.
1409
+
1410
+ Verdicts are unchanged: evaluation still fails open to the caller's safe
1411
+ default (flipping that default is objectstack#5149 appeal 1, tracked
1412
+ separately). Fault-probing callers (`evalRowPredicate`'s fail-closed path,
1413
+ `ExpressionEvaluator`'s `throwOnError`) opt out via the new
1414
+ `diagnostic.warn: false` and keep their own single diagnostic, so no broken
1415
+ predicate ever warns twice.
1416
+
1417
+ - 2a9513d: `toFilterNode` now lowers a spec `ViewFilterRule[]` into ObjectQL AST nodes instead of returning the array verbatim, so a saved view's stored filter reaches `$filter` as something the server accepts. It previously did not: `ListViewSchema.filter` / `ViewTab.filter` are declared `z.array(ViewFilterRuleSchema)`, and the whole read path — `ObjectView` → `ListViewSchema.filter` → `buildEffectiveFilter` → `mergeFilterNodes` → `toFilterNode` — carried those rule objects untouched into the query. `isFilterAST` is `false` for an array of objects, so the data API answered `400 INVALID_FILTER` and the list rendered no rows at all. Measured against a real backend on the showcase's shipped `showcase_task.in_progress` view: `$filter=[{"field":"status","operator":"equals","value":"in_progress"}]` returned `400`, while the lowered `[["status","equals","in_progress"]]` returned its 2 rows. Every saved view carrying a filter was affected, on both producers that share this sink — `plugin-list`'s `buildEffectiveFilter` (the grid and its export) and `plugin-view`'s `ObjectView` (calendar / kanban / gallery / timeline).
1418
+
1419
+ Operators are canonicalised through the spec's own `normalizeFilterOperator`, the same exit the write side (`viewFilterFold`) uses, so the two directions cannot drift into two dialects; no second operator table is introduced. An operator the spec does not know is passed through verbatim so the server still refuses it loudly rather than having a misspelling coerced into a valid filter. AST nodes and MongoDB-style object filters are unaffected, mixed arrays (a view's rules concatenated with `?filter[<field>]=<value>` URL triples) fold element-wise with the triples untouched, and a rule with a blank `field` is deliberately left unlowered — `["", op, value]` passes `isFilterAST` and returns an empty list, whereas the unlowered rule keeps the loud `400`.
1420
+
1421
+ - Updated dependencies [d915c47]
1422
+ - Updated dependencies [9e9e9a9]
1423
+ - Updated dependencies [23018cc]
1424
+ - Updated dependencies [f44d872]
1425
+ - Updated dependencies [f833d3a]
1426
+ - Updated dependencies [d22ae31]
1427
+ - @object-ui/types@17.3.0
1428
+
3
1429
  ## 17.2.0
4
1430
 
5
1431
  ### Minor Changes
@@ -2429,6 +3855,7 @@ pi-TgoJ4_DM55Fqz` — untranslated, and leaking the object's machine name and th
2429
3855
  project `health`) painted its categories from the generic `--chart-1..5`
2430
3856
  palette — the same gap the chart view (`object-chart`) had before #1932. It now
2431
3857
  resolves the dimension field's option colors (using the dataset's base `object`
3858
+
2432
3859
  - dimension→field map the query already returns) and threads them to the
2433
3860
  renderer as a per-category `categoryColors` map, so health green/red/yellow
2434
3861
  paints semantically.
@@ -2671,6 +4098,7 @@ pi-TgoJ4_DM55Fqz` — untranslated, and leaking the object's machine name and th
2671
4098
  - 991b62d: Add `compareTo` field to dashboard widgets for period-over-period
2672
4099
  comparison. Supports `'previousPeriod'`, `'previousYear'`, and
2673
4100
  `{ offset: '7d' | '4w' | '1M' | '1y' }`.
4101
+
2674
4102
  - **Metric / gauge widgets** now compute a delta percentage when `compareTo`
2675
4103
  is set and surface it as a derived `trend` (auto-labelled via
2676
4104
  `dashboard.trend.vsLast*` i18n keys sniffed from the filter macros).
@@ -2773,6 +4201,7 @@ pi-TgoJ4_DM55Fqz` — untranslated, and leaking the object's machine name and th
2773
4201
 
2774
4202
  Extract the bulk-vs-per-row decision into a reusable
2775
4203
  `executeBulkBatch(input, ops)` helper in `@object-ui/core`:
4204
+
2776
4205
  - Single decision tree shared by both update and delete fast paths.
2777
4206
  - Bulk success → no per-row pass.
2778
4207
  - Bulk partial-count → aggregate batch error.
@@ -2817,6 +4246,7 @@ pi-TgoJ4_DM55Fqz` — untranslated, and leaking the object's machine name and th
2817
4246
  multi-user editing scenarios.
2818
4247
 
2819
4248
  **`@object-ui/data-objectstack`**
4249
+
2820
4250
  - Exports `ConcurrentUpdateError` (carries `currentVersion` and
2821
4251
  `currentRecord`) and `isConcurrentUpdateError()` type guard.
2822
4252
  - `update()` / `delete()` accept `opts.ifMatch` and forward it via the
@@ -2829,6 +4259,7 @@ pi-TgoJ4_DM55Fqz` — untranslated, and leaking the object's machine name and th
2829
4259
  from conflicts without parsing the wire format.
2830
4260
 
2831
4261
  **`@object-ui/core`**
4262
+
2832
4263
  - `ApiDataSource.update()` and `.delete()` accept `opts.ifMatch` and emit
2833
4264
  the `If-Match` HTTP header.
2834
4265
 
@@ -2964,6 +4395,7 @@ pi-TgoJ4_DM55Fqz` — untranslated, and leaking the object's machine name and th
2964
4395
  detail view (`TypeError: titleFormat.replace is not a function`) and printed
2965
4396
  `Failed to evaluate expression: ${[object Object]}` for every action visibility
2966
4397
  predicate.
4398
+
2967
4399
  - `@object-ui/core`: `ExpressionEvaluator.evaluate` / `evaluateCondition` now
2968
4400
  unwrap Expression envelopes transparently.
2969
4401
  - `@object-ui/react`: new `toPredicateInput()` helper to safely normalize
@@ -2978,6 +4410,7 @@ pi-TgoJ4_DM55Fqz` — untranslated, and leaking the object's machine name and th
2978
4410
  the new `{ source }` envelope.
2979
4411
 
2980
4412
  All changes are backward-compatible — legacy bare strings continue to work.
4413
+
2981
4414
  - @object-ui/types@4.0.7
2982
4415
 
2983
4416
  ## 4.0.6
@@ -3156,6 +4589,7 @@ pi-TgoJ4_DM55Fqz` — untranslated, and leaking the object's machine name and th
3156
4589
  - New plugin-object and ObjectQL SDK updates
3157
4590
 
3158
4591
  **Added:**
4592
+
3159
4593
  - New Plugin: @object-ui/plugin-object - ObjectQL plugin for automatic table and form generation
3160
4594
  - ObjectTable: Auto-generates tables from ObjectQL object schemas
3161
4595
  - ObjectForm: Auto-generates forms from ObjectQL object schemas with create/edit/view modes
@@ -3164,6 +4598,7 @@ pi-TgoJ4_DM55Fqz` — untranslated, and leaking the object's machine name and th
3164
4598
  - ObjectQL Integration: Enhanced ObjectQLDataSource with getObjectSchema() method using MetadataApiClient
3165
4599
 
3166
4600
  **Changed:**
4601
+
3167
4602
  - Updated @objectql/sdk from ^1.8.3 to ^1.9.1
3168
4603
  - Updated @objectql/types from ^1.8.3 to ^1.9.1
3169
4604
 
@@ -3177,6 +4612,7 @@ pi-TgoJ4_DM55Fqz` — untranslated, and leaking the object's machine name and th
3177
4612
  - Patch release: Add automated changeset workflow and CI/CD improvements
3178
4613
 
3179
4614
  This release includes infrastructure improvements:
4615
+
3180
4616
  - Added changeset-based version management
3181
4617
  - Enhanced CI/CD workflows with GitHub Actions
3182
4618
  - Improved documentation for contributing and releasing