@object-ui/core 17.1.0 → 17.3.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 (66) hide show
  1. package/CHANGELOG.md +738 -0
  2. package/README.md +29 -0
  3. package/dist/actions/ActionEngine.d.ts +22 -7
  4. package/dist/actions/ActionEngine.js +40 -22
  5. package/dist/actions/ActionRunner.d.ts +133 -13
  6. package/dist/actions/ActionRunner.js +27 -4
  7. package/dist/actions/actionErrorDetail.d.ts +26 -0
  8. package/dist/actions/actionErrorDetail.js +37 -0
  9. package/dist/actions/actionKeys.d.ts +29 -12
  10. package/dist/actions/actionKeys.js +61 -10
  11. package/dist/actions/actionResponse.d.ts +42 -0
  12. package/dist/actions/actionResponse.js +104 -0
  13. package/dist/actions/index.d.ts +3 -0
  14. package/dist/actions/index.js +3 -0
  15. package/dist/actions/serverActionHandler.d.ts +143 -0
  16. package/dist/actions/serverActionHandler.js +137 -0
  17. package/dist/evaluator/ExpressionEvaluator.js +4 -2
  18. package/dist/evaluator/fieldRules.d.ts +26 -2
  19. package/dist/evaluator/fieldRules.js +63 -8
  20. package/dist/evaluator/index.d.ts +1 -0
  21. package/dist/evaluator/index.js +1 -0
  22. package/dist/evaluator/listConditional.d.ts +13 -1
  23. package/dist/evaluator/listConditional.js +0 -0
  24. package/dist/evaluator/optionRules.js +5 -1
  25. package/dist/evaluator/predicateInput.d.ts +84 -0
  26. package/dist/evaluator/predicateInput.js +72 -0
  27. package/dist/index.d.ts +3 -0
  28. package/dist/index.js +12 -0
  29. package/dist/protocols/index.d.ts +0 -1
  30. package/dist/protocols/index.js +8 -1
  31. package/dist/query/query-ast.d.ts +6 -6
  32. package/dist/query/query-ast.js +2 -2
  33. package/dist/registry/PluginSystem.d.ts +15 -5
  34. package/dist/registry/PluginSystem.js +1 -1
  35. package/dist/registry/WidgetRegistry.d.ts +7 -7
  36. package/dist/registry/WidgetRegistry.js +1 -1
  37. package/dist/styling/scoped-styles.d.ts +14 -8
  38. package/dist/utils/chart-series.d.ts +14 -2
  39. package/dist/utils/dashboard-filters.d.ts +9 -0
  40. package/dist/utils/dashboard-filters.js +99 -4
  41. package/dist/utils/dataset-format.d.ts +18 -1
  42. package/dist/utils/dataset-format.js +10 -2
  43. package/dist/utils/dataset-pivot.d.ts +57 -0
  44. package/dist/utils/dataset-pivot.js +57 -0
  45. package/dist/utils/filter-converter.d.ts +39 -11
  46. package/dist/utils/filter-converter.js +87 -7
  47. package/dist/utils/filter-tokens.d.ts +13 -6
  48. package/dist/utils/filter-tokens.js +13 -6
  49. package/dist/utils/freeze-schema.d.ts +14 -6
  50. package/dist/utils/freeze-schema.js +14 -6
  51. package/dist/utils/managedBy.d.ts +78 -50
  52. package/dist/utils/managedBy.js +75 -52
  53. package/dist/utils/normalize-list-view.d.ts +12 -2
  54. package/dist/utils/predicate-fields.d.ts +96 -0
  55. package/dist/utils/predicate-fields.js +163 -0
  56. package/dist/utils/predicate-record.d.ts +32 -0
  57. package/dist/utils/predicate-record.js +120 -0
  58. package/dist/utils/record-title.js +0 -0
  59. package/dist/validation/index.d.ts +1 -0
  60. package/dist/validation/index.js +1 -0
  61. package/dist/validation/required-presence.d.ts +31 -0
  62. package/dist/validation/required-presence.js +36 -0
  63. package/dist/validation/schema-validator.d.ts +24 -8
  64. package/package.json +4 -4
  65. package/dist/protocols/NotificationProtocol.d.ts +0 -71
  66. package/dist/protocols/NotificationProtocol.js +0 -89
package/CHANGELOG.md CHANGED
@@ -1,5 +1,743 @@
1
1
  # @object-ui/core
2
2
 
