@object-ui/core 16.1.0 → 17.1.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 (43) hide show
  1. package/CHANGELOG.md +1355 -4
  2. package/dist/actions/ActionRunner.d.ts +82 -14
  3. package/dist/actions/ActionRunner.js +164 -81
  4. package/dist/actions/actionKeys.d.ts +118 -0
  5. package/dist/actions/actionKeys.js +243 -0
  6. package/dist/actions/index.d.ts +1 -0
  7. package/dist/actions/index.js +1 -0
  8. package/dist/adapters/ApiDataSource.js +10 -1
  9. package/dist/adapters/ValueDataSource.d.ts +2 -2
  10. package/dist/evaluator/fieldRules.d.ts +0 -2
  11. package/dist/evaluator/fieldRules.js +3 -2
  12. package/dist/evaluator/optionLint.js +5 -2
  13. package/dist/evaluator/optionRules.d.ts +13 -6
  14. package/dist/evaluator/optionRules.js +0 -23
  15. package/dist/index.d.ts +4 -0
  16. package/dist/index.js +7 -0
  17. package/dist/registry/Registry.d.ts +81 -2
  18. package/dist/registry/Registry.js +102 -2
  19. package/dist/registry/public-blocks.js +52 -1
  20. package/dist/theme/ThemeEngine.js +0 -12
  21. package/dist/utils/chart-series.d.ts +28 -0
  22. package/dist/utils/chart-series.js +55 -0
  23. package/dist/utils/column-identity.d.ts +110 -0
  24. package/dist/utils/column-identity.js +210 -0
  25. package/dist/utils/dashboard-filters.d.ts +2 -2
  26. package/dist/utils/expand-fields.js +8 -8
  27. package/dist/utils/filter-converter.d.ts +47 -0
  28. package/dist/utils/filter-converter.js +94 -7
  29. package/dist/utils/filter-tokens.d.ts +101 -0
  30. package/dist/utils/filter-tokens.js +173 -0
  31. package/dist/utils/managedBy.d.ts +21 -3
  32. package/dist/utils/managedBy.js +45 -4
  33. package/dist/utils/normalize-list-view.d.ts +99 -0
  34. package/dist/utils/normalize-list-view.js +255 -0
  35. package/dist/utils/sort-values.d.ts +52 -0
  36. package/dist/utils/sort-values.js +125 -0
  37. package/dist/validation/index.d.ts +9 -1
  38. package/dist/validation/index.js +9 -1
  39. package/dist/validation/validators/index.d.ts +7 -1
  40. package/dist/validation/validators/index.js +7 -1
  41. package/dist/validation/validators/object-validation-engine.d.ts +151 -31
  42. package/dist/validation/validators/object-validation-engine.js +275 -142
  43. package/package.json +4 -4
package/CHANGELOG.md CHANGED
@@ -1,5 +1,1356 @@
1
1
  # @object-ui/core
2
2
 
