@object-ui/core 17.5.0 → 17.6.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 (70) hide show
  1. package/CHANGELOG.md +794 -0
  2. package/README.md +36 -22
  3. package/dist/actions/ActionEngine.d.ts +1 -1
  4. package/dist/actions/ActionEngine.js +1 -2
  5. package/dist/actions/ActionRunner.d.ts +82 -10
  6. package/dist/actions/ActionRunner.js +128 -22
  7. package/dist/actions/actionKeys.d.ts +68 -8
  8. package/dist/actions/actionKeys.js +81 -9
  9. package/dist/actions/actionResponse.js +1 -1
  10. package/dist/actions/index.d.ts +1 -0
  11. package/dist/actions/index.js +1 -0
  12. package/dist/actions/recordIdParam.d.ts +77 -0
  13. package/dist/actions/recordIdParam.js +37 -0
  14. package/dist/actions/serverActionHandler.d.ts +1 -1
  15. package/dist/actions/serverActionHandler.js +1 -1
  16. package/dist/evaluator/ExpressionEvaluator.d.ts +42 -1
  17. package/dist/evaluator/ExpressionEvaluator.js +42 -1
  18. package/dist/evaluator/SafeExpressionParser.js +0 -4
  19. package/dist/evaluator/declaredPredicate.js +1 -1
  20. package/dist/evaluator/fieldRules.d.ts +19 -0
  21. package/dist/evaluator/fieldRules.js +30 -6
  22. package/dist/evaluator/index.d.ts +1 -0
  23. package/dist/evaluator/index.js +1 -0
  24. package/dist/evaluator/listConditional.d.ts +49 -2
  25. package/dist/evaluator/listConditional.js +70 -6
  26. package/dist/evaluator/optionRules.d.ts +55 -0
  27. package/dist/evaluator/optionRules.js +60 -0
  28. package/dist/evaluator/rowPredicateCanon.d.ts +67 -0
  29. package/dist/evaluator/rowPredicateCanon.js +208 -0
  30. package/dist/index.d.ts +4 -0
  31. package/dist/index.js +17 -0
  32. package/dist/registry/Registry.d.ts +41 -19
  33. package/dist/theme/index.d.ts +1 -1
  34. package/dist/theme/index.js +1 -1
  35. package/dist/types/index.d.ts +92 -13
  36. package/dist/utils/chart-presentation.d.ts +205 -0
  37. package/dist/utils/chart-presentation.js +280 -0
  38. package/dist/utils/chart-series.d.ts +140 -26
  39. package/dist/utils/chart-series.js +380 -39
  40. package/dist/utils/column-identity.d.ts +43 -0
  41. package/dist/utils/column-identity.js +47 -1
  42. package/dist/utils/dataset-format.js +18 -6
  43. package/dist/utils/debug.js +7 -0
  44. package/dist/utils/expand-fields.d.ts +97 -7
  45. package/dist/utils/expand-fields.js +97 -7
  46. package/dist/utils/filter-tokens.js +0 -1
  47. package/dist/utils/freeze-schema.d.ts +38 -1
  48. package/dist/utils/freeze-schema.js +10 -0
  49. package/dist/utils/humanize-label.d.ts +60 -0
  50. package/dist/utils/humanize-label.js +62 -0
  51. package/dist/utils/managedBy.d.ts +27 -2
  52. package/dist/utils/normalize-list-view.d.ts +10 -0
  53. package/dist/utils/normalize-list-view.js +63 -1
  54. package/dist/utils/number-display.d.ts +7 -3
  55. package/dist/utils/predicate-fields.d.ts +29 -4
  56. package/dist/utils/predicate-fields.js +44 -4
  57. package/dist/utils/retired-field-types.d.ts +90 -0
  58. package/dist/utils/retired-field-types.js +124 -0
  59. package/dist/utils/sort-query.d.ts +12 -10
  60. package/dist/utils/unmaterialized-fields.d.ts +52 -0
  61. package/dist/utils/unmaterialized-fields.js +57 -0
  62. package/dist/validation/index.d.ts +1 -0
  63. package/dist/validation/index.js +1 -0
  64. package/dist/validation/schema-validator.js +105 -0
  65. package/dist/validation/server-owned-value.d.ts +62 -0
  66. package/dist/validation/server-owned-value.js +99 -0
  67. package/dist/validation/validators/object-validation-engine.js +0 -2
  68. package/package.json +4 -4
  69. package/dist/__benchmarks__/core.bench.d.ts +0 -8
  70. package/dist/__benchmarks__/core.bench.js +0 -53
package/CHANGELOG.md CHANGED
@@ -1,5 +1,799 @@
1
1
  # @object-ui/core
2
2
 