3
+ ## 17.3.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 5781fb1: `@object-ui/core` now ships the server-action dispatcher factory —
8
+ `createServerActionHandler({ fetch, baseUrl, resolveObject, ... })` — so any
9
+ consumer of the runner (standalone renderers, SDUI hosts, embedded usage) can
10
+ run `action.body` script actions by registering the produced handler, instead
11
+ of dead-ending on the built-in `executeScript`'s "must be executed server-side"
12
+ error with no supported way to make it run (objectui#2904, the follow-up
13
+ objectui#2896 deferred).
14
+
15
+ The factory is deliberately opinion-free about the three things core has no
16
+ business deciding — auth (`fetch` is an injected authenticated wrapper), origin
17
+ (`baseUrl` string or thunk; no bundler env convention), and fallback object
18
+ scope (`resolveObject`) — and owns everything protocol-shaped, once:
19
+
20
+ - name-based action identity (ADR-0110 D1 — `target` is a binding expression,
21
+ never an identity);
22
+ - the record-id resolution dance, also exported as
23
+ `resolveServerActionRecordId` (`_rowRecord`, `recordIdField`, toolbar
24
+ selection fallback with its single/zero-select guards, aggregate
25
+ `_selectedIds` bypass), replaceable wholesale via `resolveRecordId` for
26
+ hosts with their own policy (record pages);
27
+ - a re-entrancy guard per action+record;
28
+ - the `/actions` response-envelope rule: `interpretActionResponse`,
29
+ `readActionPayload` and `actionErrorDetail` moved from `@object-ui/app-shell`
30
+ internals into core and are now public exports.
31
+
32
+ `@object-ui/app-shell`'s two hand-rolled copies of this POST —
33
+ `useConsoleActionRuntime.serverActionHandler` and `RecordDetailView`'s — are
34
+ collapsed into one console wrapper (`createConsoleServerActionHandler`) that
35
+ layers the browser-only choreography (popup pre-open dance, zero-roundtrip
36
+ `newTabUrl` fast path, `redirectUrl` convention) over the core factory. The
37
+ copies had already drifted twice (objectstack#3913 — envelope; framework#3935 —
38
+ identity, fixed in one copy only): RecordDetailView now also dispatches by
39
+ declarative `name` instead of `target || name`, and no longer leaks the
40
+ client-side `_rowRecord` stash to the server.
41
+
42
+ - f833d3a: Retire `validation` from the action-param contract — it was declared on both
43
+ halves, read by neither, and rejected outright by the server (objectui#3201).
44
+
45
+ FROM: `validation?: string` was declared on the AUTHORING type
46
+ (`@object-ui/types`' `ActionParam`) and on the RESOLVED type (`@object-ui/core`'s
47
+ `ActionParamDef`). TO: it is declared on neither.
48
+
49
+ **Breaking for anyone who declared it — but it never did anything.** This is
50
+ marked `minor`, not `major`, per the repo's version-alignment policy (objectui's
51
+ major tracks `@objectstack`'s, so objectui's own breaking changes ship as `minor`
52
+ with the breaking semantics spelled out here).
53
+
54
+ **Migration: delete it.** If you authored `validation: '...'` on an action param,
55
+ it never took effect, and publishing that metadata to the server is a hard parse
56
+ failure — so any metadata that reached production either never carried the key or
57
+ never parsed. Removing it changes no runtime behaviour; it only moves the error
58
+ from "silent no-op, then rejected at publish" to a `tsc` error at the keystroke.
59
+
60
+ Why it could not work as authored:
61
+
62
+ - `ActionParamSchema` in `@objectstack/spec/ui` is `.strict()` and does not list
63
+ `validation`, so an authored key is a PARSE REJECTION on the server:
64
+ `Unrecognized key(s) on this action param: \`validation\``. Meanwhile `tsc`
65
+ against the public type accepted it — the type vouched for a key the platform
66
+ itself refuses.
67
+ - Nothing read it on the resolved side either: it was never a key of
68
+ `resolveActionParams()`'s `RawActionParam`, the runtime field metadata a
69
+ field-backed param inherits from carries no `validation` to source one from,
70
+ and `paramToField()` never mapped it — so it could not reach the field widgets,
71
+ whose rules `buildValidationRules()` builds from `required` / `minLength` /
72
+ `maxLength` / `pattern`.
73
+
74
+ Removed rather than implemented, on ADR-0049 enforce-or-remove. Giving it meaning
75
+ would mean first deciding what an "expression" is here (CEL? a formula? a regex?)
76
+ and adding it to `@objectstack/spec`, which is where such a capability has to
77
+ start — not accreted renderer-side around a key the contract does not have.
78
+
79
+ This also retires the last named exception in objectui#3174's drift guard
80
+ (`packages/types/src/__tests__/page-nav-misc-spec-parity.test.ts`), which carried
81
+ `validation` as the one key `ActionParam` added on top of the spec's set. The
82
+ rule it pins — **the authoring type declares exactly the spec's authorable
83
+ keys** — is now literal: the guard asserts the local-only key set is empty, so
84
+ any future addition fails the build instead of being waved through.
85
+
86
+ - d22ae31: Track `@objectstack/spec` 17.0.0-rc.2 (objectui#3235, #3208, #3287, #3264).
87
+
88
+ The pin moves from `^17.0.0-rc.1` to `^17.0.0-rc.2` across the workspace, and
89
+ the sibling `@objectstack/*` packages (`client` / `core` / `formula` / `lint`)
90
+ move with it — they pin `@objectstack/spec` **exactly**, so leaving them behind
91
+ kept a second copy of the spec in the tree and would have had `@objectstack/lint`
92
+ validating against rc.1 schemas that still accept keys rc.2 retires.
93
+
94
+ Breaking semantics, in FROM → TO form:
95
+
96
+ - **`app.homePageId` is retired — an app's landing page is now its first
97
+ navigation item.** An app that pinned a landing page with `homePageId` will
98
+ open on the first reachable navigation entry (by `order`) instead; the root
99
+ landing still follows `isDefault`. To restore a specific landing page, reorder
100
+ `navigation` so the intended entry comes first. Stored metadata is migrated by
101
+ `os migrate meta --from 16`. The key is a hard error now, not a stripped one:
102
+ the spec ships a tombstone that names the migration.
103
+ Upstream retired it because of its SHAPE, not its usage — it was an ID
104
+ cross-reference with no referential integrity, so a `homePageId` that pointed
105
+ at nothing silently fell back to the first navigation item anyway
106
+ (objectstack#4667, premise corrected in #4709). If the capability returns, it
107
+ returns as a flag on the navigation item itself, which cannot dangle.
108
+ - **`@object-ui/types`' `HttpMethod` now resolves to the spec's
109
+ `HttpMethodType`.** Shape is verbatim identical — the same 5-value UI subset —
110
+ and `@object-ui/types` still exports it as `HttpMethod`, so no consumer
111
+ changes. The spec renamed its `./ui` export because `HttpMethod` named two
112
+ different types depending on the import path (`./shared` / `./api` carry a
113
+ 7-value enum including `HEAD` / `OPTIONS`); objectui deliberately keeps the
114
+ 5-value one (objectstack#4691).
115
+ - **`AppContextSelector.includeAll` / `placement` are gone.** Neither ever did
116
+ anything in this renderer: context selectors are mandatory-scope, so no "All"
117
+ row was ever rendered, and `placement: 'topbar'` put nothing in the topbar.
118
+ Both carried schema defaults, which is why the liveness lint structurally
119
+ could not flag them — removal was the only channel that reaches an author
120
+ (framework#4509).
121
+ - **`NavigationArea.visible` / `order` / `requiredPermissions` are gone.** An
122
+ area is a layout grouping, not an access boundary. Gating moved down to the
123
+ navigation ITEM, where `visible` and `requiredPermissions` are unchanged and
124
+ still enforced. `AppSchemaRenderer`'s area switcher no longer hides an area, so
125
+ an area whose items are all gated away renders as visible-but-empty rather
126
+ than disappearing.
127
+ - **`@object-ui/core` no longer exports `NotificationProtocol`**
128
+ (`resolveNotificationConfig`, `specNotificationToToast`, `mapSeverityToVariant`,
129
+ `mapPosition`, `ToastNotification`). It bridged `@objectstack/spec/ui`'s
130
+ `Notification` / `NotificationConfig`, which objectstack#4610 deleted with no
131
+ successor. Use `resolveNotificationConfig` from `@object-ui/react`
132
+ (`NotificationContext`), which owns the live `NotificationSystemConfig` and is
133
+ what every notification surface already read. Note that the spec's _other_
134
+ `Notification` — `@objectstack/spec/api` — is the REST inbox row, a different
135
+ contract, and is deliberately NOT aliased in as a replacement.
136
+ - **The `email_template` client-side validator now uses
137
+ `EmailTemplateDefinitionSchema`.** It was pointing at the removed
138
+ `EmailTemplateSchema`, so authored templates were being checked against the
139
+ wrong contract: the live one is keyed `name` + `locale` (not `id`) and splits
140
+ the body into `bodyHtml` / `bodyText` (not `body` + `bodyType`)
141
+ (objectstack#4616 / #4807).
142
+
143
+ Fixes that are not breaking, but were only found because rc.2 stopped being
144
+ lenient — each had been passing vacuously:
145
+
146
+ - **`view` drafts are actually validated now.** The client validator named the
147
+ aggregated container schema while this admin authors first-class `ViewItem`s,
148
+ and the container used to strip `viewKind` / `config` in silence — so no view
149
+ draft ever had one of its own keys checked. It now validates each shape
150
+ against its own schema (objectui#3312).
151
+ - **The console's worked examples were wrong**, and being stripped rather than
152
+ refused: `view.list.object` (the container root already declares it),
153
+ `job.concurrency` / `job.timeoutMs` (no such keys; the spelling is `timeout`,
154
+ already in ms), `email_template.from` / `.to` (a template is not a send —
155
+ the sender override is `fromOverride`, an object), and
156
+ `datasource.capabilities` / `.healthCheck` (objectstack#4583 removed the
157
+ former; the latter was never a datasource key). These are the drafts an
158
+ author — or a model generating metadata — copies.
159
+ - Action key inventory re-derived: `ActionSchema` gained the package-lock
160
+ envelope (`_lock*` / `_package*` / `_provenance`), so a packaged action no
161
+ longer reports them as unknown keys.
162
+ - The schema-diff panel labels the new `default_mismatch` finding.
163
+ - Test fixtures pinning the retired `managedBy: 'system'` bucket now use
164
+ `engine-owned`. Protocol 17 split that value (objectstack#3355), so it
165
+ resolved to the default-writable fallback and a batch of "stays locked"
166
+ assertions had quietly stopped asserting anything.
167
+
168
+ ### Patch Changes
169
+
170
+ - 18cd432: `ActionEngine.getActionsForLocation` now evaluates a `{ dialect: 'cel', source }` action `visible` predicate on the canonical `@objectstack/formula` engine instead of the legacy JS evaluator. The method used to unwrap the envelope into a `${source}` string before calling `evaluateCondition`, which only routes to the CEL engine while its argument is still an envelope — so the engine silently demoted every CEL predicate to the legacy path while every action renderer (`action-button`, `action-menu`, `action-bar`, … via `toPredicateInput`) ran the same predicate on CEL (#2661). Because `ExpressionInputSchema` normalizes even a bare authored string into a CEL envelope, that was the common case, and the two engines disagree: `null < null` faults in CEL (fail-closed hide) but is `false` in JS, and CEL-only builtins such as `today()` do not exist on the JS path at all — so one `visible:` predicate could hide a button in the renderer while the engine kept it (or the reverse). Normalization now goes through a shared `toPredicateInput` helper exported from `@object-ui/core`, pinned to the renderer-side twin by a parity suite. Bare strings, non-`cel` dialects, empty `source`, and boolean predicates all behave exactly as before (#3314).
171
+ - d915c47: Relation fields (`lookup` / `master_detail` / `user` / `tree`) are now usable in action and conditional-formatting predicates: they bind as the stored foreign key on every surface, and the fields a predicate reads are included in the query projection (#3501).
172
+
173
+ Before this, one predicate over one relation field had four different fates, decided by things its author does not control. `$expand` **replaces** the id in place with the whole related record, and a view expands exactly the relations it shows as COLUMNS — so `record.owner == "U1"` was **true** where the column was absent, **false** where it was displayed, and a **fault** where the field was neither displayed nor projected (a list's `$select` was built from its columns alone, and CEL treats an absent key as a fault, not as null). A fault is fail-CLOSED on the row kebab and the selection bar and fail-OPEN on the lenient paths, so the same authoring mistake hid the button from everyone on one surface and showed it to everyone on the next, with nothing on screen to point at either. The server, meanwhile, only ever sees the id — so client and server could not agree, which is the one thing ADR-0036 / ADR-0058 exist to guarantee.
174
+
175
+ Two changes close it. `toPredicateRecord` (new, `@object-ui/core`) collapses expanded relation values back to their ids when a record is bound for evaluation — driven by the object's own field types, not by sniffing for an `id` key, so a `json` field that happens to carry one is untouched. It is threaded through `evalRowPredicate` / `resolveConditionalFormatting` (via a new `fields` option), `useRowPredicate`, `partitionBulkRows`, and both `page:header` evaluators, with the object schema supplied by `ObjectGrid` / `ListView` / `ObjectKanban` / the record context. Kanban card formatting is threaded the same way, so a rule cannot match on the grid view of a list and silently never match on its board. Display is unaffected — a detail-page title still renders the related record's name, and the schema-only `kanban-ui` entry point (which has no object schema to offer) keeps using the payload verbatim. `collectPredicateFieldRefs` / `listViewPredicates` (new) harvest the `record.x` / `data.x` references out of a view's conditional formatting, row-action defs, bulk-action defs, promoted object actions and `userActions` overrides, and add them to `$select` — intersected with the object's declared fields plus the platform columns every object carries (`isProjectableField`), because an unknown key is not ignored by every backend. No `$expand` is added: a predicate wants the foreign key, which is what an unexpanded relation already is.
176
+
177
+ - 509104a: Fix matrix report cells showing another bucket's numbers when dimension values run together.
178
+
179
+ The cross-tab in `DatasetReportRenderer` built its bucket ids by joining dimension values with the EMPTY string, so adjacent values had no boundary at all: `"x"` + `"yz"` and `"xy"` + `"z"` were the same bucket on both axes, and the later row silently overwrote the earlier one. Its cell key then joined the two bucket ids with a plain space, while dimension values contain spaces constantly ("New York", "In Progress"), so `"New"` × `"York Q1"` and `"New York"` × `"Q1"` also met in one key. A merged bucket showed a different row's measure, the overwritten row's value was unreachable, the per-row and per-column subtotals matched the wrong header, and drill-through followed the same wrong index into another record's list — none of it with an error.
180
+
181
+ Bucket ids and cell keys are now encoded with `JSON.stringify`, which carries the boundary in its own quoting rather than in a character the data is assumed never to contain. All four lookups in the renderer (row headers, column headers, row subtotals, column subtotals) share the one encoder, so they agree by construction.
182
+
183
+ The encoders moved to `@object-ui/core` as `pivotBucketId` / `pivotCellKey` and are now shared with the dashboard `DatasetWidget`, which carried the same defect and fixed it separately: two packages each hand-rolling the same key is why one fix left the other broken. The dashboard keeps its existing exports and behaviour.
184
+
185
+ - a4cff5b: Conditional-rule predicates that fail to evaluate are no longer silent
186
+ (objectstack#5149, appeal 2). `evalFieldPredicate` — the canonical funnel for
187
+ `visibleWhen` / `readonlyWhen` / `requiredWhen`, view-level `visibleOn`, legacy
188
+ `condition`, per-option `visibleWhen`, screen-field predicates and list
189
+ conditional formatting — now logs **one `console.warn` per predicate text**
190
+ when evaluation fails (parse error, unbound identifier, engine fault), carrying
191
+ the predicate source, the engine's failure reason, and the field/rule locator
192
+ the call site provides. Renderer call sites thread that locator
193
+ (`visibleWhen of field 'amount'`), so a broken predicate identifies itself in
194
+ the browser console instead of being indistinguishable from an absent one.
195
+
196
+ Verdicts are unchanged: evaluation still fails open to the caller's safe
197
+ default (flipping that default is objectstack#5149 appeal 1, tracked
198
+ separately). Fault-probing callers (`evalRowPredicate`'s fail-closed path,
199
+ `ExpressionEvaluator`'s `throwOnError`) opt out via the new
200
+ `diagnostic.warn: false` and keep their own single diagnostic, so no broken
201
+ predicate ever warns twice.
202
+
203
+ - 2a9513d: `toFilterNode` now lowers a spec `ViewFilterRule[]` into ObjectQL AST nodes instead of returning the array verbatim, so a saved view's stored filter reaches `$filter` as something the server accepts. It previously did not: `ListViewSchema.filter` / `ViewTab.filter` are declared `z.array(ViewFilterRuleSchema)`, and the whole read path — `ObjectView` → `ListViewSchema.filter` → `buildEffectiveFilter` → `mergeFilterNodes` → `toFilterNode` — carried those rule objects untouched into the query. `isFilterAST` is `false` for an array of objects, so the data API answered `400 INVALID_FILTER` and the list rendered no rows at all. Measured against a real backend on the showcase's shipped `showcase_task.in_progress` view: `$filter=[{"field":"status","operator":"equals","value":"in_progress"}]` returned `400`, while the lowered `[["status","equals","in_progress"]]` returned its 2 rows. Every saved view carrying a filter was affected, on both producers that share this sink — `plugin-list`'s `buildEffectiveFilter` (the grid and its export) and `plugin-view`'s `ObjectView` (calendar / kanban / gallery / timeline).
204
+
205
+ Operators are canonicalised through the spec's own `normalizeFilterOperator`, the same exit the write side (`viewFilterFold`) uses, so the two directions cannot drift into two dialects; no second operator table is introduced. An operator the spec does not know is passed through verbatim so the server still refuses it loudly rather than having a misspelling coerced into a valid filter. AST nodes and MongoDB-style object filters are unaffected, mixed arrays (a view's rules concatenated with `?filter[<field>]=<value>` URL triples) fold element-wise with the triples untouched, and a rule with a blank `field` is deliberately left unlowered — `["", op, value]` passes `isFilterAST` and returns an empty list, whereas the unlowered rule keeps the loud `400`.
206
+
207
+ - Updated dependencies [d915c47]
208
+ - Updated dependencies [9e9e9a9]
209
+ - Updated dependencies [23018cc]
210
+ - Updated dependencies [f44d872]
211
+ - Updated dependencies [f833d3a]
212
+ - Updated dependencies [d22ae31]
213
+ - @object-ui/types@17.3.0
214
+
215
+ ## 17.2.0
216
+
217
+ ### Minor Changes
218
+
219
+ - bca45cc: Declare the 18 spec-owned action keys `ActionDef` had been absorbing silently.
220
+
221
+ `ActionDef` ends with `[key: string]: any`, so it accepted any key of any type —
222
+ a typo (`targt`) and a retired spec key (`execute`) both type-checked, then the
223
+ runner silently bound no handler (the #2169 "Mark Done does nothing" shape).
224
+ Step 1 (objectstack#4075) made that audible with a dev-mode warning. This is
225
+ step 2: the keys the warning identified as legitimate are now real fields.
226
+
227
+ - **18 keys promoted to explicit optional fields** — `ai`, `aria`, `bodyExtra`,
228
+ `bodyShape`, `bulkEnabled`, `component`, `icon`, `locations`, `mode`,
229
+ `objectName`, `order`, `recordIdField`, `recordIdParam`, `requiredPermissions`,
230
+ `requiresFeature`, `shortcut`, `variant`, `visible`. Every type is **derived**
231
+ from `@objectstack/spec`'s `ActionInput` (`SpecActionInput['locations']`, …),
232
+ never hand-copied: a hand-written duplicate of a spec shape is a second
233
+ contract that drifts, which is the failure this issue is about. Wrong-typed
234
+ values are now compile errors — `order: 'first'`, `variant: 'chartreuse'`,
235
+ `locations: ['nope']` — where before they were absorbed silently.
236
+ - **Derived from `z.input`, not `z.infer`.** `ActionSchema` is a `ZodPipe` whose
237
+ transform narrows `visible` from `string | { dialect, source }` to the
238
+ envelope alone. This runner consumes authored/stored rows, which are
239
+ rehydrated unparsed, so it sees the input shape; deriving from the inferred
240
+ `Action` would have rejected the raw-string predicate `ActionEngine`
241
+ explicitly supports.
242
+ - **Three `as any` casts deleted** in `ActionEngine` — `visible` and
243
+ `requiredPermissions` at the location filter, `locations` at registration.
244
+ They existed only because the fields were undeclared.
245
+ - **Four objectui-dialect keys marked `@deprecated`** with the spec spelling to
246
+ use instead — `actionType` (→ `type`), `api` and `endpoint` (→ `target`;
247
+ `executeAPI` already resolves `api || endpoint || target`), and `navigate`
248
+ (→ flat `target` / `openIn`). Only these four: the remaining dialect keys are
249
+ runner mechanics (chaining, toasts, post-execution reload/close) with no spec
250
+ counterpart, and pointing them at a spelling that does not exist would be
251
+ worse than leaving them declared.
252
+
253
+ **Breaking edge, deliberate.** `shortcut` and `bulkEnabled` were retired by
254
+ `@objectstack/spec` 17 as `retiredKey()` tombstones (`z.never()`), so authoring
255
+ either is already a hard parse rejection. Deriving their types rather than
256
+ hand-writing them turns that runtime rejection into a **compile error**: code
257
+ that assigned `shortcut: 'ctrl+k'` to an `ActionDef` compiled before and does
258
+ not now. Such metadata was already refused by the platform — this only moves the
259
+ failure to where it can be fixed. A host may still pass either explicitly via
260
+ `ActionEngine.registerAction(action, { shortcut, bulkEnabled })`; only authored
261
+ metadata stopped carrying them. `bulkEnabled`'s replacement is the list view's
262
+ `bulkActions` / `bulkActionDefs`; `shortcut` has none.
263
+
264
+ The index signature **stays** — removing it is step 3, and the inverted pin
265
+ asserting it is still present remains the issue's own completion check.
266
+
267
+ - 4bf612c: Aggregate single-call mode for bulk actions: `execution: 'aggregate'` (objectui#3139).
268
+
269
+ A `bulkActionDefs` entry with `operation: 'custom'` used to have exactly one
270
+ dispatch shape: one action-runner call per selected record (`_rowRecord`
271
+ attached). "Select N rows → ONE call that receives every selected id" — the
272
+ zip-of-QR-codes / merged-PDF / batch-print shape — could not be expressed, so
273
+ downstream projects fell back to per-row `window.open` storms or gave up.
274
+
275
+ `BulkActionDef` now carries `execution?: 'perRecord' | 'aggregate'` (default
276
+ `'perRecord'`, existing views untouched). An aggregate def dispatches its
277
+ action exactly once for the whole selection with `params._selectedIds:
278
+ string[]` injected and the full records published as
279
+ `context.selectedRecords`. The authored form usually just names a declared
280
+ object action — `{ name, operation: 'custom', execution: 'aggregate' }` —
281
+ and `resolveBulkActions` attaches the declaration. Results are
282
+ all-or-nothing: a failure is attributed to every id with the real error and
283
+ per-row Retry is hidden (re-running the action is the retry; a total failure
284
+ keeps the selection). `batchSize` does not apply; `maxRecords` still gates.
285
+
286
+ The executor rides the existing `executeBulkBatch` bulk-first decision tree —
287
+ the aggregate call is its `bulkCall`, and the per-row "fallback" only
288
+ re-throws the captured error for attribution, never fans out N dispatches
289
+ against an endpoint written for one `_selectedIds` call.
290
+
291
+ Also: url/api target interpolation now exposes `${ctx.selection.ids}` (comma
292
+ -joined) and `${ctx.selection.count}` from the grid's checkbox selection, so
293
+ a plain `list_toolbar` action can carry the selection without bulk plumbing;
294
+ the console's server-action handler recognizes `_selectedIds` and skips the
295
+ single-record multi-select guard for aggregate dispatches.
296
+
297
+ - 335041c: Stop declaring 13 `@object-ui/core` symbols under names `@objectstack/spec` owns
298
+ (objectui#3158, objectstack#4115 batch 4).
299
+
300
+ **Breaking for importers of `@object-ui/core`** — seven exported names changed,
301
+ because the spec exports the same name for a _different_ thing:
302
+
303
+ | was | now | what the spec's same-named export actually is |
304
+ | :----------------------- | :-------------------------------- | :--------------------------------------------------------------------------- |
305
+ | `ChartSeries` | `ChartSeriesBinding` | the authored dataset-binding descriptor (a measure `name`, no `data`) |
306
+ | `ActionHandler` | `ActionRunnerHandler` | the SERVER-side objectql handler, `(ctx) => unknown` |
307
+ | `PluginDefinition` | `RegistryPluginDefinition` | the platform PACKAGE manifest (`id`/`slug`/`staticPath`/install hooks) |
308
+ | `ValidationError` | `SchemaNodeValidationError` | plugin-manifest validation, keyed by `field`, no severity |
309
+ | `ValidationResult` | `SchemaNodeValidationResult` | ditto, with both arrays optional |
310
+ | `defineView` | `defineSystemView` | the VIEW-DOCUMENT factory: parses a `ViewSchema`, returns a validated `View` |
311
+ | `resolveCrudAffordances` | `resolveEffectiveCrudAffordances` | the object-level affordance matrix, with no notion of server API operations |
312
+
313
+ The other six keep their names and are now **imported from the spec** instead of
314
+ re-declared: `StyleMap`, `ResponsiveStyles` (ADR-0065), `RowHeight`,
315
+ `CONTEXT_TOKENS`, `CrudAffordances`, `RowCrudPredicates`.
316
+
317
+ **The copies were live misdescriptions, not just duplicates.** Three said so in
318
+ their own comments:
319
+
320
+ - `CONTEXT_TOKENS` carried a note that the duplication was "temporary until the
321
+ next coordinated release… because the installed `@objectstack/spec` predates
322
+ that export". The installed spec (17.0.0-rc.0) exports it, and the copy was
323
+ byte-identical — so it passed every value comparison and every behavioural
324
+ test for the whole interval in which its stated reason was false.
325
+ - `RowHeight` advertised itself as "the spec's `RowHeightSchema` vocabulary"
326
+ while being a hand-written union. It happened to be correct; nothing would
327
+ have caught the day it stopped being.
328
+ - `managedBy.ts` described itself as a "UI-side mirror of the framework's
329
+ `resolveCrudAffordances()`" and carried its own `DEFAULTS` table — a
330
+ line-for-line copy of the spec's `CRUD_AFFORDANCE_DEFAULTS`, plus a copy of
331
+ its override parser.
332
+
333
+ `resolveEffectiveCrudAffordances` now **delegates** the bucket/`userActions` half
334
+ to the spec's `resolveCrudAffordances()`, so the bucket table has exactly one
335
+ definition on the platform. What stays objectui's is the part the spec has no
336
+ notion of: intersecting that matrix with the server-resolved effective API
337
+ operation set (#3391), so the UI never offers a button the server would 405 —
338
+ and the name now says that instead of claiming to be the spec's function.
339
+
340
+ Deriving `RowCrudPredicates` also **tightens** it: the local copy typed
341
+ `visibleWhen`/`disabledWhen` as `unknown`, where the spec types them as
342
+ `Expression | ExpressionInput`. That was imprecision, not a deliberate dialect.
343
+
344
+ - b414983: fix(dashboard): a date globalFilter's preset-name default becomes a range, not an equality
345
+
346
+ Setup → System Overview rendered EVERY KPI tile as 0 while its period selector
347
+ read "All time" (objectstack#4475). Every request was `200 OK`, the widgets
348
+ rendered normally, and nothing in the UI signalled a failure — zeros read as
349
+ "nothing has happened yet" rather than as an error, which is why this survived
350
+ to an RC.
351
+
352
+ Both symptoms are one missing normalization. `resolveDashboardFilterDefs` lifts
353
+ the built-in `dateRange` declaration's preset NAME to `{ preset }`, but passed a
354
+ `globalFilters` entry's `defaultValue` through raw. `@objectstack/spec`'s
355
+ `GlobalFilterSchema.defaultValue` is `string | number | boolean`, so a bare
356
+ preset name is the ONLY spelling an author can write — and nothing ever mapped
357
+ it. System Overview declares
358
+ `{ field: 'created_at', type: 'date', defaultValue: 'last_7_days' }`, so:
359
+
360
+ - `buildFilterCondition` fell through to its "a bare string date means equality
361
+ on that day" branch and the widget sent
362
+ `runtimeFilter: { created_at: 'last_7_days' }`. The backend compiled
363
+ `SELECT COUNT(*) AS "user_count" FROM "sys_user" WHERE created_at = $1`
364
+ — verified against a live server, byte-for-byte the SQL in the issue. The
365
+ actual `sys_user` count is 4; that equality matches no row.
366
+ - `DateRangeFilter` derives its selected item from `value.preset` / `.from` /
367
+ `.to`, all `undefined` on a bare string, so the control fell through to its
368
+ ALL sentinel and displayed "All time" while sending that equality. The tiles
369
+ therefore looked deliberately unfiltered and merely empty.
370
+
371
+ `normalizeDateDefault` now applies the same lift the sibling `dateRange`
372
+ declaration already receives, for `date`/`dateRange` filters whose default names
373
+ a preset this module actually knows. This is not consumer-side leniency: it is
374
+ one normalization function completing the same conversion for the sibling
375
+ declaration, and the spec admits no other spelling for an author to fix at the
376
+ producer. A genuine ISO date string still means equality on that day (the
377
+ documented behaviour), and numbers, booleans and unrecognised strings are left
378
+ exactly as declared.
379
+
380
+ No backend change is needed: given a real range the dataset path already lowers
381
+ it correctly (`WHERE (created_at >= $1 AND created_at < $2)` → 4). The
382
+ framework's dashboard metadata needs none either — it is spec-compliant as
383
+ written, and editing it would only hide the defect.
384
+
385
+ Levelled `minor` rather than `patch` because the change is visible in rendered
386
+ dashboards rather than internal: any dashboard declaring a date-typed
387
+ `globalFilters` default now emits a different query shape, its numbers change
388
+ (from 0 to real values), and its filter control's displayed label changes with
389
+ them. Anything asserting on the previously-emitted condition will see it move.
390
+
391
+ Known residual, filed separately rather than widened into here: a `date` filter
392
+ whose value is neither a known preset nor a parseable ISO date still degrades
393
+ silently to an equality that matches nothing, producing the same
394
+ healthy-looking zero. Preset names are covered by this change; a misspelled
395
+ custom value is not.
396
+
397
+ - 256f8cc: fix(dashboard): an unrecognised date filter value is skipped and named, not compared
398
+
399
+ The residual the preset-name fix (objectui#3150 / objectstack#4475) left behind,
400
+ and the more deceptive half of it: a `date`/`dateRange` filter value that is
401
+ neither a known preset name nor a parseable date used to fall through to the
402
+ "a bare string date means equality on that day" branch. A misspelled default —
403
+ `defaultValue: 'last_7_dayz'` — therefore reached the widget query as
404
+ `runtimeFilter: { created_at: 'last_7_dayz' }`, which the backend faithfully
405
+ compiled to `WHERE created_at = $1`. `200 OK`, widget renders, count is 0 —
406
+ indistinguishable from "this range genuinely has no data". No 4xx, no console
407
+ warning, no UI signal. objectstack#4475 took a full RC cycle to catch for
408
+ exactly this reason: **0 looks like a legitimate answer**.
409
+
410
+ `buildFilterCondition` now holds a date value to three spellings, and only
411
+ three:
412
+
413
+ 1. a known preset name → range bounds (unchanged, objectui#3150);
414
+ 2. an ISO date (`2026-01-15`, `2026-01-15T08:30:00Z`) or a date-macro token
415
+ (`{today}`, `{7_days_ago}`) → equality on that day (the documented
416
+ behaviour, unchanged);
417
+ 3. **anything else → the filter is skipped and `console.warn` names the filter,
418
+ the offending value, and the accepted spellings.**
419
+
420
+ The `{ preset: '<unknown>' }` object form gets the same voice. It already
421
+ dropped the filter — silently — because the preset lookup missed and no
422
+ `from`/`to` remained; that drop is now announced. When explicit bounds ride
423
+ along with an unknown preset the bounds are still honoured, and the warning says
424
+ which of the two won.
425
+
426
+ Rule 3 is deliberately the same strictness `buildWidgetScopedFilter` already
427
+ applies to a _default binding on a field the object does not have_ — skip and
428
+ warn, with the same rationale spelled out there: never emit a query the backend
429
+ can only empty-match. Field _names_ had that guard; field _values_ did not.
430
+
431
+ The macro-token check asks `resolveDateMacros` itself whether it recognises the
432
+ string, rather than restating its token grammar in a second place. One
433
+ vocabulary, no dialect to drift — and a token that resolver does not know
434
+ (`{last_7_dayz}`) is precisely the typo this guard exists to catch.
435
+
436
+ Levelled `minor`, matching objectui#3150, because the emitted query shape
437
+ changes: a dashboard carrying a misspelled date value stops sending a
438
+ never-matching equality and instead sends no constraint for that filter (its
439
+ numbers go from 0 to unfiltered) while the console says why. Anything asserting
440
+ on the previously-emitted equality will see it disappear.
441
+
442
+ Note the direction of the relaxation is chosen, not incidental: skipping widens
443
+ the result set, so the number visibly changes and the warning explains it —
444
+ whereas the old behaviour narrowed it to zero, which is the one outcome an
445
+ author cannot tell from a correct answer. Author-time rejection (validating
446
+ `GlobalFilterSchema.defaultValue` at publish, in `@objectstack/spec`) is the
447
+ stricter complement and belongs on the platform side; it is filed separately.
448
+
449
+ - d3584c6: Bring the whole `@objectstack` family to `17.0.0-rc.1`, so the dependency graph resolves a
450
+ single copy of `@objectstack/spec`.
451
+
452
+ #3178 bumped **only** `@objectstack/spec` to `17.0.0-rc.1`. The rest of the family —
453
+ `client`, `core`, `formula`, `lint` (and `sdui-parser`, reached through `lint`) — stayed on
454
+ `17.0.0-rc.0`, and each of them depends on spec at an **exact** version rather than a
455
+ caret:
456
+
457
+ ```
458
+ @objectstack/client@17.0.0-rc.0 -> spec "17.0.0-rc.0"
459
+ @objectstack/core@17.0.0-rc.0 -> spec "17.0.0-rc.0"
460
+ @objectstack/formula@17.0.0-rc.0 -> spec "17.0.0-rc.0"
461
+ @objectstack/lint@17.0.0-rc.0 -> spec "17.0.0-rc.0"
462
+ ```
463
+
464
+ So `main` carried **two** spec copies: objectui's own code read `17.0.0-rc.1` while every
465
+ `@objectstack/*` package read `17.0.0-rc.0` from its own nested `node_modules`. That breaks
466
+ the single-contract invariant this repo's guards are built on, and it breaks them
467
+ _silently_ — the affected checks depend on identity, not on version strings:
468
+
469
+ - `spec-subschema-parity.test.ts` distinguishes a genuine re-export from a fork by
470
+ **reference identity** of the zod schema object. Two spec copies make every schema a
471
+ distinct object, so a real re-export starts reading as a fork (or a fork slips through,
472
+ depending on which copy each side resolved).
473
+ - `scripts/check-spec-symbol-derivation.mjs` and `spec-symbol-parity.test.ts` use
474
+ `createRequire` to resolve spec's `.d.ts` and run it through the TS checker. With two
475
+ copies installed, _which_ declaration file the checker sees is a function of resolution
476
+ order rather than of intent.
477
+
478
+ The declared ranges were already `^17.0.0-rc.0`, which technically admits rc.1 — the pin
479
+ lived in the lockfile. Raising the remaining ranges to `^17.0.0-rc.1` makes the floor
480
+ explicit and forbids a future install from silently sliding back onto a family member that
481
+ drags rc.0 along with it. The rc.1 family members pin spec at `17.0.0-rc.1` exactly, so the
482
+ graph now converges on one copy by construction, not by luck.
483
+
484
+ No product behaviour changes here. `check:spec-symbols` reconciliation was already
485
+ completed by #3178 and stays green under the unified graph; this changeset is `minor`
486
+ per the repo's fixed-group version policy.
487
+
488
+ - 444457c: feat!: follow the framework's `managedBy: 'system'` → `'system-data'` retirement (objectstack#3355)
489
+
490
+ **FROM → TO: `managedBy: 'system'` → `managedBy: 'system-data'`.** The framework
491
+ retired the residual `system` bucket in protocol 17; this is the UI half of that
492
+ change, landing with it so the closed `ManagedByBucket` union stays a mirror
493
+ rather than a fork.
494
+
495
+ ADR-0103 split the overloaded `system` bucket additively in v16 — the
496
+ engine-owned objects moved to the explicit `engine-owned`, the admin/user-writable
497
+ ones stayed on `system` — which left that value named after the half that had
498
+ already moved out. `system-data` names what it actually holds: the SCHEMA is the
499
+ platform's, the DATA is the admin's or the user's.
500
+
501
+ **The derivation this deletes is the point.** Because v16's `system` doubled as
502
+ both the engine-owned default and the writable set, three UI surfaces had to
503
+ RECOVER the distinction from `userActions` at render time:
504
+
505
+ - `isSystemWritable()` probed `userActions` for any opted-in write. It is now
506
+ `managedBy === 'system-data'` — the bucket answers directly.
507
+ - `ManagedByBadge` derived a synthetic `'system-writable'` variant key. The
508
+ variant map is now 1:1 with the bucket union, so a new bucket is a compile
509
+ error to miss instead of a silent fallthrough. The `systemWritable` /
510
+ `system` i18n keys are **unchanged**, so no locale bundle moves.
511
+ - `resolveManagedByEmptyState()` asked the resolved `create` affordance whether a
512
+ `system` list should read "entries appear automatically" or show the New
513
+ button. `system-data` now falls through to the generic empty state by
514
+ definition; `engine-owned` keeps the automatic-entries copy.
515
+
516
+ **Breaking (UI API):** `ManagedByBadge`'s `userActions` prop and the exported
517
+ `ManagedByUserActions` interface are **removed**. The bucket alone selects the
518
+ variant now, so the prop had become metadata nothing read — the exact defect the
519
+ framework change exists to remove; shipping it as an accepted-but-ignored prop
520
+ would have reproduced it one layer up. Drop the prop from call sites; no other
521
+ change is needed.
522
+
523
+ `MANAGED_BY_BUCKETS` and `ManagedByBucket` no longer contain `'system'`.
524
+
525
+ - 850033c: Stop offering the retired `action.shortcut` / `action.bulkEnabled` keys.
526
+
527
+ `@objectstack/spec` 17 retired both as `retiredKey()` tombstones: authoring
528
+ either one is a hard PARSE REJECTION, so a draft carrying it cannot be saved
529
+ at all. The designer still offered controls for both — a "Bulk — apply to
530
+ multiple selected rows" checkbox and a "Shortcut" text field — which meant the
531
+ Studio action inspector let an author build a draft the platform would then
532
+ refuse, with the rejection arriving later and nowhere near the checkbox.
533
+
534
+ - **Action inspector**: both controls removed. The keys stay hidden from the
535
+ fallback form (the server's live schema still advertises them, so dropping
536
+ them from the hidden list would put the inputs straight back) — now under a
537
+ `RETIRED_FIELDS` list that says why, so nobody "restores the missing
538
+ control". `bulkEnabled`'s replacement is the list view's `bulkActions` /
539
+ `bulkActionDefs`; `shortcut` has none.
540
+ - **Action preview**: the `shortcut` and `bulk` pills are gone — they could
541
+ only ever render for metadata the platform now refuses.
542
+ - **`ActionEngine.registerActions`**: no longer harvests the two retired keys
543
+ from authored metadata, which made two dead registration options look
544
+ load-bearing. Both are still accepted on the single-action
545
+ `registerAction(action, options)` overload, where a HOST passes them
546
+ explicitly.
547
+
548
+ - 009e25d: Report / chart / query symbols stop wearing `@objectstack/spec`'s names
549
+ (objectui#3155, objectstack#4115).
550
+
551
+ **Breaking for TypeScript imports** — six exported names change. Each was a
552
+ different concept than the spec export it collided with, so an author reading
553
+ the objectui declaration as "the spec's" was reading a false claim:
554
+
555
+ | was | now | why they were never the same thing |
556
+ | :------------------ | :----------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------- |
557
+ | `ChartSeries` | `ChartDataSeries` | ours is a display name plus literal `data: number[]`; the spec's is a dataset-bound series descriptor (`type`/`stack`/`yAxis`/`variant`) with no data at all |
558
+ | `ChartSeriesSchema` | `ChartDataSeriesSchema` | zod twin of the above |
559
+ | `QueryAST` | `SqlQueryAST` | ours is a compiled SQL syntax tree (`select`/`from`/`join`/`group_by`); the spec's is the ObjectQL request descriptor (`object`/`fields`/`where`/`expand`) |
560
+ | `QuerySchema` | `DriverQueryConfig` | ours is the high-level config `QueryASTBuilder` compiles; the spec exports that name as a zod schema value |
561
+ | `DriverInterface` | `SqlDriverInterface` | ours is objectui's SQL-oriented client abstraction (`query(sql, params)`); the spec's is the platform runtime driver contract |
562
+ | `DatasourceSchema` | `DatasourceRegistration` | ours is the in-memory record `DatasourceManager` holds — its `driver` is a live instance; the spec's is the authored metadata document, where `driver` is a name |
563
+
564
+ Three more are now DERIVED from the spec instead of hand-restated, which fixes
565
+ live silent-stripping defects, since a `z.object()` drops unknown keys:
566
+
567
+ - **`DashboardWidgetSchema`** declared 10 of the spec's 22 keys, so
568
+ `objectui validate` deleted the other 12 without a word — `chartConfig`,
569
+ `colorVariant`, `filter`, `responsive`, `aria`,
570
+ `actionUrl`/`actionType`/`actionIcon`, `compareTo`, `suppressWarnings` and the
571
+ `requiresObject` / `requiresService` capability gates the dashboard renderer
572
+ honours at runtime. The TS interface had declared most of them all along, so a
573
+ widget could type-check and still lose half its configuration on validation.
574
+ Pinned divergences kept: `id` stays optional, `type` stays widened for the
575
+ objectui-only `list` / `custom` families, and the legacy `component` envelope
576
+ stays.
577
+ - **`GlobalFilterSchema`** took `scope` as a free-form string (any typo
578
+ validated); it now uses the spec's `widget | dashboard` vocabulary. The three
579
+ objectui widenings that back a real runtime normalizer are kept and pinned:
580
+ the bare-string `options` shorthand, the normalized `{ preset }` date default,
581
+ and an optional `optionsFrom.labelField`.
582
+ - **`AppContextSelectorSchema`** was a full restatement; spec keys and their
583
+ defaults now flow in by reference, with `label` widened for objectui's i18n
584
+ label envelope — which `AppContextSelectors` already renders.
585
+
586
+ `ListViewSchema`'s zod node now names the spec in its own initializer rather
587
+ than one hop away through a local const, so its long-standing derivation is
588
+ visible where it is declared.
589
+
590
+ Drift guard: `packages/types/src/__tests__/report-chart-query-spec-parity.test.ts`.
591
+
592
+ - 726b89c: `@object-ui/types` stops declaring sixteen symbols under names `@objectstack/spec` owns (objectui#3156, objectstack#4115).
593
+
594
+ Seven are now **derived** from the spec, nine are **renamed** to the local
595
+ dialect they always were. Both halves remove the same hazard: a local
596
+ declaration under a spec export's name reads as the spec's own definition to
597
+ the next reader, so a copy that is merely _correct today_ is a planted premise
598
+ tomorrow.
599
+
600
+ **Derived** — the spec now supplies the keys, by reference:
601
+
602
+ | symbol | derivation |
603
+ | :----------------------- | :-------------------------------------------------------------------------------- |
604
+ | `ActionParam` | `z.input<typeof ActionParamSchema>`, `type` widened to the local legacy spellings |
605
+ | `CreateExportJobRequest` | `Omit<CreateExportJobInput, 'object'>` (`object` is the method argument) |
606
+ | `CreateExportJobResult` | re-export from `@objectstack/spec/contracts` |
607
+ | `ImportRowResult` | re-export from `@objectstack/spec/api` |
608
+ | `NavigationArea` | spec keys, with `navigation` / `visible` pinned locally |
609
+ | `NavigationAreaSchema` | `specFieldsExcept(NavigationAreaSchema.shape, …)` |
610
+ | `Theme` | re-export of the spec's `ThemeInput` (the authoring shape) |
611
+ | `ExportJobFormat` | re-export of the spec's `ExportFormat` |
612
+
613
+ Four of these close real gaps rather than tidy names. `ActionParam` never
614
+ declared `reference` — the key `resolveActionParams()` actually reads for an
615
+ inline lookup target — nor `defaultFromRow`, which the metadata designer's own
616
+ inspector writes; it also narrowed `visible` to a bare string although the
617
+ resolver has always accepted the `{ dialect, source }` envelope too.
618
+ `CreateExportJobResult.createdAt` and `ImportRowResult.action` were optional
619
+ here and required by the server, leaving every consumer a branch that could
620
+ never run. And `NavigationArea`'s `id` now carries the spec's own length rule
621
+ instead of accepting any string.
622
+
623
+ **Renamed** — same word, different concept:
624
+
625
+ | was | now | why |
626
+ | :----------------- | :----------------------- | :----------------------------------------------------------------------------------------------------------------------------- |
627
+ | `FileMetadata` | `UploadedFileMetadata` | field-VALUE payload (`url`, `original_name`), not the storage file record |
628
+ | `GestureType` | `TouchGestureType` | direction-fused (`swipe-left`), not the spec's type+direction pair |
629
+ | `GestureConfig` | `TouchGestureConfig` | gesture→`action` binding, not per-gesture tuning |
630
+ | `OfflineConfig` | `PWAOfflineConfig` | service-worker route caching, not the offline data/sync model |
631
+ | `PageRegion` | `PageNodeRegion` | region of the renderer page NODE, holding `SchemaNode`s |
632
+ | `PageRegionSchema` | `PageNodeRegionSchema` | zod twin of the above |
633
+ | `ResponsiveConfig` | `MobileResponsiveConfig` | mobile box config, not the spec's SDUI grid contract |
634
+ | `WidgetManifest` | `RuntimeWidgetManifest` | SDUI component manifest, not the field-widget plugin manifest |
635
+ | `WidgetSource` | `RuntimeWidgetSource` | `module`/`inline`/`registry` loader union — and its `inline` carries a resolved component where the spec's carries source code |
636
+
637
+ **Migration**: the old names are gone, not deprecated — an alias would preserve
638
+ exactly the ambiguity being removed. Import the new name; nothing about the
639
+ shapes changed. `@object-ui/types` already re-exports the spec's own
640
+ `SpecResponsiveConfig`, and `@object-ui/react`'s `useOffline` config remains the
641
+ spec-shaped `OfflineConfig`, so both concepts stay reachable under
642
+ distinguishable names.
643
+
644
+ Each rename carries a bidirectional tripwire
645
+ (`packages/types/src/__tests__/page-nav-misc-spec-parity.test.ts`): it fails if
646
+ the spec ever claims the new name, and also if the spec retires the old one —
647
+ at which point the natural name can be taken back rather than the workaround
648
+ outliving its reason.
649
+
650
+ ### Patch Changes
651
+
652
+ - d9668a7: Honor the server's declared percent scale, so a ratio of exactly 1 renders as 100.0% (#3136)
653
+
654
+ A dataset measure declared `format: '0.0%'` rendered every ratio below 1
655
+ correctly and got the single most consequential one wrong: a rate of exactly
656
+ `1` printed as **`1.0%`**. On an SLA / pass-rate dashboard that turns
657
+ "everything met the SLA" into "1% met the SLA", on both surfaces the issue
658
+ names — the KPI card and the dataset-bound table (they share `formatMeasure`).
659
+
660
+ The cause was never a bad multiplier; it was a missing fact. `formatMeasure`
661
+ scaled by magnitude — `percentDisplayValue` multiplies by 100 only strictly
662
+ inside `(-1, 1)` — because the column arrived with a `%` format string and
663
+ nothing saying what scale its numbers were on. That guess is undecidable at
664
+ exactly 1, which is both a full-compliance ratio ("100%") and one percentage
665
+ point ("1%"), and it resolved to the reading almost nobody means.
666
+
667
+ The server now answers the question instead (framework: `percentScaleOf` +
668
+ `AnalyticsResult.fields[].percentScale`, the sibling of the ADR-0053 currency
669
+ chain): a `derived: { op: 'ratio' }` measure is a `fraction` by definition, and
670
+ a measure over a `percent` field inherits that field's scale. `formatMeasure`
671
+ takes the declared scale as a fourth argument and, when present, scales by it —
672
+ `fraction` ×100, `whole` verbatim — instead of inspecting the value. Every
673
+ dataset-bound call site passes the column's `percentScale`: the dashboard
674
+ metric/table/pivot cells, the report renderer's cells, totals and KPI, and the
675
+ dataset preview.
676
+
677
+ `percentDisplayValue` is untouched and still the fallback for a column that
678
+ arrives without the annotation (an older server, or a non-dataset percent cell
679
+ in a list view), so nothing that renders correctly today changes.
680
+
681
+ - a8ad6c0: A required boolean must be savable in its UNCHECKED state — `false` and `0` are values.
682
+
683
+ Reported against an AI-built task tracker whose 任务 object has a required
684
+ `是否完成` boolean: the create form showed the switch OFF, answered "是否完成不能
685
+ 为空", and saved instantly once the switch was turned ON. The app could only ever
686
+ create ALREADY-DONE tasks — the one state the control shows by default was the
687
+ one value it refused to save (cloud#972).
688
+
689
+ Two defects stacked, and either alone is enough to break it:
690
+
691
+ **The `required` verdict read truthiness, not presence.** `@objectstack/spec`
692
+ FieldSchema.required (ADR-0113) is "an insert must provide a NON-NULL value",
693
+ and objectql's record validator implements exactly that. react-hook-form's
694
+ built-in rule instead fails whenever `isBoolean(value) && !value` — its
695
+ accept-the-terms checkbox heritage — silently redefining every required boolean
696
+ as "must be TRUE", including a select whose chosen option value is `false`. It
697
+ also disagreed the other way, letting a whitespace-only string through for the
698
+ server to reject with a 400. The form renderer no longer hands RHF its own
699
+ `required`: the check is now a `validate` entry keyed `required` (so the error
700
+ still surfaces as `type: 'required'`, which the conditional-required cleanup
701
+ keys on) backed by a new shared `isMissingForRequired` in `@object-ui/core`, a
702
+ deliberate mirror of objectql `record-validator.isMissing` — `undefined`,
703
+ `null`, blank-after-trim string, empty array. Deleting the inherited rule also
704
+ stops a `required` that rode in on `validation` from outliving a `requiredWhen`
705
+ that resolved to FALSE.
706
+
707
+ **A boolean field held `undefined` while displaying "off".** A two-state control
708
+ has no third state, but a field with no entry in `defaultValues` rendered an OFF
709
+ switch backed by nothing: the create payload omitted the column (it lands null,
710
+ which reads as unchecked but isn't) and the presence check above would still
711
+ refuse it. The form renderer now folds `false` into `defaultValues` for every
712
+ boolean-widget field the caller left unset — in `defaultValues` itself, not
713
+ per-Controller, because that object is also the dirty-check baseline and what
714
+ the defaults-reset window replays. Every surface gets it, including the
715
+ modal/drawer create dialogs that start from a bare `{}`. An authored default
716
+ (or a loaded record, `null` included) still wins.
717
+
718
+ `WizardForm`'s cross-step gate had its own copy of the empty-value predicate; it
719
+ now imports the shared one so it cannot drift from the per-field verdict. And
720
+ the field-demo renderer read `schema.defaultValue || schema.value`, throwing
721
+ away an authored default of `false` / `0` / `''` — same falsy-as-empty class,
722
+ now `??`.
723
+
724
+ Verified end to end on a local stack against the exact metadata shape
725
+ `apply_blueprint` materializes (`{ type: 'boolean', required: true }`, no
726
+ default): a 是否完成 = 否 task with 工时 = 0 now creates and persists as
727
+ `{ hours: 0, is_done: false }`, turning the switch on still stores `true`, and a
728
+ blank required text is still refused.
729
+
730
+ - Updated dependencies [4ae0ac4]
731
+ - Updated dependencies [696e3c1]
732
+ - Updated dependencies [4bf612c]
733
+ - Updated dependencies [cb82705]
734
+ - Updated dependencies [f572849]
735
+ - Updated dependencies [444457c]
736
+ - Updated dependencies [022e4c3]
737
+ - Updated dependencies [009e25d]
738
+ - Updated dependencies [726b89c]
739
+ - @object-ui/types@17.2.0
740
+
3
741
  ## 17.1.0
4
742
 
5
743
  ### Minor Changes