3
+ ## 17.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 62311b6: feat(core): inventory `ActionDef`'s keys and warn on the ones nothing reads — objectstack#4075 step 1
8
+
9
+ `ActionDef` ends with `[key: string]: any`, so it accepts any key of any type.
10
+ Deleting `ActionDef.execute` produced **zero** compile errors even though the
11
+ field had just been removed (objectui#2990), and stale metadata still authoring
12
+ `execute: 'markDone'` type-checks today. The same deletion against
13
+ `@object-ui/types`' `ActionSchema` — which has no index signature — correctly
14
+ produced `TS2353` at the authoring site. One of the two readers can catch a
15
+ retired key; the other is structurally incapable, which is how a typo (`targt`)
16
+ and a tombstoned key both reach a runner that then silently does nothing.
17
+
18
+ This is the non-breaking first step of the staged narrowing: it makes the key set
19
+ **visible** and warns on anything outside it, without changing a single type.
20
+
21
+ New exports from `@object-ui/core`:
22
+
23
+ - `ACTION_DEF_KEYS`, `SPEC_ACTION_KEYS`, `NAVIGATION_ALIAS_KEYS`,
24
+ `RETIRED_ACTION_KEYS`, `KNOWN_ACTION_KEYS` — the inventory.
25
+ - `classifyActionKeys(action)` — splits an action's own keys into `unknown` and
26
+ `retired`.
27
+ - `warnOnUnknownActionKeys(action)` — dev-mode only, warn-once. Called by
28
+ `ActionRunner.execute`, so no consumer wiring is needed.
29
+
30
+ A retired key gets a louder, more specific warning than an unknown one: an
31
+ unknown key is probably a typo, a retired key is metadata that used to work.
32
+ `execute` is not simply gone from the spec — it is a live **tombstone**, still
33
+ present in `ActionSchema` so the parser can reject it by name with the rename
34
+ prescription.
35
+
36
+ Nothing is rejected and no types changed, so existing metadata behaves exactly as
37
+ before. Promoting the legitimate keys to explicit optional fields, then removing
38
+ the index signature so `tsc` catches both typos and retired keys, are steps 2 and
39
+ 3 of objectstack#4075.
40
+
41
+ - 9e7349e: **`target` is the only action handler slot — the `execute` alias is gone from the renderer (framework#3856).**
42
+
43
+ `ActionRunner.executeScript` read `action.target || action.execute`. That fallback
44
+ is unreachable against `@objectstack/spec` 17: `execute` is now a tombstoned key
45
+ (framework#3855) that the parser **rejects** with the rename prescription, so no
46
+ parsed action can carry it and the `||` could only ever yield `target`. Verified
47
+ against 17.0.0-rc.0 — an action declaring `execute` fails `ActionSchema.safeParse`,
48
+ and a `target` action's parsed output has no `execute` key at all.
49
+
50
+ Deleted rather than left as harmless residue: two handler slots is what let one
51
+ action run one script server-side and a different one client-side (framework#3713,
52
+ where this renderer preferred the alias while the spec transform preferred
53
+ `target`). A dead slot still reads as a live contract to the next maintainer.
54
+
55
+ `execute` is also **removed from the types**, which is the part that had never
56
+ landed. framework#3856 predicted a compile error here; there wasn't one, because
57
+ neither reader was typed against the spec's `z.infer`:
58
+
59
+ - `@object-ui/types` `ActionSchema` hand-declared `execute?: string`. Removed, so
60
+ `execute: '…'` now fails `tsc` at the authoring site (TS2353).
61
+ - `@object-ui/core` `ActionDef` hand-declared it too. Removed — but `ActionDef`
62
+ carries a `[key: string]: any` index signature, so stale hand-authored metadata
63
+ that never passed through the parser still compiles. For that path
64
+ `executeScript` now returns the rename prescription instead of a bare
65
+ "No script provided", matching the spec tombstone's rule that removing an
66
+ authorable key must be audible: silently binding no handler is the
67
+ "Mark Done does nothing" shape (framework#2169).
68
+
69
+ The four action renderers (`action:button`, `action:icon`, `action:menu`,
70
+ `action:group`) no longer forward `execute` into the runner, and Studio's
71
+ `ActionPreview` no longer falls back to it — previewing an alias-only draft as
72
+ "bound" contradicted the parse that rejects it on save.
73
+
74
+ Requires `@objectstack/spec` 17. Metadata still on the alias is rewritten by
75
+ `os migrate meta --from 16`.
76
+
77
+ - 6ae818e: feat(core): one column identity per column — `field` stamped at ingestion (#3104)
78
+
79
+ A column's field identity was resolved twice, with two different precedences
80
+ over the same `schema.columns` array, and the two halves disagreed:
81
+
82
+ - **request path** — `ListView`'s `$expand` and `$select` builders, and
83
+ `ObjectGrid.getSelectFields`, read `f?.field` and only `f?.field`.
84
+ - **render path** — the FLS gate, the hidden-field filter, `fieldOrder`, both
85
+ export branches and the hide-fields popover read
86
+ `f.name || f.fieldName || f.field` — name FIRST.
87
+
88
+ So `{ field: 'account', name: 'account_name' }` fetched `account` while the
89
+ renderer keyed off `account_name`, and `{ name: 'account' }` rendered a column
90
+ the request dropped entirely — neither `$select` nor `$expand` carried it. That
91
+ is the mechanism behind the "relation column shows a bare id / column is empty
92
+ / sort does nothing / export is missing a column" defect class.
93
+
94
+ Per AGENTS.md #0.1 the fix is not another `?? name` at the read sites. Legacy
95
+ acceptance moves to the one boundary that already folds this view's vocabulary,
96
+ `normalizeListViewSchema`, which now also canonicalizes each column's identity.
97
+
98
+ New in `@object-ui/core`:
99
+
100
+ - `columnIdentity(entry)` — the single reader. Resolves `field` → `name` →
101
+ `fieldName`, canonical-first, so it agrees with `buildExpandFields` instead
102
+ of racing it. Handles bare-string columns.
103
+ - `normalizeColumnIdentity(entry)` / `normalizeColumnIdentities(columns)` — the
104
+ fold. Stamps `field`; a legacy key that is **already present** is mirrored
105
+ onto the same identity so name-first readers resolve what the request asked
106
+ for; a legacy key that is **absent is never invented**, and an
107
+ already-canonical column is returned by reference.
108
+ - `hasConflictingColumnIdentity(entry)` — true when a column's keys disagree.
109
+ - `CANONICAL_COLUMN_IDENTITY_KEY`, `LEGACY_COLUMN_IDENTITY_KEYS`,
110
+ `TABLE_ADAPTER_COLUMN_KEY`.
111
+
112
+ The fold **mirrors** rather than deleting the legacy key, unlike the other
113
+ folds in `normalizeListViewSchema`. Deleting would work inside this repo (every
114
+ name-first read falls through to `field`), but `columns` entries cross the
115
+ package boundary into host renderers and dropping `name` from under them is a
116
+ breaking change with no inventory. Deletion is a later call, once the in-repo
117
+ consumers read `columnIdentity()`.
118
+
119
+ Behaviour is unchanged for any column carrying a single identity key — every
120
+ read site resolves the same string it did before. The entries whose resolution
121
+ moves are exactly the ones where two sites already disagreed.
122
+
123
+ `accessorKey` is deliberately untouched: it is TanStack Table's own column key
124
+ (`TableColumn.accessorKey`), not ObjectStack metadata identity, and folding
125
+ across that boundary would fossilize the merge.
126
+
127
+ - 746dd00: feat(sdui): curate the page:\*, element:\* and action:\* families into the public contract
128
+
129
+ The AI-authoring vocabulary and the Studio page designer disagreed by thirteen
130
+ blocks: `PUBLIC_BLOCKS` carried one `page:` tag and one `element:` tag while
131
+ the designer palette — and @objectstack/spec's page schema — offered the whole
132
+ families. A block a human can drag in Studio was invisible to a model writing
133
+ the same page, which is the objectui#3006 state at 10× the scale.
134
+
135
+ Fifteen tags join the contract (36 → 42 → **57**), every one shipping a
136
+ renderer with declared inputs (objectui#3065):
137
+
138
+ - `page:` — tabs, card, accordion, section, footer, sidebar
139
+ - `element:` — text, number, button, definition-list, repeater
140
+ - `action:` — button, group, menu, icon
141
+
142
+ Five stay out, each with its reason recorded and guarded: `action:bar`
143
+ (`record:quick_actions` covers the record action strip; the spec blesses the
144
+ other four), `element:image` (duplicates the curated `image` — one spelling
145
+ per concept), and `element:record_picker` / `element:text_input` /
146
+ `element:metadata_viewer` (mirroring the Studio palette's own exclusions, so
147
+ the two vocabularies stay out for the same reasons rather than by
148
+ coincidence).
149
+
150
+ The console's reverse-coverage guard now sweeps all four semantic namespaces
151
+ instead of `record:` alone — checking only the namespace you just fixed is
152
+ exactly how the last 22 doubled keys went unnoticed (objectui#3037). A new
153
+ prop-less allowlist (`element:divider`, `page:section`, `page:footer`,
154
+ `page:sidebar`) keeps "declares no inputs" a pinned decision in both
155
+ directions: those four must stay at zero, everything else curated must declare
156
+ a surface.
157
+
158
+ - 38ca8be: refactor(fields): `requiredWhen` is the only required-predicate slot — drop the retired `conditionalRequired` alias
159
+
160
+ `@objectstack/spec` 17 (objectstack#3855) **retired** `Field.conditionalRequired`,
161
+ the long-deprecated alias of `requiredWhen`. ObjectUI carried a back-compat read
162
+ for it in seven places; all of them are removed.
163
+
164
+ The removal is safe because the spec did not merely _stop emitting_ the key — it
165
+ made authoring it **fail loudly**. `retiredKey()` declares the key as
166
+ `z.never()`, so:
167
+
168
+ - `z.input` types it as `never` — writing it is a `tsc` error at the authoring site;
169
+ - the parse **rejects** it (verified against `17.0.0-rc.0`), at both `FieldSchema`
170
+ and `ObjectSchema`, with the prescription as the message:
171
+
172
+ > `conditionalRequired` was removed in @objectstack/spec 17 (#3855) — use
173
+ > `requiredWhen`. Rename the key; the value (a CEL predicate) is unchanged.
174
+ > Run `os migrate meta --from 16` to rewrite it automatically.
175
+
176
+ So spec-parsed metadata cannot carry the key — an object declaring it fails to
177
+ load rather than loading with the rule silently dropped. Keeping a renderer-side
178
+ `requiredWhen ?? conditionalRequired` would have re-created exactly the second
179
+ de-facto contract the tombstone exists to prevent: the key would have kept
180
+ working in the UI while being rejected everywhere else, hiding the producer's bug
181
+ (AGENTS.md #0.1). "Backend-agnostic" (#1) does not argue for keeping it either —
182
+ `conditionalRequired` is an ObjectStack-spec-ism, so the only producers that ever
183
+ emit it are ObjectStack producers on ≤16, and the spec ships them a converter.
184
+
185
+ Removed from:
186
+
187
+ | package | site |
188
+ | :----------------------- | :-------------------------------------------------------------------------------------------------------- |
189
+ | `@object-ui/types` | the `conditionalRequired?:` member on `FormField` |
190
+ | `@object-ui/core` | the `??` fallback + rules-param member in `resolveFieldRuleState` |
191
+ | `@object-ui/components` | three pass-throughs in the form renderer |
192
+ | `@object-ui/plugin-form` | `ObjectForm`, `ModalForm`, `sectionFields`, `deriveMasterDetail` (×2) |
193
+ | `@object-ui/app-shell` | the field inspector's legacy read/auto-migrate, and the key's entry in `clientValidation`'s CEL lint list |
194
+
195
+ **Studio authors lose nothing.** The object designer's draft validation parses
196
+ against the spec's own `ObjectSchema`, so a draft carrying the key now surfaces
197
+ the tombstone's rename prescription under the same `fields.<name>.conditionalRequired`
198
+ path the CEL lint used to report — a better message than the inspector's silent
199
+ auto-migration, and one the server agrees with. That behavior is pinned by a test.
200
+
201
+ **Migrating:** rename the key to `requiredWhen` (the CEL value is unchanged), or
202
+ run `os migrate meta --from 16`.
203
+
204
+ - 02aef0c: fix(sdui): a `kind:'html'` page can use lazily-registered blocks, and recovers when one registers late
205
+
206
+ objectui#2953 had a twin one tier over, unreported. The whitelist a
207
+ `kind:'html'` page's source compiles against was built from `getAllTypes()` +
208
+ `getConfig()` — both loaded-only — so any block registered via `registerLazy()`
209
+ was rejected as _"not an allowed component"_.
210
+
211
+ The blast radius is worse than the react tier's. There, a missing block cost one
212
+ identifier; here a compile diagnostic fails the **whole page**, so a single
213
+ `<object-kanban>` replaced the entire page with `HTML page failed to compile (2)`.
214
+ And it never recovered: `layoutElement` was memoised on `[schema, pageType]` with
215
+ no registry signal, so the cached error panel outlived the plugin actually
216
+ landing — permanently broken for the session.
217
+
218
+ `ComponentRegistry` gains three lazy-aware reads:
219
+
220
+ - `getKnownTypes()` — loaded registrations **plus** pending lazy stubs, deduped.
221
+ The set a whitelist or manifest should be built from. `getAllTypes()` keeps its
222
+ loaded-only meaning ("what can render right now") and now says so.
223
+ - `getMeta(type, namespace?)` — metadata from the loaded registration, else from
224
+ a pending stub. `getConfig()` stays loaded-only, since callers read
225
+ `.component` off it.
226
+ - `getVersion()` — monotonic counter of changes to the known set, bumped on
227
+ register / unregister / registerLazy. A cache key that a type _count_ cannot
228
+ substitute for: one registration plus one unregistration leaves the count
229
+ untouched while the set changed.
230
+
231
+ `getJsxManifest()` builds from those, and `PageRenderer` subscribes to the
232
+ registry so a page that could not compile retries when the registry grows.
233
+
234
+ A stub carries no `inputs` yet, so its props surface as `unknown-prop` warnings
235
+ rather than errors — the page compiles and renders, and the inner
236
+ `SchemaRenderer` triggers the loader and swaps in the real block. Authoring-time
237
+ prop validation is unaffected: `sdui.manifest.json` is generated with every
238
+ plugin eagerly loaded, and asserts as much.
239
+
240
+ - c4db402: refactor(views): ListView's `aria` and `sharing` are the spec sub-shapes (#2890 scope A step 5)
241
+
242
+ Last rename batch in the ListView vocabulary migration.
243
+
244
+ **`aria`** is now the spec's `AriaPropsSchema`: `label` → `ariaLabel`,
245
+ `describedBy` → `ariaDescribedBy`, folded at the ListView boundary like every
246
+ other legacy key. Two things fall out of adopting the spec shape:
247
+
248
+ - `role` becomes authorable. The list region hardcoded `role="region"`; it now
249
+ reads `aria.role` and falls back to `region`.
250
+ - `aria.live` stays as a documented local extension — it has no spec
251
+ counterpart, and dropping it would silently disable a shipped capability.
252
+ Promote it rather than growing that extension.
253
+
254
+ **`sharing`** is now the spec's `ViewSharingSchema` (`{ type, lockedBy }`),
255
+ imported by reference — the local four-key object is gone. The legacy pair folds
256
+ in: `visibility` collapses onto the two ownership kinds the spec models (only
257
+ `private` is `personal`; `team` / `organization` / `public` are all
258
+ `collaborative`), and a bare `enabled: true` maps to `personal`, which is the
259
+ badge the user already saw (the old title fell back to `'private'`).
260
+
261
+ _Visible change_: the share badge's tooltip shows the spec ownership type, so a
262
+ view authored with `visibility: 'team'` reads "Sharing: collaborative" instead
263
+ of "Sharing: team". The four-value audience has no spec home and nothing but
264
+ that tooltip consumed it; keeping a second audience enum alive would re-open the
265
+ fork this issue closes.
266
+
267
+ Also fixes the **spec bridge**, which was doing the opposite of its job: given a
268
+ spec-shaped `sharing`, `transformListView` _downgraded_ it — inventing a legacy
269
+ `visibility` audience and an `enabled` flag that the renderer then had to fold
270
+ back. Both sides speak `ViewSharing` now, so it passes through.
271
+
272
+ `conditionalFormatting` and `exportOptions` are deliberately **not** folded.
273
+ Both objectui shapes are supersets carrying capability the spec cannot express —
274
+ the `{ field, operator, value }` rule form, and `maxRecords` / `includeHeaders`
275
+ / `fileNamePrefix`. Folding them onto the narrower spec shapes would delete
276
+ working features; they want promotion upstream, not a rename.
277
+
278
+ - 5319bf1: feat(views): the list toolbar speaks one vocabulary — `userActions` (#2890 scope A step 3)
279
+
280
+ The seven bare `show*` toolbar flags fold into the spec's `userActions`, and the
281
+ renderer reads nothing else. `showDescription` folds into
282
+ `appearance.showDescription` at the same boundary.
283
+
284
+ | legacy | canonical |
285
+ | :-------------------------------------------------------- | :-------------------------------------------------------- |
286
+ | `showSearch` / `showSort` / `showFilters` / `showDensity` | `userActions.search` / `.sort` / `.filter` / `.rowHeight` |
287
+ | `showGroup` / `showHideFields` / `showColor` | `userActions.group` / `.hideFields` / `.rowColor` |
288
+ | `showDescription` | `appearance.showDescription` |
289
+
290
+ **The last three are new keys, and they close a capability hole rather than just
291
+ renaming one.** `@objectstack/spec`'s `UserActionsConfigSchema` documents itself
292
+ as "which interactive actions are available to users in the view toolbar — each
293
+ boolean toggles the corresponding toolbar element on/off", and already carries
294
+ `rowHeight` (objectui's `showDensity` under its spec name). Grouping, column
295
+ visibility and row coloring are the same kind of toggle: the spec models all
296
+ three as _configuration_ (`grouping`, `hiddenFields`, `rowColor`) but has no
297
+ "may the user change it" switch for any of them.
298
+
299
+ The consequence was visible in the product. With no `userActions` key to read,
300
+ the two list surfaces **hardcoded opposite policies**: `InterfaceListPage` (the
301
+ author-curated interface page) pinned all three OFF, `ObjectDataPage` pinned two
302
+ ON — and an interface-page author could not turn grouping back on for end users
303
+ at all. Both surfaces now express their policy as `userActions` defaults, which
304
+ an author can override.
305
+
306
+ Until the keys land in `@objectstack/spec`, `@object-ui/types` carries them as a
307
+ documented `.extend()` on `UserActionsConfigSchema` (the same shape
308
+ `ListColumnSchema` uses while waiting on objectstack#3761); it collapses into a
309
+ plain re-export once they do. Note the spec schema is not `.strict()`, so before
310
+ this an author writing `userActions: { group: false }` had it **silently
311
+ stripped** — valid on parse, no effect at render.
312
+
313
+ Defaults are unchanged and deliberately asymmetric, matching what these flags
314
+ have always done: `search` / `sort` / `filter` / `rowHeight` / `group` are on
315
+ unless turned off; `hideFields` / `rowColor` are off unless turned on. Making
316
+ them uniform would grow two buttons on every existing view, so it is left as its
317
+ own product decision rather than smuggled into a vocabulary migration.
318
+
319
+ Also drops a dead relay in app-shell's `ObjectView`, which forwarded
320
+ `showDescription` onto the node although `ListView` has only ever read
321
+ `appearance.showDescription`.
322
+
323
+ - f59f2c1: refactor(actions): `navigation` becomes a named alias of the spec's `url`, sharing one navigator (#2944)
324
+
325
+ The last open item of #2944: `ActionRunner` dispatched a seventh action type,
326
+ `navigation`, that `@objectstack/spec`'s `ActionType` does not contain. The issue
327
+ asked for a decision — promote it upstream or delete the case. Neither, as stated.
328
+
329
+ - **Promoting it is wrong.** The spec already has `url` for "go to a location",
330
+ with `openIn` for the new-tab/same-tab choice. A seventh type would put a
331
+ second spec name on one operation, which is the exact failure the #2901 audit
332
+ is named after: _a second definition of the vocabulary exists, and the renderer
333
+ is faithful to the wrong one_.
334
+ - **Deleting it is worse, because it is silent.** `{ type: 'navigation', to: … }`
335
+ is authored today (`element:button` CTAs). Without the case the action falls
336
+ through to `executeActionSchema`, which returns `{ success: true }` — a green
337
+ toast that navigates nowhere. That is #2960's trap.
338
+
339
+ So it stays, but stops being dialect. `ObjectUiLocalActionType` /
340
+ `OBJECTUI_LOCAL_ACTION_TYPES` in `@object-ui/types` declare it as objectui's own
341
+ alias of `url` — the same treatment #2985 gave `PageVisualizationAlias` — and the
342
+ runner routes both names through one navigator.
343
+
344
+ **The alias had already drifted, which is the point.** `executeNavigation` was
345
+ quietly the weaker of the two implementations: no `${param.X}` / `${ctx.X}`
346
+ interpolation, `openIn` ignored, and no `/api/…` full-page short-circuit (the
347
+ redirect-dance case `url` exists to handle). An author who wrote
348
+ `{ type: 'navigation', to: '/x?p=${param.p}' }` shipped the literal `${param.p}`,
349
+ while the identical `url` action resolved it. Both names now behave identically;
350
+ `url` in turn gains `replace` pass-through, the one modifier only the alias had.
351
+
352
+ Additive only. `replace` is omitted from the `NavigationHandler` options object
353
+ when unset, so hosts see the option shape they already saw.
354
+
355
+ The new guard is structural rather than another assertion. The runner's built-in
356
+ dispatch is a table typed `Record<RunnableActionType, …>` instead of a `switch`,
357
+ so an `ActionType` the spec **adds** stops compiling until an executor exists for
358
+ it — the Tier-2 "validates at save, renders nothing at run time" failure (#2942)
359
+ becomes a build error for actions. `spec-derived-unions.test.ts` additionally
360
+ asserts `navigation` is _absent_ from the spec enum, so the day it is adopted
361
+ upstream, the test fails and names the alias to retire.
362
+
363
+ - ce08d55: chore(deps): upgrade `@objectstack/*` to 17.0.0-rc.0, and let the spec take back what it now owns
364
+
365
+ `spec` / `client` / `formula` / `lint` move from `^16.x` to `^17.0.0-rc.0`. Two
366
+ groups of v17 changes reach this repo, and they pull in opposite directions —
367
+ the spec pruned surface objectui re-exported, and adopted surface objectui had
368
+ been carrying locally.
369
+
370
+ **The spec pruned dead Theme config (objectstack#3494), so the re-exports went
371
+ with it.** `ThemeSchema` dropped `spacing`, `breakpoints`, `logo`, `density`,
372
+ `wcagContrast`, `rtl`, `touchTarget` and `keyboardNavigation` — authorable but
373
+ never enforced, so authoring them was already a silent no-op. `@object-ui/types`
374
+ re-exported those sub-schemas _by reference_ (issue #2231), so they could not
375
+ survive the prune without becoming hand-written mirrors — exactly the second
376
+ de-facto contract AGENTS.md #0.1 forbids. Removed from the public surface:
377
+
378
+ - Types: `Spacing`, `Breakpoints`, `DensityMode`, `WcagContrastLevel`,
379
+ `ThemeLogo`, and the deprecated `SpacingScale` alias
380
+ - Schemas: `SpacingSchema`, `SpacingScaleSchema`, `BreakpointsSchema`,
381
+ `ThemeLogoSchema`, and the `SpacingSchemaType` / `BreakpointsSchemaType` helpers
382
+ - `Theme.spacing`, `Theme.breakpoints` and `Theme.logo`
383
+
384
+ `mergeThemes` no longer merges the three dropped keys. `generateThemeVars` is
385
+ unaffected — it never emitted them, which is why the liveness audit called them
386
+ dead. The one real consumer was `ThemeProvider`, which set the favicon from
387
+ `theme.logo.favicon`; that path is gone, because v17 strips the key at parse and
388
+ it could never arrive again. The live favicon is unaffected: it comes from
389
+ operator branding (`getFaviconUrl()`), applied in the console's `index.html`,
390
+ `main.tsx`, and on route change.
391
+
392
+ Nothing else read the pruned types. In particular the list-density feature is
393
+ untouched — `useDensityMode` and `rowHeightToDensityMode` use `@object-ui/core`'s
394
+ own local `DensityMode`, which never came from the spec.
395
+
396
+ **The spec adopted objectui's ListColumn extensions (objectui#2231), so the
397
+ extension collapsed.** `ListColumnSchema` used to `.extend()` the spec with two
398
+ fields, each carrying a note to promote it upstream rather than grow the
399
+ extension; v17 did exactly that. `summary` is now the spec's
400
+ `union([ColumnSummarySchema, ColumnSummaryConfigSchema])` — the same enum ∪
401
+ `{ type, field }` form `useColumnSummary` reads — and `prefix` is the spec's
402
+ `ColumnPrefixSchema`. `ListColumnSchema` is now a plain by-reference re-export.
403
+ One behavior change rides along: `prefix.type` defaults to `'text'` on parse
404
+ instead of staying `undefined`, so the cell renderer always gets a value.
405
+
406
+ **Node 22 is now the floor.** Every `@objectstack` package declares
407
+ `engines.node: ">=22.0.0"` (objectstack#3825; Node 20 reached EOL 2026-04-30).
408
+ This repo claimed `>=20` and ran CI on Node 20.x, so it promised — and validated
409
+ — a runtime its own core dependency does not support. `engines.node` is now
410
+ `>=22`, CI runs Node 22.x, and the CI/deployment docs say so.
411
+
412
+ The major stays 17: per AGENTS.md the major tracks `@objectstack`'s major, which
413
+ is also 17, and that convention deliberately outranks semver purity — so the
414
+ removals above ship as a minor rather than desyncing the two.
415
+
416
+ - 390c071: feat(record): declare inputs for the seven configurable record:\* blocks, and curate six
417
+
418
+ Seven `record:*` blocks shipped with renderers that read props but declared no
419
+ `inputs`. That combination is the worst of both: the renderer honours
420
+ `limit`, `severity`, `location` …, while every authoring surface — the designer
421
+ panel, the AI vocabulary, the generated manifest — reports the block takes no
422
+ configuration. objectui#3013 recorded them as deliberately uncurated for
423
+ exactly that reason.
424
+
425
+ The declarations mirror what each renderer actually reads:
426
+
427
+ | block | inputs |
428
+ | -------------------------------------- | ---------------------------------------------------------------------------- |
429
+ | `record:activity` | 11 — from `RecordActivityComponentProps` |
430
+ | `record:chatter` / `record:discussion` | 5 — from `RecordChatterComponentProps` |
431
+ | `record:alert` | 8 — severity, title, body, visible, icon, action, dismissible, dismissKey |
432
+ | `record:quick_actions` | 7 — actionNames, requiredPermissions, location, align, inline, variant, size |
433
+ | `record:history` | 3 — limit, emptyText, unknownUserText |
434
+ | `record:reference_rail` | 1 — hideEmpty |
435
+
436
+ `inputs` describe what an AUTHOR writes, which is a subset of what the renderer
437
+ reads. `entries`, `loading` and resolved `actions` are injected by the host
438
+ shell off RecordContext; declaring them would invite a model to hand-write the
439
+ data the page is supposed to fetch. `aria` is omitted for the reason it is
440
+ omitted on `record:details` — an accessibility escape hatch, not a layout
441
+ choice. `location` takes its enum from the spec's `ACTION_LOCATIONS` rather
442
+ than restating it, per objectui#3019.
443
+
444
+ Six of the seven are now in `PUBLIC_BLOCKS`: configurable and absent from the
445
+ contract is the state objectui#3006 was about. The contract goes 36 → 42 tags,
446
+ all resolving.
447
+
448
+ `record:chatter` stays out — it is the same renderer as `record:discussion`
449
+ under a Salesforce-familiar name, kept for schemas already in the wild. Two
450
+ spellings of one block is ambiguity an authoring model cannot resolve, so the
451
+ vocabulary carries the spec's name. A test compares the two input lists, so the
452
+ day they diverge the exclusion stops being justified and fails.
453
+
454
+ A companion assertion requires every curated `record:*` tag to declare inputs.
455
+ A curated tag with none reads as "takes no configuration" when the renderer in
456
+ fact reads props — the same gap objectui#3006 opened, pointed the other way.
457
+
458
+ - 912496d: feat(types,core): the `*Validation` rule types derive from spec 17, and the engine agrees with the server — objectstack#4115
459
+
460
+ The five spec-named rule variants in `data-protocol.ts` were hand-written
461
+ interfaces, each labelled `(ObjectStack Spec v2.0.1)` while the installed spec
462
+ was `17.0.0-rc.0`. Nothing bound them to the spec, so fifteen majors of drift
463
+ accumulated with `tsc` silent throughout and the comment still vouching for it.
464
+ They are now `z.input` derivations of `ScriptValidationSchema` /
465
+ `StateMachineValidationSchema` / `CrossFieldValidationSchema` /
466
+ `ConditionalValidationSchema` / `FormatValidationSchema`, and canonicity is
467
+ carried by that binding plus a parity gate rather than by a comment (#3017).
468
+
469
+ `z.input`, not `z.infer`, because objectui consumes **authored** metadata as it
470
+ arrives over `/meta` — before the spec applies its defaults and canonicalizes
471
+ expressions. That is the shape actually in the JSON.
472
+
473
+ **Breaking, in the shape of the rule types** (minor per this repo's version
474
+ policy — see AGENTS.md §9):
475
+
476
+ | | was | is |
477
+ | --------------------------------- | ------------------------------------------- | ------------------------------------------------------- |
478
+ | `ConditionalValidation` | `condition` + `rules[]` | `when` + `then` / `otherwise` |
479
+ | `FormatValidation` | `pattern` + `flags`, 8 named formats | `regex`, the 4 formats the server implements |
480
+ | `Script`/`CrossField` `condition` | `string` | `string \| { dialect, source }` |
481
+ | `StateMachineValidation` | — | gains `initialStates` (objectstack#3165) |
482
+ | `BaseValidation` | no `priority`, `events` included `'delete'` | gains `priority`; `'delete'` retired (objectstack#3184) |
483
+
484
+ `UniquenessValidation` / `AsyncValidation` / `RangeValidation` are now
485
+ `@deprecated`. They have no spec counterpart — the spec removed the first two
486
+ deliberately (uniqueness → a unique index, since SELECT-then-INSERT is racy;
487
+ async → the form layer) — and the spec's `ValidationRuleSchema` rejects all
488
+ three, so no rule in those shapes can ride in `ObjectSchema.validations`.
489
+
490
+ **`ObjectValidationEngine` now agrees with `objectql`'s rule-validator.** It is a
491
+ client PRE-CHECK of rules the server enforces, so every disagreement cost the
492
+ user something real. Fixed:
493
+
494
+ - **Polarity was inverted.** The server violates a rule when the predicate is
495
+ TRUE; the engine violated it when the predicate was FALSE. Every
496
+ spec-authored `script` / `cross_field` rule produced the opposite verdict.
497
+ - **Envelope conditions were a silent no-op.** `{ dialect, source }` reached
498
+ `expression.trim()`, threw, was caught, and read as "passes".
499
+ - **`conditional` was a silent no-op**, reading `rule.condition` / `rule.rules`
500
+ where the spec says `when` / `then`; `otherwise` was never evaluated at all.
501
+ - **`format` produced FALSE REJECTIONS** — it read `rule.pattern`, and
502
+ `undefined.test(...)` threw into a catch that reported a violation, blocking
503
+ writes the server accepts.
504
+ - **An absent `active` disabled the rule** and an absent `events` threw; both
505
+ arrive absent from `/meta` because the spec defaults them at parse time.
506
+ - `priority` now orders execution; `initialStates` is enforced on insert;
507
+ `format`/`state_machine` only fire when the write touches the field; a broken
508
+ predicate or an uncompilable `regex` fails OPEN with a warning; and a rule type
509
+ the engine cannot evaluate (the spec's `json_schema`) warns instead of
510
+ reporting the record as valid.
511
+
512
+ The default `SimpleExpressionEvaluator` is not CEL and never was; it now binds
513
+ both the spec's `record.x` scope and objectui's historical bare `x`, and
514
+ documents that richer predicates need a CEL-backed evaluator. `validateRecord`'s
515
+ `event` parameter no longer accepts `'delete'`.
516
+
517
+ Gates: `packages/types/src/__tests__/validation-rule-spec-parity.test.ts` (key
518
+ sets, wire shapes, the pinned `then`/`otherwise` divergence with an inverted pin
519
+ that fails when objectstack#4171 is fixed upstream) and the rewritten engine
520
+ suite. objectstack#4115's ledger drops 120 → 115.
521
+
522
+ ### Patch Changes
523
+
524
+ - b41f401: **Authoring types are input types (framework#4074 steps 2–3): `ActionParam` takes the spec's declaration forms, `ListViewSchema` stops promising parse-output defaults, and `FormField.dependsOn` matches its runtime reader.**
525
+
526
+ Three public types said something different from what the platform accepts. All
527
+ three divergences were found by making `packages/types`' tests compile (#3009)
528
+ and then resolving the declared `p1-spec-alignment.test.ts` debt site-by-site
529
+ instead of papering over it.
530
+
531
+ **`ActionParam` is now the authoring shape, aligned with the spec's input.**
532
+ `name` / `label` / `type` become optional and `field` / `objectOverride` appear:
533
+ the spec's primary way to declare a param — a bare field reference that inherits
534
+ label/type/validation/options from an object field — was unrepresentable while
535
+ all three were required. The _resolved_ shape the dialog consumes (after
536
+ app-shell's `resolveActionParams()` inlines the reference) remains
537
+ `@object-ui/core`'s `ActionParamDef`, with all three required. Authoring and
538
+ resolved are different types on purpose. `label` and option labels take the
539
+ spec's `I18nLabel` by import — which the new compile-time guard promptly
540
+ revealed to be aliased to plain `string` in the current spec (the per-locale
541
+ record is the separate `I18nObject`), so this is not a behavioural widening
542
+ today; importing the alias means objectui tracks any future widening
543
+ automatically.
544
+
545
+ **Breaking:** code destructuring `param.name` / `param.label` / `param.type` as
546
+ guaranteed must now handle the field-backed form (or consume the resolved
547
+ `ActionParamDef` instead, which is what dialog-side code should be doing).
548
+
549
+ **`ListViewInferred` is `z.input`, not `z.infer`.** The spec sub-schemas that
550
+ flow into the list-view surface (`userActions`, `tabs` → `ViewTab`, `sharing`)
551
+ carry `.default()`s, so the inferred output type made fields like
552
+ `userActions.refresh` or a tab's `pinned`/`visible` _required_ — but nothing on
553
+ the render path ever runs `.parse()`: `normalizeListViewSchema` deliberately
554
+ applies no defaults ("an absent flag stays absent", its own suite). The output
555
+ type therefore rejected valid authored metadata (`userActions: { sort: true }`)
556
+ while promising renderers defaults that never arrive. Typing the surface as
557
+ input matches both the author and the runtime object. Code that _trusted_ those
558
+ phantom defaults now gets an optionality error — which is a latent bug surfacing,
559
+ not a regression: the value really could be absent.
560
+
561
+ **`FormField.dependsOn` is `DependsOnInput`.** The runtime reader
562
+ (`resolveCascadingOptions`) has always accepted a bare name, a list of names, or
563
+ lookup-parameter entries `{ field, param }` — its parameter type says so. The
564
+ public property said `string`, so array-authored metadata type-errored while
565
+ working, and the form renderer read the key through `(f as any).dependsOn` to
566
+ get past its own type. The shape now lives in `@object-ui/types` (single source
567
+ of truth next to `FormField`), `@object-ui/core` imports and re-exports it, and
568
+ the two `as any` reads in the components form renderer are typed.
569
+
570
+ **The `p1-spec-alignment.test.ts` exclusion is gone.** Its 14 errors resolved:
571
+ the two "sharing in ObjectUI format" tests and the legacy-ARIA-spelling fixture
572
+ are deleted/rewritten — those dialects are _normalizer input_, folded by
573
+ `normalizeListViewSchema` and asserted branch-by-branch in core's
574
+ `normalize-list-view.test.ts`, the seam where the fold actually runs; asserting
575
+ them on the canonical type only ever "passed" because nothing compiled the file.
576
+ One fixture claimed a shape no surface ever admitted (an ObjectQL triplet as a
577
+ spec `ViewTab.filter`) and was corrected to the rule-object form. Every test
578
+ file in `@object-ui/types` is now compiled, with no exclusions.
579
+
580
+ Discrimination-checked: reverting `ListViewInferred` to `z.infer`, `dependsOn`
581
+ to `string`, or `ActionParam.name` to required each produces the expected
582
+ compile error in the now-compiled test files (`TS2739` / `TS2322` / `TS2741`);
583
+ restored, all projects are clean.
584
+
585
+ - 95b7214: fix(list,grid,detail,tree,core): every column resolver reads one key (#3104 PR2)
586
+
587
+ PR1 (#3119) put a canonicalizing fold at ListView's ingestion boundary. This
588
+ converges the 22 read sites themselves onto `columnIdentity()` from
589
+ `@object-ui/core`, so a surface that is NOT downstream of that fold resolves
590
+ the same identity anyway.
591
+
592
+ That distinction is the user-visible part. A standalone `object-grid` node —
593
+ authored directly on a page, with no `list-view` above it — never passed
594
+ through `normalizeListViewSchema`. Its `getSelectFields` read `c.field` alone
595
+ while the `ensureId` probe one line above read `f?.name || f?.field`, so a
596
+ legacy `{ name: 'account' }` column reached `$select` as a literal `undefined`
597
+ hole: the server never returned the field and every cell in that column came
598
+ back empty. Same for `ObjectTree`, `RelatedList` and the `record:details` /
599
+ `record:related_list` renderers.
600
+
601
+ Converged:
602
+
603
+ | Surface | Was | Now |
604
+ | ---------------------------------------- | ---------------------------------------------- | ----------------------------------- |
605
+ | `ListView` ×9 + its 2 request builders | `name \|\| fieldName \|\| field` vs `f?.field` | `columnIdentity()` |
606
+ | `RelatedList` ×8 | `accessorKey \|\| field \|\| name` | `accessorKey \|\| columnIdentity()` |
607
+ | `ObjectGrid` | name-first probe vs `c.field` projection | `columnIdentity()` |
608
+ | `ObjectTree` | `name \|\| fieldName \|\| field \|\| key` | `columnIdentity() \|\| key` |
609
+ | `buildExpandFields` | `field ?? name ?? fieldName` | `columnIdentity()` |
610
+ | `record-details` / `record-related-list` | `field \|\| name (\|\| key)` | `columnIdentity() (\|\| key)` |
611
+
612
+ `accessorKey` keeps its precedence in `RelatedList` — it is TanStack Table's
613
+ column key, not ObjectStack metadata identity, and only the `field || name`
614
+ tail was converged. `key` stays a tail fallback in `ObjectTree` and
615
+ `record-related-list` for the same reason: it is a generic entry key.
616
+
617
+ Two incidental fixes that TypeScript surfaced once the resolver stopped
618
+ returning `any`: ListView's filter-field options and its hide-fields popover
619
+ both built entries keyed `undefined` for a column with no resolvable identity.
620
+ Those entries could never match a column; they are now dropped.
621
+
622
+ **Inventory re-triage.** PR1 recorded 24 family members. Two were mis-classified
623
+ and are reclassified here rather than converged — reading what they actually
624
+ feed shows they are not column reads at all:
625
+
626
+ - `ViewPreview.tsx` adapts a ViewItem **form** section to what `object-form`
627
+ selects by (`field` → `name`) — the #3090 two-layer join.
628
+ - `SchemaForm.tsx` renders an arbitrary metadata **array** into a popover
629
+ summary and guesses at a display key; the entries are validations, actions,
630
+ or whatever the JSON schema declares.
631
+
632
+ So the family was 22, and it is now **0**. The ratchet asserts that, asserts
633
+ each converged surface actually routes through the shared reader (a surface
634
+ that dropped identity resolution instead of converging it goes red), and pins
635
+ `accessorKey`'s precedence in `RelatedList`.
636
+
637
+ - 7d9734d: feat(core): say which column identity key won, out loud (#3104 PR3)
638
+
639
+ Closes the battle opened in #3104. PR1 (#3119) put the canonicalizing fold at
640
+ ingestion; PR2 (#3122) converged all 22 read sites onto `columnIdentity()`.
641
+ This is the audible half.
642
+
643
+ A column carrying two identity keys that **disagree** — `{ field: 'account',
644
+ name: 'account_name' }` — now logs a one-time dev-mode warning naming which key
645
+ won and what to change:
646
+
647
+ ```
648
+ [ObjectUI] Column carries two identities: `field: 'account'` and
649
+ `name: 'account_name'`. `field` wins — it is the only key `ListColumnSchema`
650
+ declares — and `name` has been rewritten to match, so the rendered column and
651
+ the requested field agree. Fix the producer: drop `name` and author `field`
652
+ only. (objectui#3104)
653
+ ```
654
+
655
+ The fold making the two halves agree is what stops the bug, but silently
656
+ rewriting `name` to match `field` also hides that the producer is emitting a
657
+ contradiction. The renderer recovering is not the same as the metadata being
658
+ right, so the recovery says so.
659
+
660
+ Deliberately narrow:
661
+
662
+ - **Only contradictions.** A legacy-only column (`{ name: 'stage' }`) is legacy,
663
+ not conflicting — it is stamped without noise.
664
+ - **Warn once per (identity, conflicting spelling).** Columns are re-normalized
665
+ on every render; a warning that floods the console is a warning that gets
666
+ muted. Keyed by the pair rather than the identity alone, so a column carrying
667
+ two different stale spellings reports both — the author needs to fix every
668
+ producer, not just the first one seen.
669
+ - **Silent under `NODE_ENV=production`**, and the fold still runs there.
670
+
671
+ `resetColumnIdentityWarnings()` is exported for tests.
672
+
673
+ **No lint rule, and that is a measured decision.** #3104 asked for
674
+ `no-restricted-syntax` on `.field ?? .name` to be evaluated on its
675
+ false-positive rate first. With the family at zero, all 12 remaining scanner
676
+ hits are legitimate — a syntactic rule cannot tell a two-layer join from a dual
677
+ read, because the distinction is what the keys mean in that layer, not how the
678
+ expression is spelled. Adopting it would mean 12 inline disables on correct
679
+ code, which trains the next author to reach for the disable. The ratchet carries
680
+ a `verdict` and a `why` per site instead, so a new hit gets triaged rather than
681
+ silenced. The evaluation is written into the ratchet's header.
682
+
683
+ **Ledger item resolved with no change needed.** #3104 flagged `ListColumn` for
684
+ disposition under objectstack#4115 (spec-named symbols must be imports, not
685
+ declarations). `ListColumnSchema` is already a by-reference re-export of
686
+ `@objectstack/spec/ui`, and `spec-subschema-parity.test.ts` already pins it by
687
+ reference identity — the only check that distinguishes a re-export from a
688
+ faithful fork. Already compliant; nothing to do.
689
+
690
+ - aebfa4f: chore(core): deprecate `ObjectValidationEngine` — rule enforcement stays single-implementation on the server (#3110)
691
+
692
+ `ObjectValidationEngine` / `defaultObjectValidationEngine` / `validateRecord` are
693
+ now `@deprecated`. **Nothing is removed and no behaviour changes** — existing
694
+ callers keep working exactly as they did after #3103.
695
+
696
+ **Why.** Object-level validation rules are _enforcement_, and enforcement is
697
+ single-implementation on the server (`objectql`'s rule-validator). objectui
698
+ already draws that line for the predicates it _does_ evaluate client-side:
699
+ `evaluator/fieldRules.ts` handles the presentation predicates (`visibleWhen` /
700
+ `readonlyWhen` / `requiredWhen`) by delegating to the canonical
701
+ `ExpressionEngine`, "rather than re-implementing a parallel evaluator"
702
+ (ADR-0036). This engine was that parallel evaluator, on the enforcement side.
703
+
704
+ #3103 converged its semantics onto the server rule-for-rule, with eight
705
+ mutation-tested gates — and still left a known divergence: the server carries
706
+ ADR-0113's legacy-violation exemption (reject only when the merged state violates
707
+ _and_ this write makes it worse), which this engine does not implement. Editing
708
+ an unrelated field on a legacy row would be blocked here and accepted there. One
709
+ careful pass still left a gap, which is the argument: mirroring cross-repo
710
+ behaviour is structurally unreliable, not unreliable-this-time.
711
+
712
+ **What to use instead.** Let the write fail and render the server's rejection —
713
+ it is already structured (`field` / `code` / `message`, plus a label since
714
+ objectstack#3957). For pre-submit feedback, the answer is a validate-only
715
+ (dry-run) write on the server: identical UX, zero parity risk, and it covers the
716
+ two rule kinds a client can never check — `unique` (needs the database) and
717
+ `json_schema` (ajv lives server-side).
718
+
719
+ **The decision is a mechanism, not a comment.** What #3103 removed was a doc
720
+ comment claiming spec canonicity that had been false for fifteen majors;
721
+ shipping its successor as another comment would repeat the mistake one level up.
722
+ `validation-engine-stays-unwired.test.ts` scans `packages/*/src` and fails if a
723
+ production module starts referencing the engine, naming the file and the issue to
724
+ reverse first. Barrels still re-export it — publishing a deprecated API is not
725
+ wiring — and host applications are free to keep importing it.
726
+
727
+ The five spec-derived rule TYPES in `@object-ui/types` are unaffected: they are
728
+ the anchor for objectstack#4115's ledger and are independent of whether objectui
729
+ ships an engine.
730
+
731
+ - c4d7b20: fix(view,list,core): a view's filter no longer disappears, or arrives as a predicate on columns that don't exist
732
+
733
+ Sweeping the other `$filter` producers after #3078 turned up two live defects in
734
+ `ObjectView`, which fetches its own data for calendar / kanban / gallery /
735
+ timeline (grid delegates to `ObjectGrid`).
736
+
737
+ **1. An object filter was dropped, and only for non-grid views.**
738
+ `table.defaultFilters` is declared `Record<string, any>`, and the merge tested
739
+ `baseFilter.length > 0` — `undefined > 0` for an object. So the filter vanished
740
+ and the view returned **every record**. `ObjectGrid` assigns the same value
741
+ straight to `params.$filter`, so one view definition filtered correctly as a
742
+ grid and returned everything as a calendar.
743
+
744
+ **2. Rule objects were spread into the `and`, not wrapped.**
745
+ `['and', ...baseFilter, ...userFilter]` is only correct when the source is an
746
+ array of AST nodes. `activeView.filter` is a spec `ViewFilterRule[]`, so
747
+ spreading put bare rule objects where the AST expects nodes:
748
+
749
+ ```js
750
+ isFilterAST([
751
+ "and",
752
+ { field: "stage", operator: "eq", value: "won" },
753
+ ["owner", "=", "me"],
754
+ ]);
755
+ // false → 400 since objectstack#4121
756
+ parseFilterAST(same);
757
+ // {$and:[{field:'stage',operator:'eq',value:'won'}, {owner:'me'}]}
758
+ ```
759
+
760
+ That second line is a predicate over three columns named `field`, `operator`
761
+ and `value` — which don't exist.
762
+
763
+ > **Correction.** The first version of this note said the spread was "reachable
764
+ > whenever a view with a filter meets a user filter value". That was wrong for
765
+ > `ObjectView`: the branch required a non-empty user filter, and nothing ever
766
+ > wrote the state it was built from, so it could never run. The shape is
767
+ > genuinely broken — a live server answers it with a 400 — and the adapter-level
768
+ > defence added alongside is still warranted for any producer that emits it, but
769
+ > **this particular site was dead code, not a live defect.** Defect 1 above was
770
+ > live: it sat on the always-taken path. The dead machinery behind the wrong
771
+ > claim is removed in a follow-up.
772
+
773
+ New in `@object-ui/core`: `toFilterNode` normalizes one source (rule array / AST
774
+ / MongoDB object) and `mergeFilterNodes` combines sources as siblings under one
775
+ `and`. `ObjectView` and `ListView.buildEffectiveFilter` both use them, so the
776
+ three filter shapes are reconciled in one place instead of by hand at each
777
+ renderer.
778
+
779
+ `ObjectStackAdapter` also now translates a bare rule object sitting directly
780
+ under a logical node — the chokepoint defence for any producer still emitting
781
+ the spread shape. Only rule-_shaped_ objects are touched; a child with no
782
+ `field` is a genuine MongoDB condition and passes through untouched.
783
+
784
+ **Correcting a comment shipped in #3078.** `buildEffectiveFilter` documented the
785
+ dropped-object case as unreachable, "nothing in this repo produces one for a
786
+ list view". That was wrong: `ObjectView` passes `mergedFilters` straight into
787
+ that schema's `filter`, and its last fallback is `table.defaultFilters`. The
788
+ case is now handled rather than explained away.
789
+
790
+ Verified with 19 tests across the four packages; reverting each source file
791
+ fails the ones that cover it. Emitted filters are asserted against the spec's
792
+ own `isFilterAST` / `parseFilterAST`, including an executable pin on what the
793
+ old spread shape produced.
794
+
795
+ - 6f29aa5: fix(grid): a legacy string row action runs instead of green-toasting a no-op — objectui#2960
796
+
797
+ A list view declaring `rowActions: ['convert_lead']` rendered a menu item that
798
+ performed **zero network requests** and reported success. Where the object also
799
+ declared the same action with `locations: ['list_item']`, the row menu showed a
800
+ working entry and a dead duplicate of it side by side.
801
+
802
+ **The name never became an action.** `ObjectGrid` dispatched the legacy form as
803
+ `{ type: <action name>, params: { record } }` — the action _name_ landing in the
804
+ runner's `type` slot, never resolved against the object's action defs. It
805
+ matches no built-in type and (absent a handler registered under that exact name)
806
+ no handler either, so it fell through to `ActionRunner.executeActionSchema`,
807
+ which returned `{success: true, reload: true, close: true}` for a schema with
808
+ nothing in it. `handlePostExecution` then fired the green "Action completed
809
+ successfully" toast.
810
+
811
+ Two changes, either of which would have surfaced the bug:
812
+
813
+ **① `ObjectGrid` resolves legacy names against `objectDef.actions`.** A name
814
+ that matches a declared action is promoted to that def and dispatched through
815
+ the same path as a `list_item` action — so it actually runs, and it picks up the
816
+ def's label, `visible`/`disabled` predicates, param dialog and capability gate,
817
+ none of which the string form could carry. A name that matches an action already
818
+ rendered as a def is dropped, which is what removes the dead twin. Names that
819
+ resolve to nothing are still dispatched by name, since a consumer may have
820
+ registered a runner handler under exactly that name.
821
+
822
+ **② `ActionRunner`'s empty-schema fallthrough fails loudly.** It no longer
823
+ reports success for an action it never ran: a dispatch with no registered
824
+ handler and no `api`/`endpoint`/`navigate`/`redirect`/`onClick` returns a
825
+ failure naming the action. Schema-only shapes that _do_ declare something — a
826
+ bare `redirect`, an explicit `reload`/`close` — run exactly as before.
827
+
828
+ - ad0183a: fix(data-objectstack,core): an object filter no longer depends on whether the query expands a lookup
829
+
830
+ #3072 single-sourced the ARRAY branch of the adapter's two `find()` routes. The
831
+ object branch was left as it was: `convertQueryParams` converted a MongoDB-style
832
+ filter to AST while `translateFilterToAST` returned it verbatim — so the same
833
+ `$filter` went out in two formats, decided by whether the query happened to
834
+ expand a lookup.
835
+
836
+ Measured across 21 operator shapes, **four diverged**. Most of the gap turned
837
+ out to be harmless — `{$and: […]}` survives the plain route as a
838
+ `['$and','=',[…]]` comparison that `parseFilterAST` reads back as a real `$and`,
839
+ and `$exists` vs `$null` is a difference the server treats identically. Two were
840
+ not harmless:
841
+
842
+ - **The unknown-operator guard only ran on one route.** `convertFiltersToAST`
843
+ throws on an unrecognised operator, with a comment saying it does so "to avoid
844
+ silent failure" — but the expanded route never called it, so a typo'd operator
845
+ threw on a plain read and shipped silently whenever a lookup was expanded.
846
+ - **`$regex` was silently rewritten to `contains`.** `$regex: 'a.c'` matches
847
+ "abc"; `contains 'a.c'` matches only those three literal characters. That is a
848
+ _different question_, not a weaker version of the same one, and neither result
849
+ looks wrong on screen. The rewrite sat behind a `console.warn`, which is not
850
+ an error channel in a deployed app — and the function's own unknown-operator
851
+ message never listed `$regex` among the supported set. The spec has no
852
+ `$regex` (`FILTER_OPERATORS`, `data/filter.zod.ts`), so there is nothing to
853
+ translate it into: it is now refused, the same treatment the neighbouring
854
+ unknown operator already got. Nothing in the repo depended on the conversion.
855
+
856
+ Both refusals now throw `FilterOperatorError`, carrying `code: 'INVALID_FILTER'`
857
+ / `httpStatus: 400`. The pre-existing unknown-operator throw was a bare `Error`,
858
+ which `classifyLoadError` classifies as a network fault — so a malformed filter
859
+ told the user to check their connection (#3066), the one thing it definitely
860
+ was not.
861
+
862
+ - aa1240a: fix(sdui): lazily-registered public blocks reach a `kind:'react'` page's scope, and ReactRunner keeps the errors it catches
863
+
864
+ Two defects in the trusted `kind:'react'` page tier.
865
+
866
+ **objectui#2953 — the contract skipped lazy blocks.** `getPublicConfigs()`
867
+ resolved every curated `PUBLIC_BLOCKS` tag through `getConfig()`, which reads
868
+ loaded registrations only, so a block registered with `registerLazy()` was
869
+ absent from the contract until its plugin chunk happened to be imported. In
870
+ `apps/console` that silently dropped `object-kanban`, `object-calendar`,
871
+ `object-gantt`, `object-timeline`, `object-map` and `markdown` from every react
872
+ page's scope — writing `<ObjectKanban/>` threw `ReferenceError` even though the
873
+ tag is a first-class contract member, and whether it threw depended on load
874
+ order. `getPublicConfigs()` now resolves pending lazy stubs too, returning them
875
+ with `lazy: true` and no `component` (new `PublicComponentConfig` type); the
876
+ injected wrapper renders through `SchemaRenderer`, which triggers the loader and
877
+ shows its placeholder. `getConfig()` stays loaded-only by design.
878
+
879
+ **objectui#2954 — ReactRunner discarded its own error state.**
880
+ `getDerivedStateFromProps` re-transpiled and re-evaluated the page source on
881
+ every render and unconditionally set `error: null`. React runs it before the
882
+ re-render that follows `getDerivedStateFromError`, so the boundary threw away
883
+ the error it had just caught, rebuilt an identical throwing element, and the
884
+ throw escaped past its own `fallback` to the renderer's generic panel; `onError`
885
+ was gated on state that had already been cleared and never fired for a
886
+ compile-time error at all; and each compile minted a fresh page function — a new
887
+ element type — that remounted the subtree and wiped the page's `useState`. The
888
+ transpile+eval is now memoised on `(code, scope)`, errors persist until the
889
+ inputs actually change, and `onError` reports each error exactly once.
890
+
891
+ - d10f526: fix(sdui): the curated contract lists `record:line_items`, the tag that actually resolves
892
+
893
+ `PUBLIC_BLOCKS` carried `line_items` — the bare tag. `@object-ui/plugin-form`
894
+ registers the block as `record:line_items` with `skipFallback: true`, which
895
+ exists precisely so the bare name is _not_ claimed, so that key never existed
896
+ and the curated entry could never resolve. Its four siblings in the list are all
897
+ `record:`-prefixed, and plugin-form's own comment says "Register
898
+ record:line_items"; the bare spelling was a slip.
899
+
900
+ The effect was a block that has shipped all along — a full renderer, a label,
901
+ five declared `inputs` — being absent from the public contract, from the JSX
902
+ type surface, from the generated manifest, and from every `kind:'react'` page's
903
+ scope. It read as an unimplemented aspirational entry, which is how it was
904
+ recorded when objectui#2979 added the contract-coverage guard.
905
+
906
+ With the tag corrected the contract has no gaps left: all 36 curated tags
907
+ resolve in the console, `record:line_items` among them with its full `inputs`.
908
+ The guard's known-unimplemented list is now empty and stays asserted, so the
909
+ next entry that cannot resolve surfaces instead of being explained away.
910
+
911
+ - 2d5d594: fix(list,detail): sorting a lookup column no longer orders by an invisible key — #3096
912
+
913
+ A relational column (`lookup` / `master_detail` / `user` / `tree`) never holds
914
+ the string its cell shows: it holds the `$expand`-ed record, or a raw foreign-key
915
+ id whose label was resolved separately. Every sort path took that raw value as
916
+ its key, so the column of names came back in an order with no relation to the
917
+ names — sorting looked broken, with nothing saying the key was something else.
918
+
919
+ The two halves are fixed differently, because they can order by different things:
920
+
921
+ - **Client-side sorts** (grid column headers, any `data-table`, a non-windowed
922
+ related list) now key off the label the cell renders, via the new
923
+ `getSortValue` / `compareSortValues` in `@object-ui/core` — which resolves an
924
+ expanded record through `getRecordDisplayName` (ADR-0079), so the sort key and
925
+ the lookup cell agree on which field names a record. This replaces two broken
926
+ comparators: `a[col] < b[col]` is always false between two objects (the
927
+ comparator collapsed to a constant and permuted the rows), and
928
+ `String(a[col])` is `"[object Object]"` (every row compared equal, so the sort
929
+ silently did nothing).
930
+ - **Server `$orderby` sorts** cannot be fixed here — the key is the stored id by
931
+ construction, and `objectstack#4256` settled that no relation join is coming.
932
+ So those entry points stop offering the illusion: the ListView toolbar sort
933
+ picker withholds relational fields and explains why (pointing at a formula
934
+ field as the supported way to sort by a related name), and a windowed related
935
+ list renders no sort button for them.
936
+
937
+ A relational field the view's CURRENT sort already uses stays listed, labelled
938
+ `(by ID)`, so view metadata authored or saved with such a sort round-trips
939
+ instead of rendering a blank row and losing the sort on the next edit.
940
+
941
+ - 7f23cd0: fix(form): a numeric/boolean select option survives selection with its type intact — #3090
942
+
943
+ `SelectOptionSchema.value` has accepted `string | number | boolean` for as
944
+ long as it has existed, but the Radix controls underneath speak strings:
945
+ picking `{ value: 2 }` silently submitted `"2"` — a wrong-typed write into a
946
+ number field that nothing on the client ever reported. (Display half-worked:
947
+ a numeric default matched its numeric item; only SELECTION morphed the type.)
948
+
949
+ The renderers now stringify on the way into the control and map the selection
950
+ back to the AUTHORED option value on the way out (`matchOptionValue`), across
951
+ the in-form select, the standalone `type: 'select'` component, and the
952
+ standalone `type: 'radio-group'` component. The TS types stop lying to match:
953
+ `SelectOption.value` / `RadioOption.value` and the corresponding
954
+ `value`/`defaultValue`/`onChange` channels widen to what the zod schemas
955
+ always accepted — a call site treating `option.value` as `string` is now a
956
+ compile error pointing at a real latent crash, not a false comfort.
957
+
958
+ The ripple the widening named, handled at each boundary: `@object-ui/core`'s
959
+ `OptionLike.value` widens (the option engines compare by identity, so values
960
+ flow opaquely; the option-lint's CEL-literal domain stringifies at its
961
+ boundary), and the multi-value field widgets (checkboxes / multiselect /
962
+ radio) stringify at theirs — multi-value fields store string arrays.
963
+
964
+ Round-trip pinned by real Radix interactions in jsdom: the in-form select
965
+ submits `2` (number), the standalone select hands its handler `false`
966
+ (boolean).
967
+
968
+ - Updated dependencies [9e7349e]
969
+ - Updated dependencies [8864971]
970
+ - Updated dependencies [b41f401]
971
+ - Updated dependencies [19e9fa0]
972
+ - Updated dependencies [38ca8be]
973
+ - Updated dependencies [4952edf]
974
+ - Updated dependencies [7f0252e]
975
+ - Updated dependencies [7639a61]
976
+ - Updated dependencies [94e63ef]
977
+ - Updated dependencies [c4db402]
978
+ - Updated dependencies [5319bf1]
979
+ - Updated dependencies [49e5671]
980
+ - Updated dependencies [b5b97e2]
981
+ - Updated dependencies [f59f2c1]
982
+ - Updated dependencies [4874117]
983
+ - Updated dependencies [ce08d55]
984
+ - Updated dependencies [2374a49]
985
+ - Updated dependencies [ea7f477]
986
+ - Updated dependencies [7f23cd0]
987
+ - Updated dependencies [24e0e0a]
988
+ - Updated dependencies [3a6cf24]
989
+ - Updated dependencies [aa35561]
990
+ - Updated dependencies [03bd53b]
991
+ - Updated dependencies [3c1f321]
992
+ - Updated dependencies [a045a32]
993
+ - Updated dependencies [912496d]
994
+ - Updated dependencies [9867281]
995
+ - @object-ui/types@17.1.0
996
+
997
+ ## 17.0.0
998
+
999
+ ### Minor Changes
1000
+
1001
+ - f9bbddb: feat: gate detail/form edit & delete on the server's effective operation set (#3546)
1002
+
1003
+ PR-4 (#3391) wired the **list/toolbar** surface (ObjectView Import, ListView /
1004
+ ObjectGrid Export) to the server-resolved effective API operation set
1005
+ (`/me/permissions` `apiOperations`, intersected via
1006
+ `resolveCrudAffordances(obj, effectiveApiOperations?)`). The **detail / form**
1007
+ surfaces still gated edit/delete on the bucket + `userActions` alone. This
1008
+ extends the same intersection to them, so the record page and its forms never
1009
+ offer an operation the server would 405.
1010
+
1011
+ - **core** `isObjectInlineEditable(obj, effectiveApiOperations?)` gains the same
1012
+ optional second argument as `resolveCrudAffordances` — inline-edit is now
1013
+ additionally ANDed with the server allowing `update`.
1014
+ - **app-shell** `RecordDetailView` threads the object's effective operations into
1015
+ the synthesized Edit/Delete header actions and the record-body inline-edit
1016
+ gate (`canEdit`); `RelatedRecordActionsBridge` intersects each **child**
1017
+ object's Create/Edit/Delete handlers with that child's own effective set.
1018
+ - **plugin-detail** `record:details` ANDs its inline-edit affordance with the
1019
+ object's effective `update`.
1020
+ - **plugin-form** `ObjectForm`'s blanket managed-object field lock also engages
1021
+ when the server denies `update` (edit mode) / `create` (create mode).
1022
+
1023
+ Backward-compatible: a missing effective set (unrestricted object, older
1024
+ backend, or no `PermissionProvider`) leaves the resolved affordance untouched —
1025
+ the bucket/`userActions` decision wins, exactly as today. Layers on top of the
1026
+ existing per-object `check('edit')` / `check('delete')` permission gates
1027
+ (intersection, never union).
1028
+
1029
+ - 2735de6: feat: render the server's effective API operation set (#3391 PR-4)
1030
+
1031
+ The frontend now consumes the per-object **effective API operation set** the
1032
+ server resolves (from `/me/permissions` `apiOperations`, framework #3391) —
1033
+ never the raw `apiMethods` — so Import/Export/New/Edit/Delete buttons match what
1034
+ the server will actually admit, and a 405 import refusal shows a dedicated
1035
+ message instead of silently falling back.
1036
+
1037
+ - **core** `resolveCrudAffordances(obj, effectiveApiOperations?)` — new optional
1038
+ second argument intersects each affordance bit with its API operation
1039
+ (create/import→create/import, edit→update, delete→delete, exportCsv→export).
1040
+ Omitting it (old backend / no effective set) leaves affordances unchanged.
1041
+ - **permissions** — `/me/permissions` response carries per-object
1042
+ `apiOperations`; `PermissionContextValue.getObjectApiOperations(object)`
1043
+ exposes it (undefined when absent → callers keep current behavior); `check()`
1044
+ maps `import→allowCreate`, `export→allowRead`.
1045
+ - **app-shell** `ObjectView` intersects its toolbar affordances with the object's
1046
+ effective operations (Import); the platform-admin identity-import bypass is
1047
+ unaffected.
1048
+ - **plugin-list** `ListView` / **plugin-grid** `ObjectGrid` gate the Export
1049
+ button (and export handler) on effective `export`; `plugin-grid` gains the
1050
+ `@object-ui/permissions` workspace dependency.
1051
+ - **plugin-grid** `ImportWizard` — a 405 / `OBJECT_API_METHOD_NOT_ALLOWED`
1052
+ import refusal is detected by a new `isImportNotAllowed` predicate at every
1053
+ catch site (async, sync, dry-run) and STOPS with a dedicated
1054
+ `grid.import.notAllowed` message (10 locales + fallback dict) — it never falls
1055
+ back to the sync/legacy path (which 405s too), distinct from the 404
1056
+ route-absent fallback.
1057
+
1058
+ Backward-compatible: a missing effective set (unrestricted object, older
1059
+ backend, or no permission provider) preserves the current default-allow
1060
+ behavior everywhere.
1061
+
1062
+ - cd09a7b: refactor(views): ListView reads the spec-canonical `columns`, with legacy `fields` folded in one normalizer (#2890 scope A step 1)
1063
+
1064
+ `ListViewSchema` has been derived from `@objectstack/spec/ui` since #2231, but
1065
+ the renderer still spoke objectui's own vocabulary for the same concepts. First
1066
+ rename closed: **`fields` → `columns`**.
1067
+
1068
+ Legacy acceptance does not disappear — stored view metadata in user databases
1069
+ carries `fields` — but it now lives in exactly one place instead of being
1070
+ re-implemented per read-site:
1071
+
1072
+ - **New `normalizeListViewSchema` (`@object-ui/core`)** folds `fields` into
1073
+ `columns` (canonical wins when both are present) and drops the legacy key, so
1074
+ a read-site that was missed fails loudly instead of quietly taking the legacy
1075
+ path. It also absorbs the `viewType` renderability default ListView applied
1076
+ inline. Non-mutating, idempotent, and returns its input by reference when
1077
+ there is nothing to fold, so ListView's downstream memos keep a stable
1078
+ dependency identity.
1079
+ - **`ListView` normalizes once at the component boundary**, before anything
1080
+ reads the schema. This is what guarantees the fold runs: nothing on the render
1081
+ path parses view metadata through zod (the zod schemas serve the CLI
1082
+ validator, the VS Code extension and tests), so a `z.preprocess` on
1083
+ `ListViewSchema` — spec-side or local — would never execute.
1084
+ - **Producers emit `columns`**: `ObjectView`'s `renderListView` payload,
1085
+ `ObjectDataPage`, `InterfaceListPage` and the `list-view` registry defaults
1086
+ had been _downgrading_ already-canonical `columns` config back to `fields`.
1087
+
1088
+ Two latent inconsistencies go away with it: the filter builder's
1089
+ objectDef-not-loaded fallback now resolves `ListColumn.field` (it read only
1090
+ `name`/`fieldName`, so object-form columns produced unnamed filter entries), and
1091
+ the column list no longer depends on which of the two keys a host happened to
1092
+ emit.
1093
+
1094
+ `fields` stays declared on `ListViewSchema` and in the drift guard's sanctioned
1095
+ set — it is still valid input, and `@objectstack/spec`'s `react-blocks.ts`
1096
+ sanctions it as the React-tier `<ListView fields>` prop — but it is input-only.
1097
+
1098
+ - f1abf0e: fix(views): ListView reads the spec-canonical `filter`, so a view's base filter reaches every visualization (#2890 scope A step 4)
1099
+
1100
+ Third rename in the ListView vocabulary migration: **`filters` → `filter`**. Unlike
1101
+ the first two this closes a live bug, because the fork was asymmetric.
1102
+
1103
+ `ListView` was the **only** surface in the repo reading `filters`. Every child
1104
+ view — `ObjectGrid`, `ObjectGallery`, `ObjectKanban`, `ObjectCalendar`,
1105
+ `ObjectGantt`, `ObjectMap`, `ObjectTree`, `ObjectChart` — reads `filter`, and
1106
+ `ListView` handed them `filters`. Wherever a child fetches its own rows instead
1107
+ of receiving `ListView`'s, the view's base filter was silently dropped:
1108
+
1109
+ - **a `chart` list view aggregated the whole object.** The chart branch built an
1110
+ `object-chart` node with `filters:`; `ObjectChart` reads `schema.filter` and
1111
+ never read `filters`, so a chart view with a base filter charted unfiltered
1112
+ totals.
1113
+ - the same applied to any of the other view components rendered standalone from
1114
+ a list-view-shaped config.
1115
+
1116
+ Conversely, a **spec-authored** list view — one carrying `filter`, which is what
1117
+ the spec says and what `runtime-metadata-persistence` and "Save as view" already
1118
+ persist — rendered **unfiltered** in `ListView`, because nothing read that key.
1119
+
1120
+ The fold is a key rename only. Both keys carry an ObjectQL FilterNode array
1121
+ everywhere in objectui; every consumer passes the value straight to `$filter`.
1122
+ (The spec types `filter` as `ViewFilterRule[]` — `{field, operator, value}`
1123
+ objects — so objectui's field is typed from the spec but used as something else.
1124
+ That mismatch is real and left alone here: converting formats inside a
1125
+ vocabulary fold would change what reaches the data source.)
1126
+
1127
+ Also collapses a duplicated computation in `app-shell`'s `ObjectView`, which
1128
+ computed the same effective filter **twice** — once as `filter` for the child
1129
+ views, once as `filters` for `ListView` — with the two copies subtly different
1130
+ (only one fell back to `listSchema.filter`; only the other ran token
1131
+ substitution over the URL filters). There is now one computation, keeping both
1132
+ behaviors.
1133
+
1134
+ `filters` stays declared on `ListViewSchema` and in the drift guard's sanctioned
1135
+ set — stored views carry it and it is still valid input — but it is input-only.
1136
+
1137
+ - f05b84e: refactor(views): ListView resolves density from the spec-canonical `rowHeight` (#2890 scope A step 2)
1138
+
1139
+ Second rename in the ListView vocabulary migration: **`densityMode` → `rowHeight`**,
1140
+ folded in the same `normalizeListViewSchema` that step 1 introduced.
1141
+
1142
+ Unlike `fields`/`columns` this is not a pure alias — the two vocabularies are
1143
+ different sizes. The spec has five row heights (`compact`/`short`/`medium`/
1144
+ `tall`/`extra_tall`); ListView's toolbar offers three densities
1145
+ (`compact`/`comfortable`/`spacious`). Both directions now live in one place as
1146
+ `DENSITY_MODE_TO_ROW_HEIGHT` / `ROW_HEIGHT_TO_DENSITY_MODE`, chosen so a fold
1147
+ followed by a read is a round trip (`spacious` → `tall` → `spacious`), with the
1148
+ narrowing collapse (`short` → `compact`, `extra_tall` → `spacious`) stated once
1149
+ instead of being re-derived per call site.
1150
+
1151
+ Two behavior fixes fall out of it:
1152
+
1153
+ - **Precedence is no longer inverted.** `ListView` read `densityMode` _first_, so
1154
+ a view carrying both keys rendered the legacy value — backwards from every
1155
+ other legacy/canonical pair in the schema. The canonical key now wins.
1156
+ - **The toolbar stops re-seeding the legacy key.** `ObjectView`'s
1157
+ `onDensityChange` persisted `densityMode` into stored view metadata on every
1158
+ density toggle, so the legacy vocabulary kept regrowing underneath the
1159
+ migration. It persists `rowHeight` now.
1160
+
1161
+ `densityMode` stays declared on `ListViewSchema` and in the drift guard's
1162
+ sanctioned set — stored views carry it and it is still valid input — but it is
1163
+ input-only.
1164
+
1165
+ ### Patch Changes
1166
+
1167
+ - 7b35e4b: fix(dashboard,charts): resolve `{current_user_id}` in widget filters (framework #3574)
1168
+
1169
+ A dashboard widget filtered on `{current_user_id}` rendered `0`. The token
1170
+ reached SQL as a literal, matched no row, and nothing was logged on the client
1171
+ or the server — a silent zero that reads as "you have no work" rather than
1172
+ "this filter did not resolve". The same token in a list-view filter resolved
1173
+ correctly, so a user-scoped list and a user-scoped widget over the same data
1174
+ disagreed.
1175
+
1176
+ There was no shared resolver. Three ad-hoc implementations had grown up
1177
+ independently — `ObjectView` for list views, `ObjectDataPage` for URL filter
1178
+ triples, `NavigationRenderer` for hrefs — and each understood only the filter
1179
+ shape its own surface used. `ObjectView`'s opened with
1180
+ `if (!Array.isArray(filter)) return filter`, so it could not have been reused
1181
+ by dashboard widgets even in principle: widget filters are MongoDB-style
1182
+ objects. Widgets therefore got no resolution at all — `DatasetWidget` called
1183
+ `resolveDateMacros` and nothing else, which is why `{today}` worked in a widget
1184
+ and `{current_user_id}` silently did not.
1185
+
1186
+ - **`@object-ui/core`** — new `utils/filter-tokens.ts` with
1187
+ `resolveContextTokens` and `resolveFilterPlaceholders`. The latter expands
1188
+ _every_ placeholder vocabulary in one call and is what surfaces should use;
1189
+ resolving only some of them is the whole defect. The walk handles arrays and
1190
+ plain objects uniformly, so one resolver covers both platform filter shapes.
1191
+ - **`@object-ui/react`** — new `FilterScopeProvider` / `useFilterScope`. The
1192
+ renderer packages deliberately do not depend on `@object-ui/auth`, so the
1193
+ shell supplies the session values. This is a separate context from
1194
+ `PredicateScopeContext`, which is the expression evaluation scope and carries
1195
+ no organization.
1196
+ - **`@object-ui/plugin-dashboard` / `@object-ui/plugin-charts`** — all six
1197
+ widgets that previously resolved date macros only now resolve both
1198
+ vocabularies: `DatasetWidget`, `ObjectMetricWidget`, `ObjectDataTable`,
1199
+ `ObjectPivotTable`, and `ObjectChart` (dataset-bound and inline paths). The
1200
+ chart's `compareTo` comparison filter gets the session pass too — otherwise
1201
+ the overlay series silently ignored the owner clause the primary series
1202
+ honoured.
1203
+ - **`@object-ui/app-shell`** — `ObjectView`'s local `substituteFilterTokens`
1204
+ and `ObjectDataPage`'s inline `=== '{current_user_id}'` ternary now delegate
1205
+ to the shared resolver, so both also gain `{current_org_id}` and date macros.
1206
+ Two of the three ad-hoc implementations are gone rather than joined by a
1207
+ fourth.
1208
+
1209
+ An unresolvable token is left intact rather than dropped: leaving it yields an
1210
+ empty result, whereas dropping the clause would _widen_ the result set and show
1211
+ a signed-out viewer everyone's data. It is no longer silent — the resolver
1212
+ warns, naming the token, and suggests the intended spelling for known
1213
+ near-misses (`{current_user}`, `{user_id}`, `{organization_id}`). Authoring-time
1214
+ enforcement lands separately as `filter-token-unknown` in `@objectstack/lint`.
1215
+
1216
+ - e16ed2d: fix(dashboard,charts): send widget `dateGranularity`/`sortBy`/`limit` to the query, and give funnels a real stage order (framework#3588)
1217
+
1218
+ `DatasetWidget` never read `widget.options`. Four keys an author writes there
1219
+ change the query the server compiles, so a widget declaring
1220
+ `options: { dateGranularity: 'month' }` grouped by the raw timestamp and drew
1221
+ one bar per record, and `sortBy`/`sortOrder` produced no ordering at all.
1222
+
1223
+ - `DatasetWidget` lowers `options.dateGranularity`, `options.sortBy` +
1224
+ `options.sortOrder`, and `options.limit` into the `DatasetSelection` it posts.
1225
+ A `sortBy` naming something the widget does not project is dropped rather than
1226
+ sent, so a stale sort key left by an edit degrades to "unordered" instead of
1227
+ failing the widget against the server's stricter validation. These keys also
1228
+ join the refetch signature, so editing one in the designer refetches instead
1229
+ of re-rendering the previous grid.
1230
+ - Funnel stages follow a **declared order**. `AdvancedChartImpl` sorted funnel
1231
+ segments by value descending, unconditionally — which overrode any server
1232
+ ordering and rendered a sales pipeline as a tidy narrowing shape whatever the
1233
+ stages' real sequence, hiding the very anomaly (a bulge at Proposal) the chart
1234
+ exists to show. It now honours a `categoryOrder`, which `DatasetWidget`
1235
+ derives from the dimension field's picklist option order — the pipeline order
1236
+ an author already declared on the object — or from an explicit
1237
+ `options.stageOrder`. With no declared order the value-descending default is
1238
+ unchanged, and a category missing from the order is kept (after the declared
1239
+ ones), never dropped.
1240
+ - New `@object-ui/core` helpers `buildCategoryOrder` / `buildCategoryRank`,
1241
+ keyed by both the stored value and the display label like the existing
1242
+ `buildOptionColorMap`, so ordering works whether or not the server resolved
1243
+ the dimension's labels.
1244
+
1245
+ Requires the framework-side fix in objectstack#3588 for the selection keys to
1246
+ take effect server-side.
1247
+
1248
+ - 53642d4: fix(core,fields): a string `$orderby` is a clause, not a character array — and localize the sharing-rule widgets (objectstack#3821)
1249
+
1250
+ **The recipient picker listed nothing, ever.** `QueryParams['$orderby']` was
1251
+ typed as `Record | string[] | SortObject[]`, so `queryParamsToRecord` sent any
1252
+ non-array value through `Object.entries`. Handed the clause string `'name asc'`
1253
+ — which callers do build by hand — it walked the string index by index and
1254
+ emitted `$orderby=0 n,1 a,2 m,3 e,4 ,5 a,6 s,7 c`. The server sorted by columns
1255
+ that don't exist and every row was filtered out, so
1256
+ `sys_sharing_rule.recipient_id` rendered "No matches" for every recipient type
1257
+ and no sharing rule could be created from the Console. `ObjectGrid` builds the
1258
+ same shape from a schema-level `sort` in three places, so grids with a string
1259
+ sort silently showed an empty table.
1260
+
1261
+ A string `$orderby` is now passed through verbatim (the server's OData
1262
+ normalizer has always parsed `'name asc'`), and the type admits `string`.
1263
+ `RecipientPickerField` additionally switched to the structured
1264
+ `{ name: 'asc' }` form so it can't regress this way against any data source.
1265
+
1266
+ **The three sharing-rule authoring widgets never had translations.**
1267
+ `ObjectRefField`, `RecipientPickerField` and `FilterConditionField` hardcoded
1268
+ their English copy — a Chinese Console showed "Select an object", "Select a
1269
+ user", "Search…", "No matches", "Edit as JSON". They now go through
1270
+ `useFieldTranslation` like every other widget, with keys added under `fields.*`
1271
+ in all ten locales.
1272
+
1273
+ The recipient placeholder was the interesting one: it read
1274
+ `` `Select a ${recipientType.replace(/_/g,' ')}` ``, interpolating the enum
1275
+ value into an English sentence — a shape no locale can translate. It is now a
1276
+ per-type key (`fields.recipient.selectUser`, `…selectBusinessUnit`, …), so
1277
+ "选择业务单元" and "Select a business unit" no longer have to share a structure.
1278
+
1279
+ **Editing a rule silently dropped its recipient.** The picker resets the stored
1280
+ id when `recipient_type` changes, because an id valid for a user is meaningless
1281
+ for a team. It treated the edit form's `'' → 'user'` hydration as such a change:
1282
+ opening any saved rule blanked the recipient, and saving persisted the blank.
1283
+ Only a non-empty predecessor now counts as a type switch.
1284
+
1285
+ **Building a filter submitted the surrounding form.** None of `FilterBuilder`'s
1286
+ controls declared `type="button"`, and a bare `<button>` inside a `<form>`
1287
+ defaults to `type="submit"`. Adding, removing or clearing a condition therefore
1288
+ submitted the sharing-rule dialog — firing validation mid-edit, and on an
1289
+ already-valid form saving the record before the admin was done.
1290
+
1291
+ **A rejected write showed the user raw server diagnostics.** The form rendered
1292
+ `error.message` verbatim, so a sharing / RLS denial reached the dialog and the
1293
+ toast as `FORBIDDEN: insufficient privileges to update showcase_private_note
1294
+ pi-TgoJ4_DM55Fqz` — untranslated, and leaking the object's machine name and the
1295
+ record id to whoever hit it. Permission failures now render localized copy
1296
+ (`form.noPermissionToSave`, added in all ten locales), with the server text kept
1297
+ on the console for debugging; other failures still show the server's message,
1298
+ which is the useful part, and fall back to `form.submitFailed` when there is
1299
+ none — replacing the previously hardcoded English "An error occurred during
1300
+ submission".
1301
+
1302
+ **The detail header offered "Edit" on records the user may only read.** Object
1303
+ permissions can't express "this one record is read-only" — a read-only sharing
1304
+ grant sits inside an object the user may otherwise edit — so the header showed
1305
+ the primary Edit CTA, opened the form, and let the user retype a field before
1306
+ the server rejected the save. `DetailView` now gates Edit / Delete on the
1307
+ object-level check AND on the explain engine's record-grained verdict
1308
+ (`POST /api/v1/security/explain` with a `recordId`, ADR-0090 D6 / ADR-0095 C2 —
1309
+ the same pipeline the enforcement middleware runs, so button and server cannot
1310
+ disagree). Explaining oneself needs no special permission. The probe is one
1311
+ cached request per record, skipped entirely when the object-level check already
1312
+ says no, and **fails open** on every uncertainty — an unanswered hint must never
1313
+ be the reason a permitted user cannot act; the server stays the authority
1314
+ (ADR-0057 D10).
1315
+
1316
+ **A long option rendered straight past the combobox border.** `Combobox`'s
1317
+ trigger pinned itself to the component's `w-[200px]` default while the fields
1318
+ around it ran the full form column, and the selected label was a bare text child
1319
+ of a flex button — flex items need `truncate` AND `min-w-0` to clip, and it had
1320
+ neither. So "成员 (showcase_project_membership)" in the object picker overflowed
1321
+ the control and collided with the field beside it. The label now truncates, the
1322
+ trigger can shrink, the dropdown matches the trigger's width instead of a
1323
+ hardcoded 200px (a widened combobox used to clip its own options), and the two
1324
+ sharing-rule pickers ask for `w-full` so they line up with every other input.
1325
+
1326
+ Hardens `evaluatePermission` while there: a role config carrying only
1327
+ `fieldPermissions` (no `actions`) made `check()` throw a TypeError that
1328
+ propagated out of the render. A permission check must not be able to crash a
1329
+ view.
1330
+
1331
+ Browser-verified against the framework showcase Console in Chinese: object /
1332
+ criteria / recipient copy is fully localized, the recipient dropdown lists real
1333
+ users, business units and positions, a saved rule reopens with its recipient and
1334
+ criteria intact, editing the filter no longer submits, and a rule created
1335
+ end-to-end stores a real record id rather than free text. The criteria authored
1336
+ in the builder is honored by the evaluator: `{"pinned":true}` on an owner-private
1337
+ object granted the recipient exactly the matching records and nothing else.
1338
+
1339
+ - Updated dependencies [1767124]
1340
+ - Updated dependencies [8ecf5a6]
1341
+ - Updated dependencies [dfd3705]
1342
+ - Updated dependencies [6dee2cb]
1343
+ - Updated dependencies [c7cff19]
1344
+ - Updated dependencies [cd09a7b]
1345
+ - Updated dependencies [f1abf0e]
1346
+ - Updated dependencies [f05b84e]
1347
+ - Updated dependencies [662bdf9]
1348
+ - Updated dependencies [059a052]
1349
+ - Updated dependencies [53642d4]
1350
+ - Updated dependencies [8aae006]
1351
+ - Updated dependencies [d147a13]
1352
+ - @object-ui/types@17.0.0
1353
+
3
1354
  ## 16.1.0
4
1355
 
5
1356
  ### Minor Changes
@@ -59,7 +1410,7 @@
59
1410
 
60
1411
  Together with the `^15.1.1` alignment (#2662), a renderer CEL predicate now
61
1412
  reaches the identical verdict as the server — including the framework's
62
- `dateField == today()` equality fix (objectstack-ai/framework#3205) once it
1413
+ `dateField == today()` equality fix (objectstack-ai/objectstack#3205) once it
63
1414
  lands in a published 15.x. The broader home-grown-vs-canonical divergence
64
1415
  motivation is #2661.
65
1416
 
@@ -148,7 +1499,7 @@
148
1499
  and removes both bounds together. A new `drillUrlFilters` module owns the
149
1500
  write/read serialization so both sides can't drift (round-trip tested).
150
1501
 
151
- Companion to the framework analytics change (objectstack-ai/framework#3256).
1502
+ Companion to the framework analytics change (objectstack-ai/objectstack#3256).
152
1503
 
153
1504
  ### Patch Changes
154
1505
 
@@ -163,7 +1514,7 @@
163
1514
  (`visibleWhen`/`readonlyWhen`/`requiredWhen` via `fieldRules.ts`, which delegates
164
1515
  to `@objectstack/formula`'s `ExpressionEngine`) now tracks the 15.x engine — and
165
1516
  will pick up the framework's `dateField == today()` equality fix
166
- (objectstack-ai/framework#3205) automatically at the next 15.x release via the
1517
+ (objectstack-ai/objectstack#3205) automatically at the next 15.x release via the
167
1518
  caret range. Renderer/action `visible`/`disabled` predicates are unaffected (they
168
1519
  use the home-grown JS evaluator — tracked separately in #2661).
169
1520
 
@@ -736,7 +2087,7 @@
736
2087
  (plugin-charts) and `DatasetWidget` (plugin-dashboard) both use it, so
737
2088
  multi-dimension charts render consistently as grouped/coloured bars.
738
2089
 
739
- Refs objectstack-ai/objectui#1759, objectstack-ai/framework#1890
2090
+ Refs objectstack-ai/objectui#1759, objectstack-ai/objectstack#1890
740
2091
 
741
2092
  - 7c239fd: Add `ComponentRegistry.unregister(type, namespace?)` — the inverse of
742
2093
  `register()`. Clears the namespaced key and the bare-name fallback (when it