3
+ ## 17.6.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 279fb13: `ComponentInput.type` can declare a UNION, so a block stops warning about legal
8
+ writes its own description recommends
9
+
10
+ A registration's `type` was one coarse control kind, while a good number of spec
11
+ keys accept more than one shape. A declaration therefore had to pick an arm, and
12
+ the repo's own manifest gate then reported `type-mismatch` on the other arm's
13
+ legal values. Four of the five measured cases were the loud shape: the input's
14
+ `description` teaches the author to write an inline translation map
15
+ (`{ en, "zh-CN", … }`) while the same input's `type: 'string'` made
16
+ `sdui-parser`'s `checkType` warn about exactly that map — one platform authority
17
+ contradicting itself on the write it had just recommended. Because these land at
18
+ warning severity the page still compiled and rendered; the cost is that noise on
19
+ correct authoring trains authors, AI authors included, to dismiss the
20
+ `unknown-prop` and `type-mismatch` reports that are real.
21
+
22
+ `type` now accepts an ARRAY of coarse kinds as well as a single one (maintainer
23
+ ruling on objectui#3832, direction (a)), and a value passes the coarse check when
24
+ ANY declared arm accepts it. Both declaration sites in `@object-ui/types` move
25
+ together with the registry's own copy in `@object-ui/core`, and
26
+ `ComponentInputSchema` enforces the same widening — a non-empty array of
27
+ DISTINCT kinds, so an empty arm list or a repeated arm is refused at authoring
28
+ time rather than normalized behind the author's back.
29
+
30
+ Five declarations now spell their real contract, and the `type-mismatch` warning
31
+ on each of these legal writes is gone:
32
+
33
+ - `page:header.title`, `page:header.subtitle`, `page:card.title` —
34
+ string **or** inline translation map (the spec's union, measured against
35
+ `ComponentPropsMap` at the pinned rc.6; the renderers resolve both through
36
+ `pickLocalized`);
37
+ - `record:alert.title`, `record:alert.body` — the same two shapes, justified
38
+ against the RENDERER since the pinned spec carries no `record:alert` props
39
+ schema;
40
+ - `element:text_input.defaultValue` — `string | number`, the spec's union,
41
+ which had been narrowed to `'string'` with the number arm named only in prose.
42
+
43
+ **Backward compatible, and measured as such.** The single-kind form stays valid
44
+ and is still the canonical spelling for a one-arm key: it validates identically
45
+ (the diagnostics for one arm, `invalid-enum` and its `error` severity included,
46
+ are byte-identical), and `manifestFromConfigs` collapses a one-element array back
47
+ to the bare string, so every entry already in a published `sdui.manifest.json`
48
+ serializes unchanged and arrays appear only where a union was really declared.
49
+ The JSX authoring surface follows in the same step — `generateDts` emits a
50
+ TypeScript union for a union input, so the `.d.ts` an author type-checks against
51
+ accepts exactly what the gate accepts.
52
+
53
+ A union widens what is legal; it does not switch the check off. A value matching
54
+ NO declared arm is still reported, a multi-arm mismatch reports at its strictest
55
+ arm's severity (`error` when an `enum` arm is present, so an enum's closed list
56
+ does not become dismissible by having a second arm added next to it), and arms
57
+ are meant to match the contract rather than relax the gate:
58
+ `element:text_input.defaultValue` deliberately gains no `object` arm because the
59
+ spec rejects a map there, and `element:record_picker.emptyText` keeps its single
60
+ `'string'` arm because that renderer drops the map form (objectui#4163) — an arm
61
+ the renderer never honours would advertise a shape that cannot reach the screen.
62
+ - e1d4251: Action param `visible`: one dialect answer, and a fault that is fail-open and LOUD
63
+
64
+ `ActionParamDialog`'s `filterVisibleParams` was the last predicate face never
65
+ converted to the canonical entry. It evaluated each param's `visible` on a bare
66
+ `ExpressionEvaluator` inside `try { … } catch { return true }`, and that produced
67
+ two defects at once (objectui#4640, measured on `main`):
68
+
69
+ - **Silence.** Three of the four fault shapes emitted nothing at all — an
70
+ unparseable source, an unbound identifier and a faulting legacy predicate all
71
+ resolved without a word, so a broken `visible` was indistinguishable from an
72
+ absent one. The standing 2026-08-06 ruling on objectui#4051 /
73
+ objectstack#5149 names silence as the one option that is not available.
74
+ - **The fail DIRECTION was decided by the predicate's dialect, not by the
75
+ surface.** A bare string ran the legacy JS evaluator (lenient → falsy → param
76
+ silently DROPPED); a `{ dialect, source }` envelope ran CEL (fault → param
77
+ silently KEPT). One `visible` key, two opposite outcomes, chosen by whether
78
+ the authored text happened to contain `${…}` / `===` — the objectui#3314
79
+ shape. Both halves hurt: a dropped param means the dialog never collects a
80
+ value the server requires and the action fails at submit with nothing pointing
81
+ at the predicate; a kept one offers a field the backend rejects.
82
+
83
+ `filterVisibleParams` now routes through `evalRowPredicate`. A param whose
84
+ `visible` cannot be evaluated is **shown**, and reported once, with the action
85
+ and the param named and the predicate quoted. Fail-open is the ruled direction
86
+ for this surface: an extra offered field is rejected by the server with a
87
+ message, while a silently hidden required param is undiagnosable. (Row surfaces
88
+ keep failing closed — there the harm runs the other way.) Boolean and blank
89
+ predicates are answered before the evaluator, so `visible: false` hides the
90
+ param and an empty predicate is not reported as broken.
91
+
92
+ **Behaviour change worth knowing before you upgrade.** On the canonical CEL
93
+ engine an ABSENT key is a runtime fault, not a falsy read. A param gated on
94
+ `features.phoneNumber == true` in a deployment whose scope carries no
95
+ `phoneNumber` key at all now takes the fail-open branch: the param is SHOWN,
96
+ with a warning naming it, where it used to be hidden. The conservative outcome
97
+ is still available, in the spelling that is portable to the server's own engine:
98
+
99
+ ```
100
+ has(features.phoneNumber) && features.phoneNumber == true
101
+ ```
102
+
103
+ Deployments that DECLARE the flag (`features: { phoneNumber: false }`) are
104
+ unaffected — that is a genuine verdict on both engines, and it did not move.
105
+
106
+ `@object-ui/core` gains the two evaluator changes this needed:
107
+
108
+ - `evalRowPredicate` accepts **`rowless`** — "this surface has no row of its
109
+ own", so nothing is bound over the host scope and a `record` / `data` the
110
+ scope carries survives instead of being shadowed by an empty row. Row surfaces
111
+ are untouched: without the option the row is still the subject (objectui#3796).
112
+ - A faulting **`{ dialect, source }` envelope now reports its own source**.
113
+ It used to print the literal `"(expression)"`, and because the warn-once key
114
+ is (label, predicate), the first faulting envelope under a label silenced
115
+ every other one. The envelope is what `@objectstack/spec` normalizes every
116
+ authored predicate into, so this was the likeliest shape in served metadata
117
+ and the least diagnosable one.
118
+ - 167ec42: `ComponentMeta.labelling` grows a third value: `'control' | 'group' | 'display'`
119
+ (objectui#4857, ruled jointly with objectui#4871 as the single repo-wide vocabulary for
120
+ "how does a host learn what a widget will render"). `'display'` declares a widget whose
121
+ whole surface is a pure display in EVERY state — no focusable control, nothing a
122
+ `<label for>` could ever reach.
123
+
124
+ The form renderer answers the declaration with the objectui#4788 host container (field
125
+ id + `aria-labelledby` + `aria-describedby` + `role="group"`) in the editable state too;
126
+ the `readonly === true` arm keeps its exact #4788 semantics for undeclared widgets. The
127
+ display-only four (`formula` / `summary` / `auto_number` / `vector`) declare `'display'`
128
+ — on the real object-form path they arrive `disabled`, never `readonly` (a deliberate
129
+ distinction this change does not touch), so their visible labels pointed `for` at an id
130
+ no element carried and their help text had zero consumers in every editable form.
131
+
132
+ `grid` was re-measured before being classified: its only bare-config focusable is the
133
+ auxiliary "Add line" button (routing `for` there would have label clicks insert rows),
134
+ and every realistic config is a table of per-cell inputs — a composite. It declares
135
+ `labelling: 'group'` and its root container now consumes the host id, name and
136
+ description, exactly like `address` / `checkboxes`.
137
+
138
+ Companion registry gate: `FIELD_WIDGET_LABELLING` (exported) is a `Record` keyed by the
139
+ field-widget map's own literal key union, so registering a widget without deciding its
140
+ labelling is a compile error rather than a silent fall-through to the dangling-`for`
141
+ path, and the declaration test asserts the registered meta agrees with it key by key.
142
+ - b1119ec: Project every declared `recordIdField`, and refuse an action that names no record
143
+
144
+ objectstack#8018. An `api` action declaring `recordIdParam` identifies the record
145
+ it acts on by a row field — `recordIdField`, default `id`. The grid built
146
+ `$select` from the listView columns, `id` and the predicate refs only, so an
147
+ action keyed on any other field asked the server for everything except the key
148
+ naming its own record. The row arrived without it, and the injection was skipped
149
+ silently: the request went out anyway, minus the parameter. A backend reading a
150
+ missing selector as "match nothing" then answers success for having changed
151
+ nothing, so a record-scoped mutation reports success and does nothing.
152
+
153
+ Two independent repairs, both in this change:
154
+
155
+ - **Projection.** `listViewPredicates` (`@object-ui/core`) now also harvests
156
+ `recordIdField` from `rowActionDefs`, `bulkActionDefs` and the object's
157
+ `actions`, spelled as a synthetic `record.<name>` so the one existing harvester
158
+ handles it. Both projection builders — `ObjectGrid` and `ListView` — read that
159
+ function, so both gain the key with no call-site change. The existing guards
160
+ still apply: a name the object does not declare, or one that is not a bare
161
+ identifier, is dropped rather than put in `$select`, because an unknown key
162
+ there is not ignored by every backend.
163
+ - **Loud failure.** New `resolveRecordIdParamSeed` (`@object-ui/core`) is the one
164
+ definition of "can this row identify the record?". `useConsoleActionRuntime`'s
165
+ api handler now refuses the dispatch — `{ success: false, error }`, before the
166
+ request — when the row lacks the key, or holds `null` for it. The two refusals
167
+ are worded differently because they point at different repairs: an absent key
168
+ is a projection or read-visibility problem, a null value is a data one. Falsy
169
+ real values (`0`, `''`, `false`) are values and still dispatch.
170
+
171
+ The second half is what closes the class rather than the common case: a row can
172
+ lack the key for reasons projection cannot fix — a server-side read mask that
173
+ strips the field regardless of `$select`, a partial payload, a field the
174
+ principal cannot read.
175
+
176
+ Behaviour change worth noting: an action that previously dispatched an
177
+ under-specified request now fails visibly instead. That is the point — the old
178
+ path could not report the failure it was causing.
179
+ - af025ee: Draw a null second-dimension group instead of carrying its measure invisibly
180
+
181
+ objectui#4673. `buildChartSeries`' pivot branch kept the pre-objectui#4466
182
+ answer on the SECOND dimension: it bucketed groups by `String(row[groupKey] ??
183
+ '')` behind a `gId !== ''` gate, so a group whose second dimension is `null`,
184
+ `undefined` or `''` never joined the series list — while the line below the
185
+ gate still wrote its measure into the emitted row under the `''` key. The
186
+ number was in the data and bound to no mark, which is #4466's harm verbatim one
187
+ dimension over: the chart understated its own data without saying so.
188
+
189
+ Measured on the card's repro — `GROUP BY status, priority` over a Backlog with
190
+ 5 hours at High priority and 40 hours at no priority — the transform emitted
191
+ `{status: 'Backlog', High: 5, '': 40}` with a single `High` series, and the
192
+ renderer drew exactly ONE bar. The 40 hours were present in the row, scaled for
193
+ on the y-axis, and painted on nothing.
194
+
195
+ Two such groups were worse than unbound: `null` and `''` both key `''`, so the
196
+ later group silently overwrote the earlier one's measure and one of the two
197
+ numbers did not survive the transform at all.
198
+
199
+ **A known-empty group now draws; an unprojected key still refuses.** That split
200
+ is the doctrine the first dimension already used (objectui#4466 versus
201
+ `hasNoCategoryKey`, framework#4033), and it now answers the same way on both
202
+ dimensions. Concretely, a row that does not carry the group key at all gets no
203
+ bucket and contributes no column, where it previously wrote its measure under
204
+ `''`.
205
+
206
+ `null` and `''` are two different groups with two series, following
207
+ objectui#4508's ruling on the first dimension.
208
+
209
+ **The series key is collision-safe, not merely unlikely to collide.** Unlike an
210
+ axis bucket's private map key, a series key is a column of the emitted row and
211
+ the `dataKey` a renderer binds to, so it has to be unique within that row. A
212
+ group keys its column by its own display label — leaving an ordinary pivot's
213
+ rows, series, legend, tooltip and drill title exactly as they were — unless
214
+ that label cannot name it: shared with another group (a stored value spelling
215
+ the null bucket's label), reserved by the row itself (the x-axis column, the
216
+ identity carrier), or equal to some group's identity. Those key by identity
217
+ instead, which no other group has. Because no surviving label is any group's
218
+ identity, the two key spaces cannot meet.
219
+
220
+ Drill-through follows the same assignment: a clicked series key resolves back
221
+ to the group IDENTITY it names, and rows are matched on that. The previous
222
+ `String(r[gDim] ?? '')` comparison was the display-string matching
223
+ objectui#4508 removed on the x-axis — it spelled a null group and an
224
+ empty-string group alike, so the empty-string group's segment resolved to the
225
+ null group's records rather than its own.
226
+
227
+ No renderer change was needed: the null group's series carries the same
228
+ `nullCategoryLabel` the renderers already pass for the first dimension.
229
+ - 9ce096f: Give a chart bucket an identity distinct from its display label
230
+
231
+ objectui#4508. `buildChartSeries` used the bucket's DISPLAY string as the
232
+ bucket's own key, so two pairs of genuinely different groups were conflated —
233
+ and the segment click that drills a bar back to its records inherited both
234
+ conflations. The maintainer ruling (2026-08-14) approved the sentinel-identity
235
+ direction, aligning the chart branch with the distinct-bucket-id form the pivot
236
+ TABLE (`buildPivot`) already uses over the same dataset rows.
237
+
238
+ Two collisions, one cause:
239
+
240
+ - **A null group and an empty-string group drew ONE bar.** The pivot branch
241
+ keyed buckets by `String(xRaw ?? '')`, which spells `null` and `''`
242
+ identically. The bar took its label from whichever row created the bucket, and
243
+ the other group's segment then resolved to no row at all — a visible bar whose
244
+ click did nothing.
245
+ - **A record whose stored value spells the bucket label stole the null bucket's
246
+ drill.** A row storing the literal text `(None)` (or any localized
247
+ `chart.nullCategory` — `(未指定)` and the other nine packs) kept its own
248
+ bucket, so two bars carried the same axis text and BOTH resolved to the first.
249
+ That one is a wrong drill, not a dead one: clicking the null bucket's bar
250
+ opened the drawer on another group's records.
251
+
252
+ What changed:
253
+
254
+ - **`chartBucketId`** (`@object-ui/core`) is the bucket identity — the SAME
255
+ encoder `buildPivot` keys its buckets with (`pivotBucketId` over
256
+ `pivotDimensionValue`), so the two surfaces stop answering one question two
257
+ ways. The pivot branch now buckets by it, which is what makes null and `''`
258
+ two groups again.
259
+ - **`CHART_BUCKET_ID_KEY`** carries that identity on an emitted row, written
260
+ exactly where two DISTINCT buckets paint the same axis text — the complete set
261
+ of cases where the display string cannot name what was clicked. An ordinary
262
+ chart's rows are returned untouched (by identity), so no renderer-internal key
263
+ reaches an authoring surface.
264
+ - **`findChartSeriesRow`** takes that identity back as `options.bucketId` and
265
+ treats it as authoritative. The renderers forward it: the drill event gains
266
+ `categoryId` (`ChartSegmentClickEvent`, now declared once in
267
+ `@object-ui/core` instead of inline in three packages), `AdvancedChartImpl`
268
+ reads it off the clicked row on the cartesian, pie and funnel paths, and
269
+ `DatasetWidget.handleChartDrill` hands it to the lookup.
270
+
271
+ Behaviour change worth noting: an empty-string category no longer resolves to a
272
+ null-valued row. That tolerance was justified as the drill layer's own spelling
273
+ of "no group value", but no producer of this lookup's `category` writes it,
274
+ while `''` IS the axis text a genuine empty-string group paints — so the
275
+ tolerance was giving that group's bar a different group's records. A host that
276
+ forwards no `categoryId` keeps its existing drill unchanged.
277
+ - e05db88: A clicked cartesian mark names its own series, and the drill title reads its label
278
+
279
+ objectui#4672, objectui#4682.
280
+
281
+ **The dead pivoted drill.** objectui#4680 fixed what a cartesian click could
282
+ read out of recharts 3's `MouseHandlerDataParam`, and measured the wall it could
283
+ not get past: a chart-level click is an AXIS interaction, and recharts
284
+ dispatches those with `activeDataKey` hard-coded `undefined`, because the shared
285
+ cursor spans every series at that tick. A pivoted dataset chart — 2 dimensions,
286
+ 1 measure, the shape ADR-0021 introduced — needs the series to resolve its drill
287
+ row, so every segment of every such dashboard chart stayed a dead click. The
288
+ series was left unresolved rather than guessed, and the card carried the rest.
289
+
290
+ The answer is the mark itself. This renderer draws the `Bar` / `Line` / `Area`,
291
+ so an item-level `onClick` closes over the very `dataKey` it was rendered with —
292
+ the series is statically known, not inferred from tooltip state.
293
+
294
+ Both handlers fire for one gesture (measured: item first, chart second, sharing
295
+ one `nativeEvent` object), so the item handler does not emit. It RECORDS its
296
+ series, stamped with that gesture, and the chart-level handler composes the one
297
+ event. That is the double-fire answer and the additive property together:
298
+
299
+ - **one click, one drill event**, because there is one emit site — not a second
300
+ event suppressed after the fact;
301
+ - **a click that lands on no mark is untouched**: it records nothing and falls
302
+ through to the objectui#4680 axis answer exactly as shipped — category, bucket
303
+ identity, and the series only where one series is plotted. Empty plot area
304
+ stays category-only, and "drill the whole category" was rejected as a
305
+ different product question. Nothing that resolved before stops resolving; a
306
+ line's `dot={false}` stroke simply GAINS the exact series where it is hit;
307
+ - pairing on the shared DOM event rather than on a flag means a record left by
308
+ one gesture can never be adopted by a later click.
309
+
310
+ The clicked key is forwarded exactly as rendered, `''` included: the
311
+ empty-string second-dimension group draws its own bar since objectui#4673, and
312
+ `''` is falsy, so a truthiness test on the way out would send no series at all
313
+ and leave that bar's drill standing on the reader's coercion instead of on what
314
+ was clicked.
315
+
316
+ **The opaque drill title.** `ChartSegmentClickEvent` gains `seriesLabel`, and
317
+ `DatasetWidget`'s drill drawer titles itself from `seriesLabel ?? series`.
318
+ `ev.series` stays the LOOKUP key — `findChartSeriesRow` resolves it through the
319
+ same assignment `buildChartSeries` made — and only the title reads the label.
320
+
321
+ The two strings are equal for every ordinary group, which is why reading the key
322
+ as a title went unnoticed. They part company when a group's label cannot name
323
+ it: the null bucket beside a record whose stored value literally spells
324
+ `(None)`, which is objectui#4508's collision on the series axis, reachable since
325
+ objectui#4673. Both groups then key by `chartBucketId`, and the drawer opened on
326
+ the right records under the title `Backlog / [null]`. An internal id where a
327
+ label belongs reads as broken DATA rather than as a broken title.
328
+
329
+ Neither string can do the other's job, which is why this is a second field
330
+ rather than a change to the first: the label is not resolvable (it is exactly
331
+ what the colliding groups share) and the key is not showable. `seriesLabel` is
332
+ optional and absent wherever a renderer resolved no label, so every other
333
+ chart's title is byte-identical.
334
+ - d2ce342: Retire the structured `confirm` object on actions (objectui#4314, maintainer ruling
335
+ 2026-08-17, ADR-0049 enforce-or-remove). `confirmText` is now the one confirm
336
+ spelling — the only one the translation bundle can address
337
+ (`{ns}.objects.{obj}._actions.{name}.confirmText`), matching `@objectstack/spec`'s
338
+ action surface.
339
+
340
+ Breaking semantics (flagged `minor` per this repo's version-alignment policy):
341
+
342
+ - `@object-ui/types`: `ActionSchema.confirm` is a `?: never` tombstone — authoring
343
+ it is now a tsc error, and the Zod twin rejects any authored value at parse time
344
+ (it previously accepted the object). The backwards `@deprecated` note that
345
+ steered authors from `confirmText` INTO the structured arm is gone.
346
+ - `@object-ui/core`: `ActionRunner` no longer reads `confirm.message` (which used
347
+ to outrank `confirmText`, untranslated). `ActionDef.confirm` carries the same
348
+ `never` tombstone. The `ConfirmationHandler` signature is unchanged, but the
349
+ runner now invokes it without the `options` argument.
350
+ - `@object-ui/plugin-grid`: `resolveBulkActions` no longer falls back to
351
+ `confirm.message` when promoting an object action — spec metadata can never
352
+ deliver that key.
353
+
354
+ Nothing in the repo, the example apps, or the schema catalog authored the
355
+ structured form (verified on the issue); a dialog authored that way silently lost
356
+ localization. Reopen condition recorded on objectui#4314: real demand returns the
357
+ arm WITH bundle keys designed in.
358
+
359
+ ### Patch Changes
360
+
361
+ - 2533ec5: The bulk-action dialog's "this widget needs a DataSource" rule now derives from the shared reference-field family instead of a fourth private copy.
362
+
363
+ `packages/plugin-grid/src/components/bulkParamToField.ts` held its own
364
+ `DATA_SOURCE_WIDGET_TYPES` — the fourth hand-maintained answer to one question
365
+ ("which widget has to query records, so it must be handed a DataSource and a
366
+ `reference_to`"), and the only one whose member set matched none of the other
367
+ three: `lookup` / `master_detail` / `user`, against `@object-ui/core`'s
368
+ `EXPANDABLE_FIELD_TYPES` (which also holds `tree`) and the object form's rule
369
+ (which adds three widget-hint pickers). Nothing anywhere could detect the drift;
370
+ the same shape objectui#4770 and objectui#4790 each closed on another surface.
371
+
372
+ It now reads core's set through one predicate, so all three consumers of the rule
373
+ — the label prefetch / option source (`isLookupishParam`), the `dataSource` prop
374
+ the dialog threads into the widget (`fieldNeedsDataSource`), and the
375
+ `reference_to` / `display_field` branch of `bulkParamToField` — cannot drift apart
376
+ from each other or from the form again.
377
+
378
+ No behaviour change on any reachable path, which is why this is a patch. The one
379
+ member the two tables differed on, `tree`, can never be a widget key on this
380
+ surface: it is absent from the fields widget map and `mapFieldTypeToFormType`
381
+ sends it to `field:lookup`, so a `tree` param arrives at the rule as `lookup`
382
+ (pinned). The divergence in the other direction is deliberately preserved: the
383
+ form additionally wires `object-ref` / `filter-condition` / `recipient-picker`,
384
+ widget hints no object schema can declare and no bulk param produces — absorbing
385
+ them would change which widgets receive a DataSource here, which is a behaviour
386
+ change and not a convergence.
387
+
388
+ The pin is an identity pin, not a membership one: it spies on the `has` of the
389
+ Set object core exports, so a member-identical private copy fails it. A
390
+ value-equality assertion would have passed against exactly the defect this
391
+ change removes.
392
+ - bbe8b86: The allow-list of option widgets that are fed the live record is now one exported constant, `CASCADE_OPTION_WIDGET_TYPES`, instead of three private copies.
393
+
394
+ `select` / `multiselect` / `radio` / `checkboxes` are the widgets whose OFFERED
395
+ option set is re-resolved against a record (per-option `visibleWhen`, plus the
396
+ `dependsOn` gate), so they are the widgets a surface must thread its live record
397
+ to. Three surfaces feed that one evaluator — the object form, the single-record
398
+ action dialog and the bulk action dialog — and until now each carried its own
399
+ private `new Set([...])` of the same four keys, with a comment in each asking the
400
+ next person to change all three together. Nothing could have reported them
401
+ drifting: every copy passed its own behavioural tests, and a divergence would
402
+ have shown up only as one surface silently disagreeing with another about what
403
+ "the record" is.
404
+
405
+ The set now lives in `@object-ui/core`, next to `resolveCascadingOptions` — the
406
+ evaluator that reads that record — because core is the one package all three
407
+ surfaces already depend on, and it is re-exported from `@object-ui/fields` next
408
+ to `resolveFormWidgetType`, whose output is the vocabulary the keys are written
409
+ in. Both are the same object, pinned by test; each consumer keeps its own
410
+ normalization (`normalizeFieldType` in the form, `resolveFormWidgetType` in the
411
+ dialogs), which agree on these four members.
412
+
413
+ No behaviour changes: the members are identical on all three surfaces, and the
414
+ existing pins for each surface still assert the same records reaching the same
415
+ widgets. The rationale that was repeated in the three copies — including the
416
+ note that the widget-hint picker family (`filter-condition`, `recipient-picker`,
417
+ the lookup family) reads a different sibling key off the same channel and is
418
+ deliberately NOT in this set — is now stated once, in the constant's own
419
+ documentation. Whether the action and bulk dialogs should ever feed those
420
+ pickers stays an open question (objectui#4771), unchanged by this convergence.
421
+ - 8477be5: `cloneAsOverride()` now returns `DeepMutable<T>`, so a Tenant/User override draft type-checks as the mutable value it has always been.
422
+
423
+ `cloneAsOverride<T>(view: T): T` handed the input type straight back. Cloning a
424
+ `SystemView<S>` — which is `DeepReadonly<S>` plus the marker symbol — therefore
425
+ returned something still typed deep-readonly, even though the implementation has
426
+ always produced a plain mutable object (`structuredClone`, or a JSON round-trip
427
+ fallback) and deliberately drops the marker. The declaration was simply wrong
428
+ about its own value, and the documented override flow was the thing that broke:
429
+
430
+ ```ts
431
+ const draft = cloneAsOverride(userListView)
432
+ draft.columns.push({ name: 'name' }) // TS2339 before this change
433
+ ```
434
+
435
+ That is `packages/core/README.md`'s override example, and it failed to compile
436
+ against the built types — measured by the doc-snippet compile gate, which reads
437
+ `dist/*.d.ts` rather than source. The neighbouring line one block up,
438
+ `userListView.columns.push(...) // ❌ TypeError (strict mode)`, is the opposite
439
+ demonstration and correctly still fails; only the draft line changes colour.
440
+
441
+ The fix adds `DeepMutable<T>`, the inverse of the `DeepReadonly<T>` that
442
+ `SystemView` is built from, and returns it. Per the maintainer's 2026-08-19
443
+ ruling (option A on objectui#5257), the alternatives were rejected by name:
444
+ teaching a cast in the README is the lenient-consumer pattern the contract rules
445
+ out, and declaring the block a documentation fragment hides a real signature
446
+ defect behind the fragment marker.
447
+
448
+ Why this is a patch and not a break: the return type relaxes TOWARD what the
449
+ runtime already does, never away from it. A caller gains permission to mutate;
450
+ nobody loses one. `DeepMutable<S>` stays assignable everywhere `DeepReadonly<S>`
451
+ or `SystemView<S>` was expected, so a caller who fed a draft back into a
452
+ deep-readonly position still compiles — both directions are pinned as type-level
453
+ cases in `freeze-schema.types.test.ts`. A repo-wide sweep found no call site at
454
+ all outside the README, so nothing in this workspace needed changing.
455
+
456
+ Two limits of `DeepMutable`, stated rather than discovered later. It is
457
+ symmetric with `DeepReadonly` arm for arm, which means it inherits the same
458
+ tuple behaviour: a tuple widens to an array, exactly as `DeepReadonly` widens it
459
+ in the other direction. And it does not remove the `SYSTEM_VIEW_MARKER` key —
460
+ the clone never carries the symbol at runtime, but the key is declared optional,
461
+ so keeping it states "may be absent", which is true. Excluding it would require
462
+ a non-homomorphic mapped type that drops the `?` modifier from every other
463
+ property and turns optional keys required — a strictly worse type traded for
464
+ removing a key that already reads as optional.
465
+ - ad07b65: Four packages stop publishing tooling material in their `dist/`
466
+
467
+ Each of these packages spelled its build exclusions as `*.test.*`, while this repo's tooling convention is a directory one — `__tests__` / `__mocks__` / `__benchmarks__`, exactly as `TOOLING_FILE` in `scripts/check-phantom-dependencies.mjs` spells it. Any tooling file whose *name* is not `*.test.*` therefore stayed in the emit program and shipped in the tarball. This is the same shape and the same cause as objectui#4006, which fixed `@object-ui/fields` and `@object-ui/plugin-editor` by the filename criterion and so did not reach these four.
468
+
469
+ Measured by building each package from a cleared `dist/` on both sides of the change. Nine files disappear, none appears, and every surviving file is untouched — the totals move by exactly the count removed:
470
+
471
+ | package | `dist/` files | removed |
472
+ | --- | --- | --- |
473
+ | `@object-ui/core` | 176 to 174 | `dist/__benchmarks__/core.bench.js`, `core.bench.d.ts` |
474
+ | `@object-ui/plugin-designer` | 70 to 66 | `dist/__tests__/__mocks__/plugin-form.d.ts`, `plugin-grid.d.ts`, and both `.d.ts.map` |
475
+ | `@object-ui/plugin-grid` | 62 to 60 | `dist/__tests__/explainDouble.d.ts` and its `.d.ts.map` |
476
+ | `@object-ui/plugin-view` | 13 to 12 | `dist/__tests__/explainDouble.d.ts` |
477
+
478
+ Only `@object-ui/core`'s had runtime weight. The other eight are declarations nothing resolves, but `core.bench.js` is a real emitted module whose first import is `import { bench, describe } from 'vitest'` — a runtime import of a package a consumer never installs, since `vitest` is a devDependency of `@object-ui/core` and devDependencies are not installed transitively. Nothing resolves it today either (it is not in the `exports` map), so no consumer breaks in either direction; this is the tarball shedding files nothing reached.
479
+
480
+ No type coverage leaves with the emit. The three plugins' helper and mock files are already program inputs of the `tsconfig.test.json` that each package's `type-check` chains, reached through the imports in the suites beside them — `tsc --listFiles` names all four files on both sides of the change. `core.bench.ts` had no such edge, since nothing imports a benchmark, so it is now named explicitly in `packages/core/tsconfig.test.json`. That move was deliberate rather than forced: `scripts/check-type-check-coverage.mjs` enumerates `*.test.ts(x)` only, so a benchmark that no program reads is invisible to it, and dropping the coverage silently would have been the "coverage that was right by accident" objectui#4006 recorded. Verified by appending a provably-false annotation to the benchmark, which turns `tsc -p packages/core/tsconfig.test.json` red at exit 2.
481
+ - 41f498b: Fix `packages/core/README.md`'s Component Registry example, which taught
482
+ `new ComponentRegistry()` against an exported singleton **instance**, not a
483
+ class — the built `packages/core/dist/registry/Registry.d.ts` declares
484
+ `export declare const ComponentRegistry: Registry<any>`, so the snippet did
485
+ not compile (`TS2351: This expression is not constructable`, measured by the
486
+ objectui#5138 doc-snippet type gate). A reader who copied it got a compile
487
+ error; if `new ComponentRegistry()` had compiled it would have produced a
488
+ second, empty registry nothing renders from, the more expensive half of the
489
+ mistake.
490
+
491
+ The snippet now calls `ComponentRegistry.register(...)` /
492
+ `ComponentRegistry.get(...)` directly on the singleton, with one line stating
493
+ it is the process-level shared instance `SchemaRenderer` resolves every
494
+ `type` against — the same wording `packages/components/README.md` was given
495
+ in objectui#5160, kept consistent across both READMEs. Readers who want their
496
+ own isolated registry still have `Registry` itself, separately exported as a
497
+ real class.
498
+
499
+ `scripts/check-doc-snippet-types.mjs`'s `UNGATED_DOCS` entry for
500
+ `packages/core/README.md` is updated to match: `TS2351x1` is dropped from its
501
+ reason text now that the diagnostic is gone. The entry is not deleted — the
502
+ document's remaining `TS2339x2` pair (a different, pre-existing defect) is
503
+ out of scope for this change; it's tracked as objectui#5257.
504
+ - ac600e5: A `user` field in a form now receives `dataSource` / `dependentValues` / `dependsOnLabels`, like every other reference field.
505
+
506
+ The form renderer decided which registered widget gets those three props from a
507
+ module-private `DATA_SOURCE_FIELD_TYPES` set, while `@object-ui/core` kept
508
+ `EXPANDABLE_FIELD_TYPES` for the same underlying fact — a field whose stored
509
+ value is a foreign key into another object. The core side's TSDoc claimed to
510
+ mirror the form's set, and it did for 15 days: the form's copy then gained
511
+ `capability-multiselect` (objectui#2403) and the three widget-hint pickers
512
+ `object-ref` / `filter-condition` / `recipient-picker` (objectui#2421) on the
513
+ same day, after which the two sets were not in a subset relation in either
514
+ direction — `user` only in core, the picker names only in the form — with
515
+ nothing able to report it.
516
+
517
+ The form now derives its rule instead of restating it: the reference half is
518
+ core's set, the form-specific half is the three picker names, which are widget
519
+ hints and can never be a declarable field `type`. Adding a member to
520
+ `EXPANDABLE_FIELD_TYPES` therefore also grants it the form's data-source wiring;
521
+ that coupling is intended and is now written down on both sides.
522
+
523
+ The user-visible half is `user`. It previously received none of the three props.
524
+ `dataSource` and `dependentValues` each have a `SchemaRendererContext` fallback
525
+ inside the widget, so the person picker limped along wherever a provider
526
+ happened to supply one; `dependsOnLabels` has no fallback, so a
527
+ dependency-gated user picker interpolated the raw API name into its
528
+ "select ... first" hint in every locale — the leak objectstack#5407 closed for
529
+ lookups and left open here. The widget contract's own `dataSource` doc has
530
+ always named `user` among the types the form renderer injects for.
531
+
532
+ No change to what is expanded, projected or rendered anywhere else: the core
533
+ set's members are untouched.
534
+ - c1ef923: Grid and related-list column headers no longer offer a sort on a `formula` column.
535
+
536
+ A `formula` value is computed on read: no driver materialises a column for it, so
537
+ a server `$orderby` naming one has nothing to order by. That sort never worked.
538
+ Until objectstack#6994 the platform did not say so — the response carried the very
539
+ values it had been asked to order by, out of order, under a `200`, with ascending
540
+ and descending byte-identical on a real SQL driver — and it now answers
541
+ `400 INVALID_SORT`. So the header was wrong before the platform's refusal and is
542
+ wrong after it, for the same reason: it offers a sort that cannot be performed.
543
+
544
+ `ObjectGrid` withheld the affordance only from reference-bearing columns
545
+ (objectui#3096). Unmaterialized types are a SECOND reason a server sort is
546
+ impossible, not a different mechanism, so it now reads both — and so do the two
547
+ sort entry points of a related list (the embedded table's headers and the
548
+ sort-button row a `data-list` card keeps), which each derived that rule
549
+ separately.
550
+
551
+ Client-side sorting is deliberately unchanged. There the rows are all in the
552
+ browser and the formula value is the one the server hydrated on read, so ordering
553
+ by what the cell shows is honest — the same split the relational carve-out makes.
554
+ A sort DECLARED in view metadata is also unchanged: it still goes out and is still
555
+ refused by name, because silently dropping an author's declaration would hide the
556
+ authoring error instead of surfacing it (the toolbar's sort picker keeps such a
557
+ field listed for exactly that reason — it is the only way to remove it).
558
+
559
+ The membership — `formula` alone — moved out of a private set in `ListView` into
560
+ `@object-ui/core` (`UNMATERIALIZED_FIELD_TYPES` / `isUnmaterializedFieldType`),
561
+ bound to `@objectstack/spec`'s own storage predicate so the renderer cannot drift
562
+ from what the drivers actually store. It is deliberately narrower than the spec's
563
+ write contract `COMPUTED_VALUE_TYPES`: a `summary` and an `autonumber` each get a
564
+ real maintained column and sort correctly, and withholding their headers would
565
+ have broken two affordances that work.
566
+ - af5e292: Emit explicit file extensions on relative import specifiers, so the published
567
+ entries can be imported by Node's own ESM resolver.
568
+
569
+ `@object-ui/react`'s built entry re-exported through extensionless relative
570
+ specifiers (`export * from './SchemaRenderer'`). Node does not extension-search
571
+ relative specifiers, so `import('@object-ui/react')` under plain Node — an SSR
572
+ host, or any consumer without a bundler — failed with `ERR_MODULE_NOT_FOUND`.
573
+ Bundled consumers were never affected and are unchanged by this.
574
+
575
+ `@object-ui/types`, `@object-ui/core` and `@object-ui/i18n` carried the same
576
+ emission; `@object-ui/react`'s entry stayed unloadable until they were fixed
577
+ too, because evaluation crosses into them. No exported API changed.
578
+ - 9f23d2b: The object list's Import button now honours `userActions.import`'s CEL predicates.
579
+
580
+ `@objectstack/spec@17.0.0` widened BOTH toolbar-scope keys, not just `create`:
581
+ `userActions.create` and `userActions.import` are typed identically
582
+ (`z.union([z.boolean(), RowCrudActionOverrideSchema])`) and
583
+ `resolveCrudAffordances` emits a predicate envelope for each, with the docblock
584
+ binding them in one breath ("`importPredicates` — same binding as
585
+ `createPredicates`"). objectui#4646 gave the `create` half a consumer and left
586
+ the `import` half declared-and-inert: `importPredicates` had zero readers in
587
+ objectui. An author could write `userActions.import.visibleWhen`, have the spec
588
+ accept it and the resolver parse it, and watch the object-list toolbar offer the
589
+ CSV import wizard unconditionally.
590
+
591
+ The toolbar now evaluates them, mirroring the related list's create half:
592
+ `visibleWhen` fails CLOSED (an unevaluable predicate hides the entry and warns
593
+ once), `disabledWhen` fails SOFT (an unevaluable one leaves the button enabled),
594
+ and the declared-ness rules are the family's — `?? true` for `visibleWhen` so
595
+ `visibleWhen: false` hides rather than reading as "ungated", `!= null` for
596
+ `disabledWhen` so an empty predicate reads as "no condition". The layer sits on
597
+ TOP of the object-level verdict: a predicate can narrow what the `managedBy`
598
+ bucket, the server's effective API operations and the principal's grant already
599
+ allow, never re-open what they closed.
600
+
601
+ Per the spec's binding, a toolbar predicate evaluates once per toolbar against
602
+ the record of the scope the toolbar sits in — and a standalone object list has
603
+ no record in scope. Predicates over the host scope (`os.user.*`, `features.*`)
604
+ are the meaningful shape there; one reading `record.*` has nothing to bind and
605
+ hides the entry, which is the fail-closed rule the spec spells out for exactly
606
+ this surface.
607
+
608
+ `UserActionsOverride.import` widens from `boolean` to the same union as
609
+ `create`, deliberately in this same change: objectui#4646 kept it narrow on
610
+ purpose because widening a type ahead of its consumer re-declares the
611
+ inert-metadata defect one key over. Type and consumer travel together.
612
+ - 31676be: `buildChartSeries`' pivot branch preserves key ABSENCE, so a never-projected first dimension still reaches the framework#4033 placeholder.
613
+
614
+ `hasNoCategoryKey` (plugin-charts' `AdvancedChartImpl`) exists to catch one
615
+ shape: a dimension a dataset query GROUPED BY but never PROJECTED, so no row
616
+ carries the category key. Rather than draw an axis with no marks, the renderer
617
+ names the missing key. Its whole signal is `key in row`, asked of the rows it is
618
+ handed — which for a dataset-bound chart are `buildChartSeries`' output.
619
+
620
+ The pivot branch wrote `[xKey]` onto every bucket it created, so `key in row`
621
+ was unconditionally true downstream and the guard could not fire for a
622
+ 2-dimension/1-measure chart no matter what the query returned. Every row
623
+ collapsed into one unnamed bucket drawing a blank tick — the exact silent shape
624
+ the placeholder was introduced to eliminate. The defence was dead precisely
625
+ where the defect it guards against lands. The single-dimension branch was never
626
+ affected: it passes rows through, so key-absent rows stay key-absent.
627
+
628
+ An emitted bucket now carries the axis key exactly when some row of it did.
629
+ That is `hasNoCategoryKey`'s own `rows.some(key in row)` lifted through the
630
+ pivot's aggregation, so the guard reads the same fact before and after.
631
+
632
+ The route rests on a measurement of how a dataset query actually reports an
633
+ unprojected dimension in a 2-dimension grouping: it OMITS the key. The ObjectQL
634
+ strategy writes a dimension key only when the engine returned that column, the
635
+ native-SQL strategy returns driver rows carrying only the selected columns, JSON
636
+ cannot transport `undefined`, and `ObjectStackAdapter.queryDataset` passes rows
637
+ through by reference. An explicit `null` means the opposite — the column WAS
638
+ projected and its value is null — and that case is untouched: it still renders
639
+ under the `(None)` bucket label (objectui#4466 / objectui#4497). Where one
640
+ bucket collects both (an absent value and a stored `null` share the `[null]`
641
+ identity), the bucket keeps its label and draws, because refusing there would
642
+ tell an author their query never projected a dimension that it did.
643
+
644
+ No change to any chart whose first dimension projected: ordinary, null-valued
645
+ and empty-string categories all emit byte-identical rows, key order included.
646
+ - 5ffcc14: fix(plugin-report): forward the chart chrome and series presentation `ReportChartSchema` declares (objectui#4877)
647
+
648
+ A report's embedded chart forwarded exactly six keys to the registered chart
649
+ component — `chartType`, `data`, `height`, `isAnimationActive`, `series`,
650
+ `xAxisKey`. Everything else `ReportChartSchema` declares as authorable never
651
+ left the report renderer, so it was inert metadata: the author writes it, the
652
+ schema accepts it, nothing reads it.
653
+
654
+ `showLegend` was the sharpest case because dropping it does not merely ignore
655
+ the author, it INVERTS them: `AdvancedChartImpl` computes
656
+ `legendVisible = showLegend !== false`, so an absent value means the legend is
657
+ on and an explicit `showLegend: false` still drew one.
658
+
659
+ Now lowered, under objectui#4229's ruled data/presentation split:
660
+
661
+ - chrome — `showLegend`, `showDataLabels`, `colors` (both the positional-palette
662
+ array and the per-category record), `subtitle`, `description`, `annotations`,
663
+ `interaction`, `height`;
664
+ - per-series presentation — `color`, `stack`, `type`, `yAxis`, `dashArray`,
665
+ `opacity`, `variant`, matched by `series[].name` so series MEMBERSHIP stays
666
+ with the dataset.
667
+
668
+ `title` is deliberately not forwarded: the report renderer paints it as its own
669
+ heading above the plot, and forwarding it would draw a second one inside the
670
+ chart's frame. `aria` is not lowered either — nothing on this path reads it
671
+ (`AdvancedChartImpl` has no `aria` prop, and this renderer hands the component a
672
+ schema directly rather than through `SchemaRenderer`'s flat ARIA injection), so
673
+ forwarding it would move declared-but-unread one layer down.
674
+
675
+ The two helpers (`chartConfigPresentation`, `mergeAuthoredPresentation`) moved
676
+ from `plugin-dashboard`'s `DatasetWidget` to `@object-ui/core` beside
677
+ `buildChartSeries`, the derivation they merge onto, so both surfaces lower one
678
+ vocabulary once instead of keeping a second copy (the duplication objectui#4389
679
+ filed as a defect). `@object-ui/core` additionally exports `mergeAuthoredSeries`
680
+ — the series merge alone — for a surface whose axes are bare dimension/measure
681
+ NAME strings rather than spec `ChartAxis` objects, which is what a report chart
682
+ declares. `DatasetWidget` re-exports both names, so its public surface and its
683
+ rendering are unchanged.
684
+ - d971e51: A create form no longer deadlocks on a `requiredWhen` field that also declares a runtime `defaultValue`.
685
+
686
+ `#4069` ruled that in **create** mode a field whose `defaultValue` is a runtime
687
+ instruction the server resolves per insert (`NOW()` / `current_user`, or a CEL
688
+ Expression envelope) is producer-owned: the control is deliberately left empty
689
+ and the key is omitted from the payload, because `ObjectQL.applyFieldDefaults`
690
+ resolves the declaration only for a field that arrives absent or null. That was
691
+ implemented on the STATIC `required` flag.
692
+
693
+ The conditional spelling was not covered. `requiredWhen` is resolved one layer
694
+ downstream, in the form renderer, against the live record — so a predicate
695
+ resolving TRUE on a create form put the requirement straight back: the control
696
+ was still empty by design, the submit was refused, and the user had nothing
697
+ sensible to type.
698
+
699
+ Both spellings now behave identically on a producer-owned field. A
700
+ `requiredWhen` predicate is a claim about the value at rest in a given state,
701
+ and `NOW()` / `current_user` resolve at insert regardless of state, so the
702
+ producer's guarantee covers the conditional claim by the same argument that
703
+ covers the unconditional one. An author who really means "the user must supply
704
+ this in this state" has a natural spelling for it: do not declare the default.
705
+
706
+ The suppression lands in the single evaluator both layers read,
707
+ `resolveFieldRuleState` — the same verdict that draws the required marker and
708
+ the one the submit-time check consults — so a field can never lose its asterisk
709
+ while still refusing the write. The classifier that answers "is this value the
710
+ producer's to supply" moved down to `@object-ui/core`
711
+ (`isRuntimeDefault` / `isServerOwnedValue`, re-exported from
712
+ `@object-ui/plugin-form`) so the renderer, the wizard's cross-step gate and the
713
+ create-form field builders all read one implementation rather than three.
714
+
715
+ **Edit mode is unchanged.** Defaults do not re-apply to an existing record, so
716
+ on a persisted row the token was already resolved at insert and blanking the
717
+ column is a real removal: `requiredWhen` enforces there exactly as authored.
718
+ Fields with no declared default, and fields whose default is a static literal
719
+ (which IS seeded into the control), are also unaffected in both modes.
720
+ - dfc6975: Related-list "+ New" now honours `userActions.create` predicates, and the grid
721
+ toolbar's inline-edit affordance is gated on `update` permission (objectui#4646,
722
+ objectui#4647).
723
+
724
+ Two declared-but-unenforced gaps on the same toolbar surface.
725
+
726
+ **#4646 — `createPredicates` had a producer and no consumer.**
727
+ `@objectstack/spec@17.0.0` widened `userActions.create` to
728
+ `z.union([z.boolean(), RowCrudActionOverrideSchema])`, so `resolveCrudAffordances`
729
+ emits `createPredicates` — and nothing in objectui read them, against roughly
730
+ fifteen consumption sites apiece for `editPredicates` / `deletePredicates`. The
731
+ symptom: a parent record entering a frozen state correctly greyed its children's
732
+ row Edit/Delete while the related list's "+ New" stayed fully live, so the user
733
+ filled in the whole child form to earn a server 409. The related-list toolbar now
734
+ evaluates `visibleWhen` / `disabledWhen` **once against the host parent record**,
735
+ per the spec docblock's binding for this key, on top of the existing
736
+ `o.create ∧ can(child, 'create')` check. `visibleWhen` hides "+ New" and fails
737
+ CLOSED; `disabledWhen` greys it and fails SOFT — the same evaluator, fail
738
+ directions and hidden-vs-disabled split the record header already uses for
739
+ edit/delete (objectui#4419 / PR #4515). A bare-boolean `userActions.create` is
740
+ untouched: with no predicates there is nothing to evaluate.
741
+
742
+ **#4647 — the inline-edit toggle was the one ungated affordance on its toolbar.**
743
+ It rendered on "grid view ∧ the host wired `onInlineEditChange` ∧ not the compact
744
+ toolbar", and every host wires that callback unconditionally. New and Import are
745
+ hidden for an account without the grant and the bulk-delete entry on the same
746
+ toolbar ANDs `can(obj, 'delete')`, but a read-only principal could flip inline
747
+ edit, modify cells and press "Save all" to earn a server 403. It is now gated on
748
+ the object's resolved edit affordance ∧ `can(object, 'update')`, mirroring that
749
+ bulk-delete gate. The gate is applied at all three sites that carry this
750
+ affordance — the wide toolbar's toggle, the compact toolbar's settings-popover
751
+ entry (which previously had no gate at all, not even the callback), and the
752
+ `editable` mode handed to the grid, so a stored view carrying `inlineEdit: true`
753
+ can no longer drop a read-only principal into editable cells with no toggle to
754
+ press.
755
+
756
+ `ListViewSchema.userActions.editInline` is also consumed now: an explicit `false`
757
+ withholds the affordance wholesale, which authors previously could not do.
758
+
759
+ **Behaviour change for read-only users, stated plainly.** Where the UI used to
760
+ offer inline editing and let the server refuse it, it now declines to offer the
761
+ entry point at all. No data access changes — the server gate was and remains the
762
+ enforcement boundary; this only stops the UI walking users into round-trips
763
+ guaranteed to fail. Accounts *with* the grant see no change, and hosts with no
764
+ `PermissionProvider` mounted (standalone embeds, the Studio designer) keep
765
+ today's behaviour, since `can()` answers `true` there by design.
766
+
767
+ One deliberate non-change: the absent case of `userActions.editInline` defers to
768
+ the host's existing `inlineEdit` channel rather than enforcing the spec's
769
+ `.default(false)`. Enforcing that default would remove the toggle from every
770
+ stored console list view in one release, since nothing folds a legacy key into
771
+ `editInline` and no existing view declares it. This follows the rule the
772
+ surrounding toolbar-flag block already states for itself — defaults chosen to
773
+ match what the flags have always done. `InterfaceListPage`, the key's other
774
+ consumer, reads the absent case as OFF, because the ADR-0047 interface page has
775
+ no such host channel to defer to.
776
+ - Updated dependencies [88085e3]
777
+ - Updated dependencies [279fb13]
778
+ - Updated dependencies [1184192]
779
+ - Updated dependencies [a2a9747]
780
+ - Updated dependencies [af5e292]
781
+ - Updated dependencies [7f96b10]
782
+ - Updated dependencies [f1d4748]
783
+ - Updated dependencies [578e025]
784
+ - Updated dependencies [598c89a]
785
+ - Updated dependencies [b8b9af4]
786
+ - Updated dependencies [97abb24]
787
+ - Updated dependencies [deb157a]
788
+ - Updated dependencies [d2ce342]
789
+ - Updated dependencies [9695da7]
790
+ - Updated dependencies [58b8346]
791
+ - Updated dependencies [3cf4de0]
792
+ - Updated dependencies [c9dc811]
793
+ - Updated dependencies [a0b9e91]
794
+ - Updated dependencies [99bd015]
795
+ - @object-ui/types@17.6.0
796
+
3
797
  ## 17.5.0
4
798
 
5
799
  ### Minor Changes