@object-ui/i18n 17.6.0 → 17.7.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 (56) hide show
  1. package/CHANGELOG.md +4320 -0
  2. package/README.md +192 -13
  3. package/dist/builtinAggregateLabels.d.ts +40 -0
  4. package/dist/builtinAggregateLabels.js +23 -0
  5. package/dist/currency.d.ts +27 -3
  6. package/dist/currency.js +28 -4
  7. package/dist/fallbackInterpolation.d.ts +79 -0
  8. package/dist/fallbackInterpolation.js +124 -0
  9. package/dist/i18n.d.ts +27 -1
  10. package/dist/i18n.js +99 -16
  11. package/dist/index.d.ts +7 -13
  12. package/dist/index.js +41 -17
  13. package/dist/locales/ar.d.ts +946 -113
  14. package/dist/locales/ar.js +1095 -161
  15. package/dist/locales/de.d.ts +618 -113
  16. package/dist/locales/de.js +715 -134
  17. package/dist/locales/en.d.ts +632 -127
  18. package/dist/locales/en.js +1044 -162
  19. package/dist/locales/es.d.ts +700 -113
  20. package/dist/locales/es.js +794 -130
  21. package/dist/locales/fr.d.ts +700 -113
  22. package/dist/locales/fr.js +795 -132
  23. package/dist/locales/index.d.ts +6990 -1169
  24. package/dist/locales/index.js +48 -9
  25. package/dist/locales/ja.d.ts +618 -113
  26. package/dist/locales/ja.js +708 -128
  27. package/dist/locales/ko.d.ts +618 -113
  28. package/dist/locales/ko.js +712 -132
  29. package/dist/locales/pt.d.ts +700 -113
  30. package/dist/locales/pt.js +794 -130
  31. package/dist/locales/registry.d.ts +102 -0
  32. package/dist/locales/registry.js +220 -0
  33. package/dist/locales/rtl.d.ts +24 -0
  34. package/dist/locales/rtl.js +26 -0
  35. package/dist/locales/ru.d.ts +782 -113
  36. package/dist/locales/ru.js +910 -152
  37. package/dist/locales/zh.d.ts +635 -130
  38. package/dist/locales/zh.js +725 -131
  39. package/dist/pickLocalized.d.ts +30 -0
  40. package/dist/pickLocalized.js +39 -0
  41. package/dist/provider.d.ts +101 -12
  42. package/dist/provider.js +335 -50
  43. package/dist/translateFn.d.ts +29 -0
  44. package/dist/translateFn.js +8 -0
  45. package/dist/useDisplayLocale.d.ts +11 -0
  46. package/dist/useDisplayLocale.js +11 -0
  47. package/dist/useObjectLabel.d.ts +40 -18
  48. package/dist/useObjectLabel.js +46 -14
  49. package/dist/useSafeTranslation.js +40 -42
  50. package/dist/utils/formatting.d.ts +16 -0
  51. package/dist/utils/formatting.js +23 -3
  52. package/dist/utils/spec-formatters.d.ts +13 -23
  53. package/dist/utils/spec-formatters.js +41 -2
  54. package/dist/utils/spec-translations.d.ts +16 -3
  55. package/dist/utils/spec-translations.js +52 -13
  56. package/package.json +12 -6
package/CHANGELOG.md CHANGED
@@ -1,5 +1,4325 @@
1
1
  # @object-ui/i18n
2
2
 
3
+ ## 17.7.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 6cc910b: A field-backed action param reaches its record picker, and a param whose backing
8
+ field cannot be read is refused instead of rendered as an empty text box.
9
+
10
+ An action declaring a `params` entry backed by a `lookup` field rendered as a bare
11
+ text input: no options, no typeahead, and no request for the referenced object on
12
+ the wire at all, because no picker was ever built. With the param `required`, the
13
+ action could not be launched from the UI — while the same field rendered a working
14
+ picker on a record form in the same build, off the same metadata.
15
+
16
+ `resolveActionParams()` resolves a field-backed param against the `objects` list
17
+ its caller holds, and two of the console's own callers cannot hold the right one:
18
+ `ConsoleShell`'s root action runtime — the provider that exists so a `type: 'flow'`
19
+ `action:button` works outside the four object views — passes no objects at all, and
20
+ `DeclaredActionsBar` passes exactly one (none when driven by an `actions` prop).
21
+ With the owner object absent the param took the resolver's last-resort shape, and
22
+ for a field-backed param that shape is `text`: it declares no inline `type`, so
23
+ there is nothing else to fall back to. The object binding was never missing from
24
+ the client — this seam never asked the metadata store for it.
25
+
26
+ - `useConsoleActionRuntime` now resolves field-backed params against the caller's
27
+ objects UNIONED with the console metadata store's, caller first (`withKnownObjects`),
28
+ after awaiting the object type so "this field does not exist" is an answer rather
29
+ than a boot race. A draft/preview overlay the caller carries still wins.
30
+ - The degradation is no longer anonymous. `resolveActionParams()` stamps
31
+ `unresolvedField: '<object>.<field>'` on a param whose backing field it could not
32
+ find and says so in dev. This is the half that made the defect invisible for a
33
+ whole version: by the time `paramToField()` sees such a param it IS a `text`
34
+ param, so `paramDegradesWithoutTarget()` answered false, no "no reference target"
35
+ warning fired, and even the "paste a record id" placeholder and help text did not
36
+ apply.
37
+ - `ActionParamDialog` refuses such a param: it renders an alert naming the
38
+ `<object>.<field>` pair in place of the control, logs one line per dialog opening
39
+ in every build, and disables Confirm so the action cannot be launched with a value
40
+ the dialog had no contract to collect.
41
+
42
+ New localized string `actionDialog.unresolvedParam` in all ten locale bundles. The
43
+ `<object>.<field>` locator renders as its own node rather than an interpolation, so
44
+ an identifier is never re-ordered by a translation.
45
+ - e686f4d: A record form can no longer be saved while an upload is still in flight (objectui#10166).
46
+
47
+ A `file` / `image` value only becomes its fileId once the presigned upload settles. Until
48
+ now a record form had no notion of upload state at all, so a Save pressed during that
49
+ window wrote the record WITHOUT the attachment — and reported success. The user picked the
50
+ file, saw it listed and saved; there was no error, no warning, and the record looked saved.
51
+ Whoever noticed did so later, looking at a record that should have a file and does not.
52
+
53
+ `onUploadingChange` (ADR-0059) already carried the signal, and had exactly one consumer,
54
+ `ActionParamDialog`. It could not have a second: that prop is per-widget, and a record form
55
+ hands a `fields` array to the `form` node renderer and never touches a widget, so there is
56
+ no point in the chain where it can attach a callback — and its upload controls can sit
57
+ inside a section, a tab or a line-items subform.
58
+
59
+ `@object-ui/fields` therefore publishes the AGGREGATION beside the prop: `useUploadingScope`
60
+ (the host's "is anything below me uploading") and `UploadingScopeProvider`.
61
+ `useUploadingSignal` — also exported now, for widgets authored outside this repo — feeds
62
+ both sinks from the one call it already made, so the per-widget prop and the scope cannot
63
+ disagree, and a host that mounts no provider is unaffected. A widget that unmounts
64
+ mid-upload releases its slot, so a collapsing section cannot wedge Save shut.
65
+
66
+ Nesting CHAINS rather than shadows: an inner scope gates its own Save AND reports itself to
67
+ the scope above. The direction is forced by `MasterDetailForm`, whose Save persists parent
68
+ and children in one batch while its rows are edited by nested `ObjectForm`s — a gate that
69
+ saw only the parent's uploads would refuse nothing while a child's attachment was in flight
70
+ and would still read as coverage.
71
+
72
+ Every submit owner in `@object-ui/plugin-form` is gated: `ObjectForm`, `ModalForm`,
73
+ `DrawerForm`, `SplitForm`, `TabbedForm`, `WizardForm`, `MasterDetailForm`, and
74
+ `EmbeddableForm` through the `ObjectForm` it hosts. While an upload is in flight each
75
+ refuses the submit (which is also the keyboard-submit guard), labels Save "Uploading…", and
76
+ renders the reason as a sentence — `form.uploadInFlight`, new in all ten locale packs. The
77
+ hosts that own their Save button — `ModalForm`, `DrawerForm`, `MasterDetailForm`, and
78
+ `WizardForm`'s final step — disable it as well; the flat `ObjectForm`, `SplitForm` and
79
+ `TabbedForm` paths submit through the `form` node renderer in `@object-ui/components`, which
80
+ exposes no per-button disable, so there the refusal plus the label and the notice are what
81
+ the user meets.
82
+
83
+ `WizardForm` is gated on its FINAL commit only. Moving between steps writes nothing, so
84
+ `Next` is deliberately untouched — but note that leaving a step unmounts its widgets, so an
85
+ upload in flight is released by the unmount and its value never reaches the record. That
86
+ loss predates this change and is not addressed by it.
87
+ - 5e67837: Translations that `I18nProvider` loads after mount now reach the readers already
88
+ on screen (objectui#10382).
89
+
90
+ **The defect.** Both of the provider's asynchronous loads end in
91
+ `addResourceBundle`: the app bundle from `loadLanguage`, and the built-in
92
+ catalogue for the language the instance booted in. Each was followed by a "force
93
+ re-render" that set the provider's `language` state to the value it already held.
94
+ React bails out of a same-value update, so nothing re-rendered. A reader that drew
95
+ the fallback before the bundle arrived (an object label read through
96
+ `useObjectLabel`, for example) kept drawing the English source label until
97
+ something unrelated re-rendered it, with no error. The console wires
98
+ `loadLanguage` exactly this way, so whether authored translations appeared on
99
+ first load depended on timing.
100
+
101
+ **What changed.**
102
+
103
+ - `useObjectTranslation`, and every hook built on it (`useObjectLabel`,
104
+ `useSafeTranslation`, `useDisplayLocale`), now subscribes each reader to the
105
+ i18next store's `added` event through react-i18next's `bindI18nStore` option,
106
+ passed per call. Every store write hands readers a new `t`, so a reader that
107
+ memoises on `t`, or on a resolver built from it, recomputes too.
108
+ - The two same-value `setLanguage` calls are gone. The one after the app bundle
109
+ also set the context's `language` back to the boot language when the user had
110
+ switched while the bundle was in flight. The context now stays on the language
111
+ i18next is on.
112
+ - The built-in catalogue is written only when the merge adds a key. That effect
113
+ re-runs on every language change, and an unconditional write would re-render
114
+ every reader for nothing.
115
+ - A switch through the context's `changeLanguage` writes its bundles silently,
116
+ because the `languageChanged` that follows re-renders every reader once, in
117
+ the new language.
118
+
119
+ **Behaviour you may notice.**
120
+
121
+ - On first load, a reader re-renders once when the app bundle lands, and once
122
+ more if the built-in catalogue was not resident yet. Work keyed on `t` or on a
123
+ label resolver runs again at that moment. For example, a chart that resolves
124
+ group labels while it fetches will fetch again, so that its labels come from
125
+ the new bundle.
126
+ - A language switch still re-renders each reader once.
127
+ - Host code that calls react-i18next's own `useTranslation` directly, rather
128
+ than `useObjectTranslation`, is not subscribed. It keeps react-i18next's
129
+ default, which re-renders on `languageChanged` only.
130
+
131
+ No export is added or removed. Marked `minor` rather than `patch` because the
132
+ first-load behaviour is observable.
133
+ - ea9d17f: A currency field in `dynamic` mode shows the tenant's currency, not its
134
+ `currencyConfig.defaultCurrency` (objectui#10422).
135
+
136
+ `@objectstack/spec` declares two currency modes: `fixed` (a single currency) and
137
+ `dynamic` (user selectable). Its field guidance says a field without a fixed
138
+ currency uses the tenant default at runtime. `resolveFieldCurrency` read
139
+ `currencyConfig.defaultCurrency` in either mode, so in a USD tenant a field
140
+ declaring `currencyConfig: { currencyMode: 'dynamic', defaultCurrency: 'EUR' }`
141
+ showed `€3,456` where it should show `$3,456`. An empty `currencyConfig`, which
142
+ the spec parses to a dynamic config with `defaultCurrency: 'CNY'`, showed
143
+ `CN¥`.
144
+
145
+ The resolver now reads `currencyConfig.defaultCurrency` only when
146
+ `currencyMode` is `'fixed'`. Its precedence is: the explicit `currency`, then a
147
+ fixed `currencyConfig.defaultCurrency`, then the legacy top-level
148
+ `defaultCurrency`, then the tenant default. With none of these, it returns
149
+ `undefined` and the amount shows as a plain number.
150
+
151
+ **Behaviour change.** Every face that resolves a currency through
152
+ `resolveFieldCurrency` changes together: the list cell, the field widget, the
153
+ metric tile, the detail summary chip, the gantt tooltip, and the grid's
154
+ configured cells and summary footer. On each of them, a field in `dynamic` mode
155
+ now shows the tenant currency. So does a `currencyConfig` that names no
156
+ `currencyMode`, because the spec's default mode is `dynamic`. To keep a field on
157
+ one currency, declare `currencyMode: 'fixed'`. A fixed field renders as before.
158
+
159
+ The parameter type of `resolveFieldCurrency` gains an optional
160
+ `currencyConfig.currencyMode` member (`'dynamic' | 'fixed'`), which is the
161
+ spec's enum.
162
+ - 4357a27: Show the environment admin a storage-capacity banner from the tenant runtime's own
163
+ storage verdict (objectui#10439, the objectui half of cloud#2135).
164
+
165
+ `GET /api/v1/usage/storage` already served the verdict the upload and bulk-import
166
+ guardrail refuses with (`warn` from 80%, `blocked` at 100%) and the two figures
167
+ `usedMb` / `limitMb`. Nothing in the console rendered them, so an environment could
168
+ fill up with no warning and learn it was full only when an upload failed.
169
+
170
+ - **`warn`**: a banner with the used and limit figures, for example "850 MB of
171
+ 1,024 MB used".
172
+ - **`blocked`**: the banner says storage is full and uploads and imports are paused,
173
+ that existing data is untouched, and links to the control plane to upgrade. The link
174
+ is left out on a runtime that names no upstream cloud.
175
+ - **Anything else** (`ok`, `unknown`, `unlimited`, an endpoint that cannot be read or
176
+ answers off-contract) renders nothing.
177
+
178
+ The banner reads the served verdict. It never compares the figures with each other or
179
+ with a threshold, so it shows what the guardrail enforces. It is mounted in
180
+ `ConsoleShell` beside the read-rate report and has the same audience: only a workspace
181
+ admin sees it, and only a workspace admin's session issues the request. The two
182
+ banners share one request to the endpoint. Neither banner is exported from the package
183
+ entry.
184
+
185
+ `@object-ui/i18n` gains the five `console.storageUsage.*` keys in all ten locale packs.
186
+ - 7343376: fix(types,plugin-designer)!: the Studio app wizard saves a document the platform accepts, and an edit keeps the stored `accentColor` (objectui#10867)
187
+
188
+ ⚠️ **BREAKING (authoring)**, marked `minor` under this repository's version-alignment rule (a `major` in the fixed group would move all of it off the `@objectstack` major). Two published `@object-ui/types` members narrow: a navigation separator no longer takes a `label`, and `AppWizardDraft.layout` is removed. A TypeScript literal that writes either no longer compiles. `NavigationItem` is now a union type alias, not an interface. So code that reads `label` off an unnarrowed `NavigationItem` into a `string` slot (its type is now `string | undefined`; objectui#11299, in the same release, widens it to the spec's `I18nLabel` or `undefined`, see `.changeset/11299-types-nav-label-locale-map.md`) also stops compiling, as does code that spreads an entry-only key onto one, and an `interface` that `extends NavigationItem` or augments it (extend `NavigationEntryItem` instead). `objectui validate` now refuses a separator `label` too: the zod mirror's `NavigationItemSchema` refuses every key on a separator that the spec's separator does not declare. The four `appDesigner` layout keys also leave the published `@object-ui/i18n` packs and `DESIGNER_DEFAULT_TRANSLATIONS`, so an application that calls `t()` with one of them now renders the raw key (unless the call passes a `defaultValue`).
189
+
190
+ **Clause-②: yes (narrowing)** — the separator arm of `NavigationItem` loses `label`, and `AppWizardDraft` loses `layout`.
191
+
192
+ - **A separator carries only `type`, `id` and `order`.** `@objectstack/spec`'s separator branch declares exactly those keys, and its `AppSchema` refuses anything else. `NavigationItem` required a `label` on every item, so the wizard's "Add separator" wrote `{ id, type: 'separator', label: '' }`, and the console's create-app and edit-app saves were refused with `422 INVALID_METADATA` (`unrecognized_keys` `['label']` at `navigation.N`). `NavigationItem` is now a union of two arms, discriminated by `type`. `NavigationEntryItem` holds every other nav type and keeps its required `label`. `NavigationSeparatorItem` admits `type`, `id` and `order`, and every other entry key is `?: never` on it. Both arms are exported. Reading an entry-only key off an unnarrowed item still compiles, but its type now includes `undefined` (a `label` is `string | undefined`; objectui#11299, in the same release, makes it the spec's `I18nLabel` or `undefined`, see `.changeset/11299-types-nav-label-locale-map.md`), so passing it where a `string` is required does not. Narrow on `item.type === 'separator'` before relying on `label`. `menuItemToNavigationItem` maps a legacy separator to `{ id, type: 'separator' }` and drops its label. `spec-derived-unions.test.ts` no longer pins the separator `label` as a blocker. It asserts, at both spec tiers, that the separator arm admits the spec separator's keys and no others.
193
+ - **`@object-ui/plugin-designer`: the wizard and `NavigationDesigner` write a separator as `{ id, type }`.** `NavigationDesigner` no longer writes a `label` onto a new separator, and its label, icon and visibility patchers skip one.
194
+ - **`@object-ui/layout` narrows on the separator arm; nothing it renders changes.** `resolveNavItemLabel` answers `''` for every separator. A stored separator carrying a non-empty `label`, which `menuItemToNavigationItem` produced before this change, used to resolve to that label. No renderer asks it for a separator's label. The mobile bottom nav's leaf list, which already skipped separators, is now typed as entries.
195
+ - **`@object-ui/app-shell` narrows the same way; nothing it renders changes.** `useNavPins` registers a favorite only for an entry and leaves a separator as it is, and the Studio sidebar's navigation walk passes a separator through unchanged.
196
+ - **`@object-ui/plugin-designer`: `EditAppPage` keeps the stored branding.** The wizard maintains the logo, primary colour and favicon, and its `branding` replaced the stored block, so a stored `accentColor` was dropped on every edit. The console reads that key. The save now keeps every stored `branding` key the spec's `AppBrandingSchema` declares, read from that schema. The wizard's values win for the keys it maintains. A stored key the spec does not declare is still left out.
197
+ - **The wizard's Layout control is removed, with `AppWizardDraft.layout`.** The spec declares no app `layout`, no console surface reads one, and since objectui#10842 the save wrote none. The control persisted nothing. `EditAppPage` no longer reads a stored `layout` into the draft. The Basic Info step's description now reads "Name, title, and icon".
198
+ - **`@object-ui/i18n`:** the four `appDesigner` layout keys (`layout`, `layoutSidebar`, `layoutHeader` and `layoutEmpty`) are removed from all ten packs, and `appDesigner.stepBasicDesc` no longer names a layout.
199
+
200
+ **Migration:** write a separator as `{ id, type: 'separator' }`, with an optional `order`. Remove `layout` from any `AppWizardDraft` you build. Code that spreads an entry-only key (`label`, `visible`, `requiredPermissions` and the rest) onto a value typed `NavigationItem` narrows it first (`item.type !== 'separator'`) or types it `NavigationEntryItem`: the separator arm refuses those keys. `AppComponentSchema.layout`, the renderer node's own layout strategy, is a different member and is unchanged.
201
+
202
+ Pinned in `packages/types/src/__tests__/app-wizard-separator-layout-10867.test.ts` and `packages/plugin-designer/src/__tests__/AppWizard.specDocument-10867.test.tsx`.
203
+ - 244d516: A grouped grid over a data source that declares no `queryGroupHeaders` now
204
+ **refuses grouping** instead of grouping a page of rows (objectui#10881,
205
+ maintainer ruling F). Grouping is a property of the query (ruling A on
206
+ objectui#7189): a data source that cannot answer the group header query cannot
207
+ group, so the grid names the member it is missing rather than drawing counts it
208
+ cannot know.
209
+
210
+ - **`@object-ui/plugin-grid`** — a grouped `object-grid` that fetches its own
211
+ rows over such a source renders an error panel naming `queryGroupHeaders`
212
+ and issues no row query. It used to fetch one window and group it in the
213
+ browser, so every group count was a page slice and a group whose rows all
214
+ fell past the window was missing. Rows handed in WHOLE — a
215
+ `data: { provider: 'value', items }` block, or a `data` prop from a parent
216
+ view that declares no larger `rowCount` — are still grouped in the browser,
217
+ exactly. A host that hands rows in while declaring them one page of more
218
+ (the external-pagination props: `manualPagination`, `onPageChange` and a
219
+ `rowCount` above the rows it handed) is now refused as well, with a sentence
220
+ of its own: grouping needs every record. It used to have that page grouped as
221
+ if it were whole.
222
+ - **`@object-ui/plugin-list`** — `ListView` makes the same refusal, with the
223
+ same sentence, in place of a grouped grid over such a source, and fetches no
224
+ window for it. It used to fetch one window and hand it to the grid as
225
+ `data`, which the grid grouped as if the rows were whole. Rows handed to
226
+ `ListView` in whole are still handed to the grid.
227
+ - **`@object-ui/i18n`** — two new keys, `grid.grouping.needsHeaderQuery` and
228
+ `grid.grouping.needsWholeRows`, in all ten locale packs.
229
+
230
+ The `Partial` marker described by the pending
231
+ `7189-grouped-grid-partial-disclosure` entry is retired everywhere before any
232
+ release carried it: its three `grid.grouping.partial*` strings, `GroupRow`'s
233
+ `partialLabel` / `partialTitle` props and the notice above the group list are
234
+ gone.
235
+
236
+ **Breaking semantics (declared `minor` per this repo's version policy):** a
237
+ grouped grid that fetches its own rows over a data source without
238
+ `queryGroupHeaders` — `ApiDataSource`, `ValueDataSource`, or a host adapter —
239
+ grouped the page it fetched in the last release, and now renders an error panel
240
+ instead. So does a grouped grid view in `ListView` over such a source. So does
241
+ a grouped grid handed rows by a host that declares them one page of more
242
+ (`manualPagination`, `onPageChange` and a `rowCount` above the rows handed),
243
+ whatever its data source. To keep grouping, either:
244
+
245
+ - implement `queryGroupHeaders` on the data source (the ObjectStack adapter
246
+ does), and let the grid fetch its own rows, or
247
+ - hand the rows in whole (`data: { provider: 'value', items }`, or a `data`
248
+ prop with no `rowCount` above it).
249
+
250
+ Not changed here: while a toolbar search is active, `ListView` over a data
251
+ source that answers the header query still hands a grouped grid its window,
252
+ whose group counts are the window's, because the header query carries no
253
+ search (objectstack#20358).
254
+ - d0fba91: A region-tagged language code reaches the built-in catalogue of its base language (objectui#11326)
255
+
256
+ The built-in catalogues are keyed by base language (`zh`), while the platform
257
+ answers, the Console's language menu offers, and the provider stores full
258
+ BCP-47 tags (`zh-CN`). `isBuiltInLanguage` and `loadBuiltInLocale` looked a code
259
+ up exactly, so `loadBuiltInLocale('zh-CN')` resolved `null`. A user who picked
260
+ 中文(中国) got every built-in string in English on each full page load, with
261
+ only the `en` catalogue fetched, while server-translated labels stayed Chinese.
262
+
263
+ **What changed at the contract.** Three exported registry functions now accept a
264
+ region-tagged code they used to refuse. A code is looked up exact tag first,
265
+ then its base language, through one normaliser. That is the order the cached
266
+ locale seed already used, and the seed now reuses the same function.
267
+
268
+ - `loadBuiltInLocale('zh-CN')` resolves the `zh` catalogue. It is fetched and
269
+ memoised under `zh`, so `zh` and `zh-CN` share one fetch, and
270
+ `getLoadedBuiltInLocales()` stays keyed by catalogue code.
271
+ - `isBuiltInLanguage('zh-CN')` is `true`, and `isBuiltInLocaleLoaded('zh-CN')`
272
+ answers for the `zh` catalogue.
273
+ - A code with no catalogue for the tag or for its base (`xx-YY`, `tlh`) is
274
+ refused exactly as before.
275
+ - A catalogue keyed by a full tag would win over its base language. None ships
276
+ today.
277
+
278
+ **What a host observes.**
279
+
280
+ - A stored `zh-CN` is kept, and kept as written. `I18nProvider` boots in
281
+ `zh-CN` and renders from the `zh` catalogue, and its `language`, the
282
+ document's `lang` attribute and the stored value all stay `zh-CN`. A host with no
283
+ `loadLanguage` loader used to purge a stored `zh-CN` on boot. It no longer
284
+ does.
285
+ - `preloadBootstrapLocale()` fetches the `zh` catalogue for a stored `zh-CN`, so
286
+ the first paint is already Chinese.
287
+ - `changeLanguage('zh-CN')` fetches the `zh` catalogue before it switches.
288
+ - A cached locale seed of `zh-CN` now boots in `zh-CN` instead of being
289
+ rewritten to `zh`, because the `zh` catalogue serves the exact tag. The
290
+ strings are the same. The provider's `language` and the document's `lang`
291
+ attribute read `zh-CN`, the tag the signed-in user's `sys_user.locale` applies a moment
292
+ later.
293
+ - c476be0: The action success toast is composed from the action's `outcomeMessages`, then its `successMessage`, then the runner's default text. A `message` in the server's answer is no longer shown (objectui#11344).
294
+
295
+ `@objectstack/spec` 17.6.0 declares `ActionSchema.outcomeMessages`, the success copy for each handler outcome. Its keys are the snake_case `outcome` values that a `type: 'api'` or `type: 'script'` handler returns in its success payload. This follows ruling A on objectstack-ai/cloud#2315: the server returns facts, and the console writes the sentence in the user's locale.
296
+
297
+ - `@object-ui/core`: the `ActionRunner` success toast now picks its text in this order:
298
+ 1. the `outcomeMessages` entry named by the top-level `outcome` of the handler's answer;
299
+ 2. `successMessage`;
300
+ 3. the runner's default text, in the language of the translator set with `setTranslator`.
301
+
302
+ Rungs 1 and 2 fill in `${result.*}` tokens from the handler's answer. That is the same scope `onSuccess.navigate` reads, and the values go in as text, not percent-encoded. **Behaviour change:** `result.data.message` used to take precedence over both. The runner no longer reads it at all. To show the server's sentence, write `${result.message}` as the copy. `ActionDef` now declares `outcomeMessages`.
303
+ - `@object-ui/react`: `useActionTextLocalizer` resolves each `outcomeMessages` entry that a named action declares. It looks in `_actions.NAME.outcomeMessages.OUTCOME`, then `globalActions.NAME.outcomeMessages.OUTCOME`, then falls back to the authored text. On a named action, an inline `I18nLabel` map on an entry, or on `successMessage`, is collapsed to the active language first. `${result.*}` tokens pass through unchanged, and the runner fills them in after the action runs.
304
+ - `@object-ui/i18n`: `useObjectLabel()` adds `actionOutcome(objectName, actionName, outcome, fallback)`, the resolver for that address.
305
+ - `@object-ui/components`: `action:button`, `action:icon`, `action:group` and `action:menu` now forward `outcomeMessages` to the runner. Before, a registered action rendered through `action:bar` lost the map one hop before the toast. The `action:button` and `action:icon` registrations do not publish it as an input, because their own spec rows do not declare it at 17.6.0.
306
+ - `@object-ui/types`: `UIActionSchema` declares `outcomeMessages`, derived from the spec.
307
+
308
+ `@object-ui/core` and `@object-ui/types` raise their `@objectstack/spec` floor from `^17.5.0` to `^17.6.0`, because their published types now read `ActionSchema.outcomeMessages`, a member the spec first declares in 17.6.0.
309
+ - 64dae8e: Six user-visible fixes across the maker surface, the assistant rail and the
310
+ dataset captions.
311
+
312
+ **The maker's start chips now promise only what ADR-0112 v1 builds
313
+ (cloud#1984).** Two of the five asked for automation the first version has no
314
+ flows or actions for — the ticket chip said 「状态流转」, the inventory chip said
315
+ 「低库存预警」 — and the measured behaviour was not a refusal but a silent
316
+ degrade: a status kanban and a low-stock view. The chip promised an alert and
317
+ delivered a page. All five are reworded in all ten packs (and in the call-site
318
+ `defaultValue` fallbacks, which are a second copy of the same strings) to ask
319
+ for objects, fields, views, pages, dashboards and sample data, keeping each a
320
+ real business scenario — the ticket chip now asks for a status field and a board
321
+ grouped by it, the inventory chip for a view that filters below the reorder
322
+ point. A note beside the keys says to revert when v2 re-adds flows.
323
+
324
+ **Five newer AI tools get their step labels (objectui#7481).** A zh conversation
325
+ read `✓ Get authoring rules 已完成` between 「读取元数据结构」 and 「列出对象」:
326
+ `get_authoring_rules` (cloud#1837), plus `load_tools`, `open_record`,
327
+ `test_flow` and `toggle_flow`, are registered by the cloud AI runtime but are
328
+ newer than the pinned spec's tool registry, so they had no `chatbot.tool.*`
329
+ entry in any pack and fell through to the English title-caser.
330
+
331
+ **The assistant rail follows the thread when you send (objectui#7480).** The
332
+ rail and the full-page maker are the same component; what differs is width. A
333
+ reply that still ends on screen in the wide column runs two or three times
334
+ taller in a ~360px rail, so `StickToBottom`'s lock is escaped by the time the
335
+ user types and the new bubble, the tool steps and the streaming answer all land
336
+ below the fold. Every send path now re-arms the lock — including the plan-card
337
+ "Build it" and 确认修改 approvals, whose own code comments already named this
338
+ miss. Message APPENDS deliberately do not, so a user reading back through the
339
+ thread mid-answer is never yanked to the bottom.
340
+
341
+ **Console toasts move off the assistant composer (objectui#7482).** 「客户更新
342
+ 成功」 sat on the ChatDock composer's send button and stayed there. One defect,
343
+ two symptoms: `apps/console` pinned the toaster to `bottom-right` — an override
344
+ that predates ADR-0057 P3a — so a toast both covered the button and, because
345
+ sonner pauses a toast's dismiss timer while the pointer is inside the toaster
346
+ region, never got to run its 4s timer with a pointer resting on the composer
347
+ underneath. The override is gone; the console takes `ConsoleToaster`'s own
348
+ documented top-right anchor, and the 4s success duration is now pinned.
349
+
350
+ **Built-in aggregate captions follow the locale everywhere (objectui#7534).**
351
+ objectui#7258 taught `buildChartSeries()` to resolve a server-minted default
352
+ measure through the locale map, so a chart legend read `计数` while the table
353
+ beneath it, the KPI caption, the pivot header and the dataset preview still
354
+ printed the server's hard-coded English `Count`. `buildDatasetFieldHelpers()`
355
+ takes the same optional `builtinAggregateLabels`, resolving through the one
356
+ `resolveMeasureLabel` order, and the five call sites pass it. Omitting the
357
+ argument reproduces the previous output byte for byte, and an author-declared
358
+ measure still keeps its own label verbatim (objectui#4106).
359
+
360
+ **The activity feed stops asking for an object the environment does not have
361
+ (objectui#7476).** A tenant environment has no `sys_activity`, so every page
362
+ load issued a request that 404'd. Everything downstream was already correct —
363
+ the adapter memoizes the missing collection, its logger demotes the failure, the
364
+ feed retires as an ANSWER and the panel renders its earned empty state — so what
365
+ is left is the request itself, and `data-objectstack` states the rule for it:
366
+ the cure for a doomed request is not issuing it. New `useObjectPresence` reads
367
+ the object registry the shell loads for the nav anyway; only a registry that has
368
+ ANSWERED and lists other objects without this one skips the read. Every
369
+ uncertainty — no provider, empty registry, still loading, errored — reads as
370
+ before, because a wrong skip would cost a real deployment its feed.
371
+ - d3995fe: Retire 25 confirmed-dead locale keys from all ten packs — 250 translated strings
372
+ with no reader anywhere in the repo (objectui#4730's key-level trim round;
373
+ `calendar.agenda` included).
374
+
375
+ Every key was confirmed individually, not swept from a tool's output. The
376
+ inventory comes from `scripts/check-i18n-dead-keys.mjs`, which stays report-only
377
+ by design, and each candidate then had to clear the objectui#4658 evidence
378
+ standard on its own: zero `t()` call sites, zero textual footprint anywhere
379
+ outside the packs, and a read of its plausible consumer confirming no i18n
380
+ wiring reaches it. Five namespaces held nothing but retired leaves and went with
381
+ them — `map`, `cellRender`, `rowAction`, `recordDetail`, and `home.stats`.
382
+
383
+ The retirements fall into three shapes:
384
+
385
+ - **Superseded twin vocabularies.** `cellRender.*` and `rowAction.*` duplicated
386
+ a `grid.*` vocabulary that won. `RowActionMenu.tsx` is fully i18n-wired and
387
+ reads `grid.openMenu` / `grid.edit` / `grid.delete`; `ObjectGrid.tsx` reads
388
+ `grid.empty` / `grid.yes` / `grid.no` / `grid.systemFields`. The twins had no
389
+ reader on either side.
390
+ - **Labels that outlived their control.** `calendar.agenda` labelled a view mode
391
+ `b55a34647` retired from `CalendarViewMode` (now `'month' | 'week' | 'day'`).
392
+ `home.quickActions.createApp*`, `layout.systemNav.createApp`,
393
+ `actionDialog.defaultActionTitle` / `.ok` and `grid.bulk.selectPlaceholder`
394
+ sit in namespaces whose consumers are live and wired but demonstrably read
395
+ other siblings.
396
+ - **Surfaces that left the product.** `map.*` is the strongest form:
397
+ `@object-ui/plugin-map` declares no `@object-ui/i18n` dependency and contains
398
+ no `t()` call at all, so it cannot consume a locale string. `home.stats.*` and
399
+ `recordDetail.viewersTooltip` name surfaces nothing renders.
400
+
401
+ `packages/i18n/src/__tests__/dead-key-batch-retired-4730.test.ts` pins the
402
+ retirement, following the convention of the five retirement pins already in that
403
+ directory. It is load-bearing rather than decorative: every i18n gate in this
404
+ repo runs call site to key, so a dead key coming **back** into the packs is
405
+ invisible to all of them, and this pin is the only thing watching that direction.
406
+
407
+ **Deliberately NOT deleted, and pinned as live.** Seven `console.*` bootstrap
408
+ strings that this same sweep reported CONFIRMED-dead are in fact **live**, and
409
+ were pulled back out of the batch. `LoadingScreen.tsx` is bootstrap-critical UI
410
+ that must render before i18n loads — precisely when the server is unreachable —
411
+ so it deliberately does not call `t()`. It imports the packs directly and reads
412
+ them as plain object properties (`strings.loadingSteps.connecting`). That
413
+ consumer is invisible to both legs of the sweep: there is no call for the AST
414
+ pass to classify, and the full dotted key is never spelled in source because the
415
+ namespace segment is bound to a local variable. The new pin asserts those keys
416
+ stay, so the next sweep round cannot repeat the mistake.
417
+ - 06b82b8: An action param that declares the spec's `carryOver` is shown read-only and submitted verbatim (objectui#6246)
418
+
419
+ `@objectstack/spec`'s `ActionParamSchema.carryOver` says a param's value is
420
+ carried through the action dialog rather than collected: seeded from the row
421
+ (`defaultFromRow: true` is required beside it), shown as a non-editable summary,
422
+ and submitted unchanged. The console dropped the key: `resolveActionParam()`
423
+ builds its output key by key and never copied it, so the dialog rendered every
424
+ such param as an ordinary editable field. The permission-set Clone action
425
+ declares it on its five JSON permission facets, so each of them — row-level
426
+ security among them — was a prefilled JSON textarea, and a hand edit that was
427
+ still valid JSON produced a clone granting more than its base.
428
+
429
+ - `@object-ui/core`: `ActionParamDef` gains `carryOver?: boolean`.
430
+ - `@object-ui/app-shell`: the resolver copies `carryOver` on all three of its
431
+ branches; `ActionParamDialog` renders a declared carry-over as a collapsed
432
+ read-only summary and builds no field widget for it at all; and
433
+ `serializeParamValues` leaves a carry-over value untouched, even on an upload
434
+ field. The action designer's dialog preview draws a carry-over param as the
435
+ same read-only line instead of its field's input.
436
+ - `@object-ui/i18n`: one new key, `actionDialog.carryOverHint`, in all ten packs.
437
+
438
+ Params that do not declare the key render and submit exactly as before.
439
+ - b362c1b: Setup gains a **Packaged automation** page — the operational surface for the flows an
440
+ installed package ships (ADR-0126 §7.4, objectui#6301). Reached the way every other
441
+ framework-contributed Setup surface is: the page registers the component-registry ref
442
+ `automation:packaged`, so app navigation names the ref and `ComponentNavView` resolves it
443
+ at `/apps/<app>/component/automation/packaged`. No bespoke route is added — a second way
444
+ in would be a URL the app metadata does not know about.
445
+
446
+ Per packaged flow the page does exactly two things:
447
+
448
+ - **on/off for this scope** — reads the activation state the engine reports
449
+ (`GET /api/v1/automation/_status`, backed by the ADR-0126 §7.2 `sys_metadata_activation`
450
+ ledger) and flips it through `POST /api/v1/automation/<name>/toggle`;
451
+ - **clone** — `POST /api/v1/automation/<name>/clone` with a mandatory new machine name and
452
+ label (§7.1). The carried-over definition is never offered as editable form fields; the
453
+ copy is edited in Studio like any other flow.
454
+
455
+ Authoring stays in Studio. The list is scoped to packaged flows by the server's own
456
+ three-clause provenance test (`isCodeArtifactBody`, ADR-0029 D9.6) rather than the
457
+ `_packageId`-only shortcut, which classifies a tenant overlay bound to a package as
458
+ packaged — the cloud#970 misread, and here it would put a tenant's own flow behind an
459
+ install-wide switch.
460
+
461
+ **Server refusals reach the operator verbatim** — no client-side softening or rewording.
462
+ Three shapes are relayed as sent: the §5 posture gate (403 `PERMISSION_DENIED`, whose
463
+ message names the tenancy posture *and* the sanctioned clone path), the §7.3 subflow guard
464
+ (409 `DELETE_RESTRICTED`, which names the packaged callers that would break mid-run — a
465
+ list nothing on the client could reconstruct), and the §7.1 clone name conflict (409).
466
+
467
+ ⛔ **No drift or ancestry surface** (§9): no diff-vs-base, no "customized" badge, no
468
+ base-moved notice, no link from a clone back to its source. Cloned-without-disabled and
469
+ disabled-without-clone are ordinary states, shown plainly. Tests pin the absence, including
470
+ the case where a response carries a `clonedFrom` key anyway — the platform does not track
471
+ that lineage, so a page that displayed it would be displaying something it invented.
472
+
473
+ `@object-ui/i18n` gains the `packagedAutomation` key group — 24 keys in `en` and real
474
+ translations in all nine other packs, matching each pack's existing vocabulary for "flow"
475
+ and "package" (zh 流程/软件包, ja フロー/パッケージ, ko 플로우/패키지, de Flow/Paket,
476
+ fr Flux/Package, es Flujo/Paquete, pt Fluxo/Pacote, ru Поток/Пакет, ar التدفق/الحزمة) and
477
+ each one's quotation convention. The group deliberately carries no drift or ancestry
478
+ wording, and no server refusal text: those arrive as server-authored prose and are
479
+ rendered verbatim.
480
+ - 9602dc8: Setup › **Packaged automation** gains a packaged **actions** section beside its flows one —
481
+ the surface half of ADR-0126 §8 item 2 (objectui#6412; the engine, ledger and both dispatch
482
+ doors landed in objectstack#12348). The maintainer's pull, verbatim and untranslated:
483
+ 「动作 可能是需要开关的,因为有的 action 我不想启用。」
484
+
485
+ Per packaged action the section does exactly **one** thing: **on/off for this scope**. That
486
+ is all the `sys_metadata_activation` ledger knows about an action, and the section claims
487
+ nothing more.
488
+
489
+ - **State** comes from the ledger's `metadata_type: 'action'` rows, read through the generic
490
+ data API list the object itself sanctions for exactly this
491
+ (`apiMethods: ['get', 'list']` — *"Reads stay open so operability surfaces can answer
492
+ 'what is disabled here?'"*). **Absence of a row means active**, so a stock boot shows
493
+ everything armed.
494
+ - **Discovery** mirrors the runtime's own `collectActionDeclarations`: object-embedded
495
+ `actions[]` from `GET /meta/object` **plus** standalone items from `GET /meta/action`, with
496
+ the object-embedded declaration winning a `<object>:<action>` key clash. Listing only the
497
+ first source would leave an administrator with no off-switch for a packaged standalone
498
+ action.
499
+ - **Flips** invoke the L6 write door `POST /actions/_activation/:object/:action` with exactly
500
+ the one key its body declares (`{ enabled }`); `global` is the object segment for an
501
+ object-less action, the same spelling both dispatch doors take.
502
+
503
+ **⛔ No clone for actions.** The flows section keeps its own (§7.1); amendment ruling 3
504
+ charters the *switch* for actions and nothing else, and §8 keeps the clone half pre-chartered
505
+ until real pull appears. A clone control here would advertise machinery that does not exist —
506
+ which is also why the server's §5 refusal for actions recommends the platform operator and an
507
+ ordinary sibling action where the flow refusal recommends a clone.
508
+
509
+ **⛔ No drift or ancestry surface** (§9): no "customized" badge, no diff-vs-base, no
510
+ base-moved notice. The platform tracks no such lineage, so a surface showing it would be
511
+ showing something it had to invent. The absence is pinned against a response that smuggles
512
+ `clonedFrom` / `baseVersion` in, so it is enforced at the renderer and not merely by the wire.
513
+
514
+ Server refusals reach the operator **verbatim** — no client-side softening, no retry loop.
515
+ Three shapes are reachable in tests, each transcribed character-for-character from the
516
+ runtime's own message builders: the §5 posture gate (403 `PERMISSION_DENIED`, naming the
517
+ posture *and* the sanctioned path), the ambiguous-name refusal (409 `RESOURCE_CONFLICT`,
518
+ naming the objects a machine name collides across — a list nothing on the client could
519
+ reconstruct) and the no-ledger outage (503 `SERVICE_UNAVAILABLE`).
520
+
521
+ One further refusal is the section's own and it points the same way: a `hasMore` on the
522
+ ledger read is treated as a **load failure** rather than rendered. A dropped row reads as
523
+ "active", so a partial ledger would show a switched-off action as armed — the one direction
524
+ this section must not fail in.
525
+
526
+ The flows section is unchanged in behaviour. It gains a heading beside the new one, and the
527
+ page subtitle now says "Flows and actions" — that string moved in all ten packs together.
528
+ Nine new `packagedAutomation.*` keys land in `en` **and in all nine other packs** as real
529
+ translations; the two toggle-failure keys are artifact-neutral by wording and are reused
530
+ rather than duplicated.
531
+ - 969ba84: Renderers for the `app:launcher` and `nav:menu` page blocks.
532
+ Phase 1 of the 2026-08-26 maintainer ruling on objectstack#12183 — the two
533
+ `PageComponentType` members that are purely metadata-driven, so nothing had to
534
+ ship before their renderers could. Phase 2 (`global:search` /
535
+ `global:notifications`) landed in `f99932a42` and set the pattern this
536
+ follows.
537
+
538
+ A page that declared either member drew a dashed box. The two symptoms were not
539
+ the same, which is worth recording because it decides what "fixed" looks like
540
+ for each:
541
+
542
+ - `nav:menu` is in `PALETTE_PLACEHOLDER_BLOCKS`, registered eagerly, so it drew
543
+ the literal "Component Placeholder" scaffold in every host.
544
+ - `app:launcher` is only in `PROTOCOL_COMPONENTS`, registered when a host opts
545
+ in via `registerPlaceholders()` — which just `apps/console` does. So it drew
546
+ the scaffold in the console and `SchemaRenderer`'s red OBJUI-001 "Unknown
547
+ component type" panel everywhere else.
548
+
549
+ Neither block adds a data layer — each mounts plumbing that was already live,
550
+ and neither issues a request or touches an adapter:
551
+
552
+ - `app:launcher` reads the metadata app registry (`useMetadata().apps`, which
553
+ `MetadataProvider` fetches eagerly) through the shared `filterActiveApps`
554
+ predicate, and draws it with `HomeAppsStrip` — the console's own launcher
555
+ grid — so an authored launcher and the Home launcher cannot drift into two
556
+ looks for one thing.
557
+ - `nav:menu` reads the active app's navigation tree from that same registry and
558
+ renders it as page content, taking every derived fact from `@object-ui/layout`:
559
+ hrefs from `resolveHref`, labels from `resolveNavItemLabel`, the active row
560
+ from `resolveActiveNavItem`, and the item-level guards (`visible`,
561
+ `requiredPermissions`, `requiresObject` / `requiresService`) in the order
562
+ `NavigationItemRenderer` applies them, wired to the same console providers
563
+ `UnifiedSidebar` wires them to. `action` items dispatch through
564
+ `useNavActionDispatch`, so framework#4509's "renders but dead-clicks" shape is
565
+ not reintroduced.
566
+
567
+ `nav:menu` does not mount `NavigationRenderer` itself: that renders through
568
+ `SidebarMenuButton`, whose `useSidebar()` throws outside the shell's
569
+ `SidebarProvider`, and a page block has to render standalone. `@object-ui/layout`
570
+ therefore exports `resolveNavItemLabel`, which was module-private — an additive
571
+ export with no behaviour change, so the sidebar and an authored menu cannot show
572
+ one nav entry under two names.
573
+
574
+ Both registrations publish **no** `inputs`: `ComponentPropsMap` declares an empty
575
+ shape for each, and both use `skipFallback: true` so neither claims the bare
576
+ `launcher` / `menu` keys. This does not change the Studio page palette —
577
+ `app:launcher` remains recorded there as a shell singleton, which is a palette
578
+ decision independent of whether a declared type renders.
579
+
580
+ Three new strings — the launcher's and the menu's accessible names, and the
581
+ menu's empty state — are declared under `console.nav` in `en.ts` and its nine
582
+ sibling packs. An inline `defaultValue` alone is not a fix: it renders English
583
+ at one call site and leaves the string untranslatable everywhere
584
+ (objectui#3517).
585
+ - 220c18d: Dashboard/analytics widgets get a self-explaining DEFAULT empty state, stated
586
+ once for the surface (objectui#7063).
587
+
588
+ Maintainer ruling 2026-08-31 (hotcrm#1212, following hotcrm#1203): a widget that
589
+ renders a bare row-placeholder on an empty result is the PLATFORM's defect and
590
+ must be fixed uniformly — apps must not compensate widget by widget
591
+ (objectstack#13848). The measured scenario is a fresh flagship-demo install:
592
+ eleven populated tiles and one reading exactly `暂无数据行` mid-page, which reads
593
+ as "the dashboard failed to load" even though the widget, its declaration and
594
+ its (not yet produced) data are all legitimate.
595
+
596
+ - New `WidgetEmptyState` is the seam the three dashboard surfaces now share.
597
+ There was no shared placeholder to fix: `DatasetWidget` wrote
598
+ `dashboard.noRows`, while `ObjectDataTable` and `PivotTable` wrote
599
+ `dashboard.noDataAvailable` — three renders, two strings, no common code.
600
+ - The default now reads as a STATE, not a failure: `role="status"` (the empty
601
+ branches previously carried no role at all, while the failure branches beside
602
+ them are `role="alert"`), muted treatment with an inbox glyph rather than a
603
+ warning triangle, and a title plus an explanation where the placeholder was a
604
+ single terse fragment.
605
+ - It names WHAT is empty with zero authored copy — the widget's data source,
606
+ which is the half the reader cannot already see (the tile's title is rendered
607
+ by the card header directly above). That is `widget.dataset` on the dataset
608
+ path and `schema.objectName` on the object-bound table/pivot; `PivotTable`
609
+ takes it as a new optional `sourceLabel` prop, which `ObjectPivotTable`
610
+ forwards.
611
+ - Copy is platform i18n: `dashboard.empty.title` / `.message` / `.sourceLabel`
612
+ added to `en` and all nine sibling packs. No inline `defaultValue` and no
613
+ interpolation — the source renders as a labelled value, so no separator is
614
+ concatenated in code and every pack spells its own punctuation.
615
+
616
+ No new authoring obligation and no new spec key. Note that the `emptyState`
617
+ override the card assumes for this surface does not exist: `emptyState` is a
618
+ LIST-view contract, and `@objectstack/spec`'s `DashboardWidgetSchema` declares no
619
+ such key — so there is nothing here for an author to override, and adding one
620
+ would be a contract question rather than a rider.
621
+ - 8952395: A grouped grid now says, where the group counts are, that it grouped a **page**
622
+ (objectui#7189).
623
+
624
+ `useGroupedData` buckets the rows the browser already holds and computes every
625
+ per-group aggregate from that same array, so both the set of groups and every
626
+ number in a group header are properties of the fetched page, not of the query.
627
+ That is a correct implementation of client-side grouping and is **unchanged**
628
+ here — what was missing is any statement that client-side grouping is what you
629
+ are looking at. Measured on a 186-record store distributed 86 / 61 / 31 / 7 / 1
630
+ across five business units with a 100-row page: with contiguous rows the grid
631
+ rendered **two** group headers (`86`, `14`) and three units were absent from
632
+ the screen entirely; with interleaved rows all five resolved but every count
633
+ was a page slice (31 / 31 / 30 / 1 / 7). Nothing on screen said either.
634
+
635
+ The paging footer is not a statement about what was grouped, and it
636
+ demonstrably did not prevent the wrong reading — a wrong number invites a
637
+ second look, an absent row invites none. So the disclosure goes where the
638
+ authoritative-looking number is:
639
+
640
+ - a short `Partial` marker beside **every** group count, at every nesting
641
+ depth, carrying the full sentence as its `title` and its accessible name;
642
+ - one line directly above the group list, inside the grouped region rather
643
+ than in the footer: *"Grouped over the first 100 of 186 records. Group counts
644
+ are page-scoped, and a group whose records all fall beyond the loaded rows is
645
+ missing here."*
646
+
647
+ The trigger is the strongest thing the component can actually know, and the
648
+ wording never outruns it. With a real match total to compare against
649
+ (`resolvedTotalMatching` — the one derived value the pager and both bulk-bar
650
+ sites already read, reached either from the grid's own fetch or from a host's
651
+ `rowCount`) it states the fact with both numbers. With no total but a window
652
+ that came back full it may only say *"more may match"* — the same inference
653
+ `plugin-list`'s own footer draws when no total is known. Rows handed in inline
654
+ are not a page and are never marked, and **a grouped grid whose result set fits
655
+ in one page shows nothing at all**: the marker is conditional, which is what
656
+ makes it worth reading.
657
+
658
+ Server-side grouping — the durable fix — is deliberately NOT part of this. It
659
+ is an API-surface decision still open on objectui#5560, and nothing here builds
660
+ toward it or changes the fetch.
661
+
662
+ `@object-ui/i18n` carries the three new `grid.grouping.*` strings across all
663
+ ten locale packs; `GroupRow` gains two optional props (`partialLabel`,
664
+ `partialTitle`) and is otherwise unchanged. No metadata schema key was added:
665
+ the condition is derived from data the grid already has.
666
+
667
+ ⚠️ **Dated note, 2026-09-28 — grid grouping is now server-side where the data
668
+ source can answer the group header query — objectui#7189, PR objectui#10878.**
669
+ The paragraphs above describe this change as it was written. Later in this same
670
+ release three of their sentences stopped describing the grid: that
671
+ `useGroupedData` buckets the rows the browser already holds, so the set of
672
+ groups and every header number are properties of the fetched page; that
673
+ client-side grouping is **unchanged** here; and that server-side grouping is not
674
+ part of this, is still open on objectui#5560, and that nothing here builds
675
+ toward it or changes the fetch. Maintainer ruling A on objectui#7189 settled that
676
+ question. On a data source that declares `queryGroupHeaders`, a grouped grid
677
+ that fetches its own rows now takes its groups, every group count and every
678
+ per-group aggregation from the query, and pages each group's rows on the
679
+ server; its counts are the query's own, so the `Partial` marker and the notice
680
+ described above never render there. On a data source without
681
+ `queryGroupHeaders`, the grid still groups the page it fetched in the browser,
682
+ as described above, and the marker and the notice are kept there. The rest of
683
+ this entry is kept as the reading of this change; the
684
+ `7189-server-side-grid-grouping` entry states what a grouped grid does now.
685
+
686
+ ⚠️ **Dated note, 2026-09-28 — the `Partial` marker is retired before release —
687
+ objectui#10881.** Later in this same release the marker and the notice described
688
+ above were retired, with the three `grid.grouping.partial*` strings and
689
+ `GroupRow`'s `partialLabel` / `partialTitle` props that carried them (maintainer
690
+ ruling F). A grouped grid that fetches its own rows over a data source that
691
+ declares no `queryGroupHeaders` no longer groups the page it fetched: it
692
+ refuses grouping with an error naming that member. So the last sentences of the
693
+ note above — that on such a source the grid still groups the page it fetched,
694
+ and that the marker and the notice are kept there — no longer describe the
695
+ grid. The rest of this entry is kept as the reading of this change; the
696
+ `10881-grouping-needs-header-query` entry states what ships.
697
+ - 0348bc9: **BREAKING:** `@object-ui/plugin-detail` no longer exports seven components that
698
+ nothing registered and nothing mounted (objectui#7192, objectui#7175). This
699
+ narrows the package's published surface. It is declared `minor`, not `major`,
700
+ because this fixed release group follows the `@objectstack` major (AGENTS.md
701
+ §9); the breaking change is stated here instead.
702
+
703
+ Removed, with every type exported beside them:
704
+
705
+ - `CommentInput`, with `CommentInputProps`
706
+ - `DiffView`, with `DiffViewProps`, `DiffFieldType`, `DiffMode` and `DiffLine`
707
+ - `InlineCreateRelated`, with `InlineCreateRelatedProps`,
708
+ `RelatedFieldDefinition` and `RelatedRecordOption`
709
+ - `MentionAutocomplete` and the helper `createMentionFromSuggestion`, with
710
+ `MentionAutocompleteProps` and `MentionSuggestionItem`
711
+ - `PointInTimeRestore`, with `PointInTimeRestoreProps` and `RevisionEntry`
712
+ - `RecordNavigationEnhanced`, with `RecordNavigationEnhancedProps`
713
+ - `RelationshipGraph`, with `RelationshipGraphProps` and `GraphNode`
714
+
715
+ Why they went: none of the seven was passed to `ComponentRegistry.register`, so
716
+ metadata had no type string to name any of them, and none was mounted anywhere
717
+ in this repository. The downstream readings agree: `cloud` references none of
718
+ the seven names (objectstack#14187), and `hotcrm` depends on no `@object-ui/*`
719
+ package at all. The maintainer ruled to delete them; the ruling is relayed on
720
+ objectui#7192.
721
+
722
+ Not affected: `RichTextCommentInput` and its `MentionSuggestion` type (the
723
+ composer `RecordActivityTimeline` mounts), `RecordComments` (`DetailView`
724
+ mounts it) and `extractMentions` all stay exported.
725
+
726
+ `@object-ui/i18n`: the `detail.*` keys that only these components read are
727
+ removed from all ten locale packs, and the matching rows from
728
+ `DETAIL_DEFAULT_TRANSLATIONS`:
729
+
730
+ - `PointInTimeRestore`'s ten, added by objectui#7163: `revisionHistory`,
731
+ `noRevisions`, `revisionFieldsChanged`, `revisionFieldsChangedOne`,
732
+ `revisionPreview`, `revisionSnapshot`, `restoreConfirm`, `restoring`,
733
+ `confirmRestore`, `restoreToPoint`
734
+ - `emptyValue`, an older key whose last reader was `PointInTimeRestore`
735
+ - `DiffView`'s five: `unifiedDiff`, `sideBySideDiff`, `noChanges`,
736
+ `previousVersion`, `currentVersion`
737
+ - `RecordNavigationEnhanced`'s six: `firstRecord`, `previousRecordKey`,
738
+ `nextRecordKey`, `lastRecord`, `searchWhileNavigating`, `searchRecords`
739
+
740
+ Keys these components shared with surfaces that stay (`detail.recordOf`,
741
+ `detail.noRecords`, `detail.cancel`, `detail.activityEmptyValue`, the
742
+ relative-time keys) are kept. A host that read one of the removed keys itself
743
+ now gets the raw key back.
744
+
745
+ Other entries in this same release describe work on `PointInTimeRestore` and
746
+ `DiffView` (their translation and display-locale fixes). That work shipped in
747
+ components this entry removes.
748
+
749
+ **Migration:** there is no replacement. If you import one of these components,
750
+ copy its source file (`packages/plugin-detail/src/`, the file named after the
751
+ component) from a release tag that still ships it, for example
752
+ `@object-ui/plugin-detail@17.5.0`, into your own code. If you need one of them
753
+ back in the package, open an issue that says who uses it and where.
754
+ - a4514e8: Retire `useObjectLabel().viewDescription()` and the `_views.<view>.description`
755
+ catalog convention it resolved (objectui#7219, maintainer ruling 2026-09-02,
756
+ option B — enforce-or-remove).
757
+
758
+ **Breaking for translation bundles, deliberately — and this text is the notice.**
759
+ Out-of-repo translation bundles that authored
760
+ `<ns>.objects.<objectName>._views.<viewName>.description` cannot be seen from
761
+ this repo, so there is no census to point at and no migration script to run:
762
+ that key now resolves nowhere, and an entry left under it is simply ignored.
763
+ Nothing throws, and nothing else on that node changes.
764
+
765
+ **What replaces it.** A list view's description has exactly ONE channel: the
766
+ `I18nLabel` value authored on the view entry itself — a string, or an inline
767
+ locale map:
768
+
769
+ ```ts
770
+ listViews: {
771
+ by_unit: {
772
+ label: 'By business unit',
773
+ description: { en: 'Open work only.', 'zh-CN': '仅未完成的工作。' },
774
+ },
775
+ }
776
+ ```
777
+
778
+ `ObjectView` relays that value to the renderer and `plugin-list`'s `ListView`
779
+ resolves it against the display locale (objectui#7199, shipped before this
780
+ change), so the authored channel already works end to end. **Migration:** move
781
+ the sentence out of the translation bundle and onto the view entry as a locale
782
+ map.
783
+
784
+ **Why removed rather than wired in.** The member was declared and resolved but
785
+ had zero callers and zero in-repo bundle usage — an entry authored under the
786
+ catalog key reached no screen. Wiring it in would have put two vocabularies on
787
+ one concept (`I18nLabel` on the entry, and the catalog key) and required a
788
+ precedence rule between them, which is the ambiguity rather than the fix.
789
+
790
+ The two sibling members on the same node are **unaffected**: `viewLabel` and
791
+ `viewEmptyState` still resolve `_views.<view>.label` and
792
+ `_views.<view>.emptyState.{title,message}`, and the shared `viewSuffixes` key
793
+ builder they use is unchanged — only the `'description'` tail is gone. Pin tests
794
+ in `@object-ui/i18n` and `@object-ui/app-shell` were retargeted onto those two
795
+ survivors plus a case that authors the catalog `description` and asserts the
796
+ authored value is what a consumer resolves.
797
+ - 47547d0: Localize the server's built-in aggregate measure titles on dataset charts
798
+ (objectui#7258 — consumer half of the objectstack#14492 contract; maintainer
799
+ ruling B, 2026-09-02).
800
+
801
+ A dataset-bound chart's aggregate axis / legend title read the analytics
802
+ service's hard-coded English `Count` on a zh console whose category labels were
803
+ already Chinese. The renderer was passing `fields[].label` through verbatim —
804
+ correctly, for an author-declared measure (objectui#4106) — and had no way to
805
+ tell the server's built-in default apart from an author's label.
806
+
807
+ The wire now can: `AnalyticsResult.fields[]` gains an OPTIONAL structural
808
+ discriminator, `builtinAggregate?: 'count' | 'sum' | 'avg' | 'min' | 'max' |
809
+ 'count_distinct'`, populated only on the server-side built-in defaults
810
+ (objectstack#14492). This change is the consumer side of that contract:
811
+
812
+ - `@object-ui/core`: `buildChartSeries` now accepts `ChartMeasureField[]` —
813
+ `ChartResultField` plus the optional `builtinAggregate` carrier
814
+ (`BuiltinAggregateCarrier`), declared beside the renderer shape rather than
815
+ on it because the spec this release is built against does not carry the key
816
+ yet; new `BUILTIN_AGGREGATES` / `BuiltinAggregate` / `isBuiltinAggregate` /
817
+ `resolveMeasureLabel`; `ChartSeriesOptions.builtinAggregateLabels` carries
818
+ the locale strings in (core stays React-free and i18n-free — the same
819
+ division as `nullCategoryLabel`). A field carrying a recognised
820
+ discriminator resolves through that map; every other field keeps its wire
821
+ `label` verbatim — never by matching the label's text or the field's name
822
+ (the rejected option A).
823
+ - `@object-ui/i18n`: `builtinAggregateLabels(tt)` resolves the six strings
824
+ through the existing `report.aggregate.*` keys (zh already carried 计数 /
825
+ 求和 / 平均 / …; all ten packs are pinned to cover the vocabulary).
826
+ - `plugin-charts` (`ObjectChart`), `plugin-dashboard` (`DatasetWidget`),
827
+ `plugin-report` (`DatasetReportRenderer`): pass the resolved map to
828
+ `buildChartSeries`.
829
+
830
+ Before: 合作中 / 已流失 / 潜在 under an axis titled `Count`. After: the same
831
+ chart titled `计数`; an `en` session still reads `Count`; an author-labelled
832
+ measure (`Tasks`) and a measure literally named `count` without the
833
+ discriminator are byte-for-byte unchanged. Until the upstream field is
834
+ populated the wire carries no discriminator and every chart renders exactly as
835
+ before.
836
+ - 20cb8db: `ObjectTimeline` refuses an undeclared date axis instead of inventing one
837
+ (objectui#7459).
838
+
839
+ Steps ① and ② of the three-step sequence the maintainer ruled on 2026-09-01
840
+ (总监批 #28). House posture, on record with that ruling:
841
+ 日期轴永不虚构 — a date axis is never fabricated.
842
+
843
+ `ObjectTimeline` resolved its date axis through five declared bindings and then
844
+ closed the chain with a sixth rung that was a bare literal field name nobody
845
+ has ever declared. A name therefore ALWAYS resolved: for a view that declared
846
+ no axis, every record read a key its object does not carry, every event landed
847
+ in the "No date" bucket, and the screen read as a timeline that had been built
848
+ and simply had nothing in it.
849
+
850
+ Two changes, shipped together because neither is observable alone:
851
+
852
+ - **The renderer now refuses.** An object-bound timeline with no declared date
853
+ axis renders a diagnostic naming every binding it accepts —
854
+ `timeline.startDateField`, `timeline.dateField`, `mapping.date`, and the two
855
+ deprecated flat spellings — instead of a chart. The twin of `ObjectGantt`'s
856
+ "Gantt configuration required" screen, in the shape `5f4514f7b` settled.
857
+ - **The invented sixth rung is gone**, which is the only thing that makes the
858
+ refusal reachable. Added while the floor stood, it would have been dead code;
859
+ retired without the refusal, it would have produced exactly the silent
860
+ "No date" outcome the ruling rejects.
861
+
862
+ **What changes for an author.** A view that declares a date axis is completely
863
+ unaffected — all five declared spellings resolve exactly as before, and a
864
+ timeline authored from literal `items` is never refused, since its items carry
865
+ their own dates and no field name is read for them. A view that declared no
866
+ axis anywhere, and was rendering an empty-looking timeline, now says so.
867
+
868
+ ⚠️ Both premises were RE-MEASURED on the current tree before anything was
869
+ edited, rather than taken from the card: the renderer had no absent-axis
870
+ refusal (against a live control term that fires in the same file), and the
871
+ floor was still present and still spelled as reported. The pairing itself is
872
+ pinned — the refusal cases go red the moment the floor returns, including one
873
+ whose records carry a column that happens to be named `date`, where a returned
874
+ floor renders a convincing timeline rather than an empty one.
875
+
876
+ Refusal is distinguished from "renders an empty timeline" by asserting the
877
+ canvas is ABSENT, not merely event-free. The component's success surface is now
878
+ named (`data-testid="timeline-canvas"`) so that distinction can be measured;
879
+ every other terminal state of the component already named itself.
880
+
881
+ Step ③ of the ruling — the `'created_at'` floors on the two plugin faces —
882
+ stays for a later change (it landed as `04a67b9dc`) and is deliberately NOT in this change.
883
+ - 77b2a18: Load locale catalogues on demand — the ten packs leave the eager closure (objectui#7479)
884
+
885
+ `@object-ui/i18n`'s entry re-exported all ten catalogues and `builtInLocales`
886
+ named every one of them in a single object literal, so every import of the
887
+ package made all ten statically reachable and nothing could tree-shake them.
888
+ The console fetched **456,196 gzipped bytes** of translation data before first
889
+ paint, for a viewer who reads one catalogue — and every new translation key was
890
+ paid for by every visitor in every language.
891
+
892
+ **What changed at the contract.** `en` stays statically re-exported from the
893
+ entry; the other nine are fetched per locale through the new
894
+ `loadBuiltInLocale()`. `en` is not an exemption, it is what makes the split
895
+ safe: it is `fallbackLng`, it is the source of the `TranslationKeys` type, and
896
+ it is the synchronous dictionary `@object-ui/app-shell`'s splash renders from
897
+ before i18n is usable and on the server-down path. Removing it would have meant
898
+ raw keys on screen in nine languages, or English literals in the splash — the
899
+ "delete or untranslate the copy" route this work was explicitly not allowed to
900
+ take.
901
+
902
+ New on the entry, all of them payload-free:
903
+
904
+ - `loadBuiltInLocale(code)` — fetch one catalogue; `null` for a code this
905
+ package does not ship. Memoised, and a failed fetch stays retryable.
906
+ - `getLoadedBuiltInLocales()` / `isBuiltInLocaleLoaded(code)` — what is resident
907
+ right now, synchronously.
908
+ - `BUILT_IN_LANGUAGE_CODES` / `isBuiltInLanguage(code)` — enumerate all ten
909
+ without fetching any of them.
910
+ - `preloadBootstrapLocale(options)` / `resolveBootstrapLanguage(options)` —
911
+ resolve the boot language and fetch its catalogue BEFORE the first render.
912
+ - `pickInitialLanguage(config)` — the language `createI18n` will boot in.
913
+
914
+ `I18nProvider` fetches the catalogue for whatever language it boots into, and
915
+ `changeLanguage()` awaits the new catalogue before switching, so a switcher
916
+ needs no new wiring.
917
+
918
+ **BREAKING for an importer of a specific pack.** `zh`, `ja`, `ko`, `de`, `fr`,
919
+ `es`, `pt`, `ru`, `ar` and `builtInLocales` are no longer exported from
920
+ `@object-ui/i18n`. They are exported from the new `@object-ui/i18n/locales`
921
+ subpath, which is the explicit all-ten door — a specifier whose cost is visible
922
+ at the import site. ⚠️ Never reach for it on a page-load path. (Marked `minor`
923
+ rather than `major` per this repository's version-alignment rule; the breaking
924
+ semantics are stated here.)
925
+
926
+ **What a page load costs now.** Two full console builds in one container, same
927
+ instrument, `origin/main` `d8b4739d4` against this change:
928
+
929
+ | reading | control | after | delta |
930
+ |-----------------------------|----------:|----------:|---------:|
931
+ | eager closure, gzipped | 3,575,370 | 3,164,817 | −410,553 |
932
+ | eager chunks / total | 50/518 | 51/528 | |
933
+ | catalogues in that closure | 10 | 1 | −9 |
934
+ | `vendor-objectstack` | 1,236,315 | 1,236,315 | 0 |
935
+ | `ui-components` | 394,726 | 394,718 | −8 |
936
+ | `framework` | 80,414 | 80,430 | +16 |
937
+
938
+ The last three rows are the control that makes the first one readable: the bytes
939
+ did not move to a roomier chunk, they left the eager closure. The nine deferred
940
+ catalogues weigh 405.5 KB gzipped and the browser fetches exactly one of them,
941
+ only when the viewer's locale is not `en`.
942
+
943
+ **Gates.** `PER_CHUNK_GZIP_CEILINGS['i18n-locales']` (465,000 over ten
944
+ catalogues) is replaced by `['i18n-locale-en']` at 50,000 over 40,415, and
945
+ `MAX_EAGER_CLOSURE_GZIP_BYTES` comes down from 3,597,000 to 3,210,000 — both
946
+ LOWERINGS that follow the measured drop, and the aggregate one is required: left
947
+ where it was, its headroom would have been 4.74x the regression this gate must
948
+ catch and the sensitivity half would have called the gauge blind. The
949
+ `i18n-locales` entry in `EXHAUSTED_HEADROOM_ALLOWANCES` is removed rather than
950
+ lowered — its chunk no longer exists, and the catalogue that remains clears the
951
+ floor on its own.
952
+
953
+ A new gate, `pnpm check:eager-locale-catalogues`, weighs COMPOSITION rather than
954
+ bytes: exactly one catalogue may be in the built eager closure, and it must be
955
+ the resident one. A byte ceiling cannot catch the catalogues returning one at a
956
+ time inside its headroom, and cannot tell "left the closure" from "moved to a
957
+ chunk with more room".
958
+
959
+ **Correction, 2026-10-01 (objectui#11326).** The list above says
960
+ `loadBuiltInLocale(code)` resolves `null` for a code this package does not ship,
961
+ and pairs `isBuiltInLanguage(code)` with the ten codes. That was true when this
962
+ change was written, and it no longer is: objectui#11326 looks a code up exact
963
+ tag first, then its base language, so `loadBuiltInLocale('zh-CN')` resolves the
964
+ `zh` catalogue and `isBuiltInLanguage('zh-CN')` is `true`. A code with no
965
+ catalogue for the tag or for its base still resolves `null`, and
966
+ `BUILT_IN_LANGUAGE_CODES` still lists the ten.
967
+ - c2f0f48: feat(console): a "Language" item on the profile page, writing the signed-in user's own
968
+ `sys_user.locale` (objectui#7501).
969
+
970
+ The platform made that column user-writable on 2026-09-03 and nothing in the product let
971
+ anyone set it — the profile page edited `name` and the avatar and carried no language
972
+ control at all, which is the `{name, image}` whitelist the ruling widened. Two people in
973
+ one deployment were already receiving their notifications in different languages with no
974
+ way for either of them to choose.
975
+
976
+ The new card reads and writes the user's own `sys_user` row through the data adapter, and
977
+ it is deliberately a card of its own rather than another field in the Personal Information
978
+ form: that form is written by `useAuth().updateUser`, which posts to better-auth's
979
+ `/update-user`, and `locale` is not a better-auth `additionalFields` entry, so that
980
+ endpoint cannot carry the column. Its feedback vocabulary is the form's — the same success
981
+ and failure `Alert`s, the same disabled-while-saving submit.
982
+
983
+ What it offers is the i18n provider's own `offerableLanguages` (the deployment's published
984
+ locales intersected with what this renderer can resolve) — the same set `LocaleSwitcher`
985
+ renders, so no second language list is introduced. Each entry is named in its own language.
986
+ A stored tag the deployment no longer publishes is shown rather than dropped, so the
987
+ control never displays a value that is not the account's.
988
+
989
+ Clearing is a first-class option: "Use the deployment default" writes `null`, which is the
990
+ documented meaning of an unset column and the only way back once a tag has been stored.
991
+
992
+ A refused tag (`400 VALIDATION_FAILED` with a per-field `locale` entry) is rendered on the
993
+ item, with `aria-invalid` and `aria-describedby` pointing at it, rather than as an
994
+ undirected alert. When the deployment's write route is not this user's — the field/object
995
+ permission answer for `sys_user.locale` — the control is read-only and says why instead of
996
+ offering a button that would answer 403, and it does not render at all while the row is
997
+ unread or if the read is refused.
998
+
999
+ **This does not change the interface language.** The UI language is a per-device
1000
+ preference the i18n provider keeps in `localStorage` and the globe menu switches;
1001
+ `sys_user.locale` is a per-user server column read per recipient at delivery time. The two
1002
+ stayed separate here on purpose; whether they should be joined is a product decision that
1003
+ has not been made.
1004
+
1005
+ `@object-ui/i18n` gains the seven `profile.language.*` keys, in all ten packs.
1006
+ - 23b9958: `RecipientPickerField` gains a picker mode for the `field` sharing recipient
1007
+ (objectui#7613; maintainer ruling objectstack#14103, executor objectstack#15072).
1008
+
1009
+ `sys_sharing_rule.recipient_type` carries a sixth value, `field` — the
1010
+ record-relative recipient. The recipients are whoever a user-valued column of the
1011
+ SHARED object names on each matched record, so `recipient_id` stores a field
1012
+ NAME (`assignees`, `owner_manager`) and not a record id. The widget's
1013
+ `TYPE_TO_OBJECT` table had no entry for that kind, so it fell through to the
1014
+ plain text input documented for unknown types: functional (the stored value was
1015
+ already the right one), but the admin had to know and type the machine name of
1016
+ the column by hand, with no list to choose from and nothing saying whether the
1017
+ name existed.
1018
+
1019
+ When `recipient_type` is `field`, the widget now reads the shared object from
1020
+ the sibling `object_name` field — the same dependency the `filter-condition`
1021
+ widget already reads — loads that object's schema through
1022
+ `dataSource.getObjectSchema`, and offers its **user-valued** columns, storing the
1023
+ column's name.
1024
+
1025
+ **The offered set is exactly the set the evaluator honours**, and that agreement
1026
+ is the point of the change rather than a detail of it. The sharing evaluator
1027
+ reads a column as users when it is the `user` type, or a `lookup` /
1028
+ `master_detail` whose `reference` is `sys_user`; anything else it treats as
1029
+ "grants nobody" and warns about once per rule. A picker offering a wider set
1030
+ would let an admin save a rule that looks configured and authorises nobody —
1031
+ worse than a hand-typed name, because it has a credible appearance. `hidden` is
1032
+ deliberately not filtered (the evaluator honours a hidden user column), and
1033
+ `reference_to` is deliberately not read (the protocol refuses that spelling by
1034
+ name, and so does the evaluator).
1035
+
1036
+ Three things the mode says out loud rather than leaving to be inferred: it asks
1037
+ for the shared object before offering anything when `object_name` is still
1038
+ unset; an object with no user columns says so instead of rendering a
1039
+ search-flavoured "No matches"; and a stored name that is not on the offered list
1040
+ — a column deleted or retyped since the rule was saved — stays visible and is
1041
+ marked, because the evaluator grants nobody for it and a control that merely
1042
+ looked empty would hide a rule that still names it.
1043
+
1044
+ Unknown recipient types keep degrading to the plain text input, and so does
1045
+ `field` itself when the data source cannot enumerate an object's columns: a
1046
+ hand-typed name is worse than a list, and better than a list that can never
1047
+ fill.
1048
+
1049
+ Three copy keys are added across all ten locale packs
1050
+ (`fields.recipient.selectField`, `noUserFields`, `fieldNotUserTyped`). The
1051
+ "select an object first" gate deliberately reuses the criteria builder's
1052
+ existing sentence rather than adding a twin: it is the same sentence in the same
1053
+ role on the same form, gating on the same sibling field.
1054
+ - 91f9276: **The maker's edit-mode starter offers sample data, not an automation v1 cannot
1055
+ build (objectui#7709).** Bound to an existing app (`?package=`), the maker's
1056
+ empty state offered four starters: add a field, add an object, add a dashboard,
1057
+ and 「加一个自动化 —— 审批、状态流转或通知」. Approval, status flow and
1058
+ notification are all refused by ADR-0112 v1 (cloud#1956 / PR #1970), and the
1059
+ measured behaviour on the sibling chips was not a refusal but a silent degrade
1060
+ into a view — so the product recommended an automation and would have handed
1061
+ back a page.
1062
+
1063
+ Rewording it was not available: asking for a field, a view or a dashboard
1064
+ duplicates one of the three chips beside it. The fourth chip is now
1065
+ `addSampleData` —「给现有对象补一批贴近真实的示例数据,好拿去演示。」 — in all
1066
+ ten packs and in the call-site `defaultValue` fallback, which is a second copy
1067
+ of the same string. The three surviving chips all add STRUCTURE; what an app
1068
+ that already has objects most often lacks is DATA, and `seed` is on v1's
1069
+ authoring whitelist. A note beside the keys in every pack and at the call site
1070
+ says this chip's automation wording comes back when ADR-0112 v2 re-adds flows
1071
+ and actions, and the retired sentence for each pack is kept in the guard suite
1072
+ so v2 has it verbatim.
1073
+ - a915064: `listImportMappings` no longer renders a refused door as "no mapping is registered"
1074
+ (objectui#7741).
1075
+
1076
+ `ObjectStackAdapter.listImportMappings` degrades every failure to an empty list, and the
1077
+ import wizard hides its saved-mapping selector on an empty list. So "the server served
1078
+ zero mappings" and "the server refused, or broke" produced the identical UI on every
1079
+ deployment — the feature simply absent — with a `console.warn` as the only
1080
+ discriminator, in the browser console, with nothing pointing at it. That silence did not
1081
+ merely hide a fault: it produced a confident WRONG diagnosis in a careful reporter
1082
+ (objectstack#14026 was filed, routed and worked by two seats against a wizard that had
1083
+ been correct since `@object-ui/data-objectstack@17.1.0`).
1084
+
1085
+ **The empty-list return is unchanged.** `listImportMappings` still answers
1086
+ `Promise<any[]>` and still never throws, on every arm including the loud ones — this is
1087
+ a channel added ALONGSIDE that contract, not a change to it.
1088
+
1089
+ - **New: `ObjectStackAdapter.onMetadataReadWarning(cb)`** — a subscribe/unsubscribe
1090
+ channel, sibling in shape to `onWriteWarning` and `onSaveAdvisory`. It fires when a
1091
+ metadata read failed in a way that is NOT the supported "this deployment does not
1092
+ serve that kind" shape, carrying `MetadataReadWarningEvent`: which read it was, the
1093
+ object, whether the server `refused` this caller or the answer was `unreadable`, and
1094
+ the server's own ADR-0112 code, HTTP status and message.
1095
+ - **New: `classifyImportMappingsFailure(err)`** and `ImportMappingsFailureKind`, exported
1096
+ so a consumer can apply the same verdict. It reads the ERROR — the ADR-0112 `code`
1097
+ first, the status only where no code was declared — and never "is the result an empty
1098
+ array", which is what both conditions produce and so can never tell them apart.
1099
+ - **The older-server case stays quiet.** A deployment that does not serve the `mapping`
1100
+ kind (404/501 with no route, `ROUTE_NOT_FOUND`, `NOT_IMPLEMENTED`, or the metadata list
1101
+ door's 400 `INVALID_REQUEST`) still degrades to an empty list with no selector and no
1102
+ event. That is a real, supported deployment shape and it must not become a visible
1103
+ fault.
1104
+ - **The console now says so.** `AdapterProvider` subscribes to the new channel and
1105
+ renders a warning toast naming the object, the remedy and the server's own words, so a
1106
+ user without devtools open can tell "there are none" from "we could not find out".
1107
+ Three new `console.importMappings*` keys ship in all ten locale packs.
1108
+
1109
+ This applies framework objectstack#13906 decision 1 option A — *a thing that could not be READ is
1110
+ not a thing that is ABSENT* — at this seam. It is an already-adopted discrimination, not
1111
+ a new principle.
1112
+ - af9e957: fix(types,components,plugin-form,console,core): a faulted `visibleWhen` refuses the submit, naming the field and the rule; a blank field rule is refused; blank gates are diagnosed (objectui#8069)
1113
+
1114
+ ⚠️ **User-visible, and a narrowing.** A form whose field `visibleWhen` cannot be
1115
+ evaluated — a typo in a column name, a syntax error, an unbound root — used to
1116
+ render the field (fail-open) and submit as if the rule had said "show". It now
1117
+ still renders the field, and **refuses the submit** with a message that names
1118
+ the field and the rule (`form.visibleWhenFaulted`). This is ADR-0137 D2 as
1119
+ ruled for objectui#8069 (Q1 = B, one judge per rule): no server evaluates a
1120
+ field's `visibleWhen`, so its fail-open render direction (D3) was a silent grant
1121
+ — a field the working rule would have hidden, drawn, edited and written. The
1122
+ refusal applies on the record form renderer (`form.tsx`, every `ObjectForm`
1123
+ layout), the console's `/forms/:name` and `/f/:slug` page, and the wizard's
1124
+ cross-step gate at final submit.
1125
+
1126
+ - `requiredWhen` / `readonlyWhen` are **unchanged on the client**: the server
1127
+ evaluates both and refuses a faulted one itself (ADR-0137 D2), and the form
1128
+ renderer shows that field-attributed refusal beside the input.
1129
+ - **Accepted residuals:** a `visibleWhen` reading `previous` cannot be
1130
+ evaluated on a CREATE form, so such a form is refused on every submit; and
1131
+ the wizard's cross-step gate binds no `previous` in either mode, so the same
1132
+ rule is refused at the final submit of an EDIT wizard too.
1133
+ - A **blank** field rule (`''`, whitespace, an envelope whose `source` is
1134
+ blank) is a fault, not "no rule" (ADR-0137 D2). A STORED blank `visibleWhen`
1135
+ is refused at submit on the same three paths; a stored blank `requiredWhen` /
1136
+ `readonlyWhen` is the server's to refuse.
1137
+
1138
+ ⚠️ **Narrowing (`@object-ui/types`): `FormFieldSchema` refuses a blank field
1139
+ rule at parse.** `visibleWhen`, `readonlyWhen` and `requiredWhen` on a form
1140
+ field now refuse a predicate that is blank after trimming, with the spec's own
1141
+ sentence (`EVALUATED_EXPRESSION_SOURCE_REQUIRED`) — ADR-0137 D1, the same
1142
+ refusal `@objectstack/spec` makes on `FieldSchema`. The accepted SHAPE is
1143
+ unchanged (a string or `{ dialect?, source }`, `ExpressionWireSchema`'s own
1144
+ arms): only the blank value is taken out. A blank GATE — `BaseSchema`'s
1145
+ `visible` / `hidden` / `disabled`, a form field's view-level `visibleOn`, an
1146
+ option's `visibleWhen` — still parses and is still read as "no gate".
1147
+
1148
+ **Added (`@object-ui/core`):** `resolveFieldRuleState` returns `faults` beside
1149
+ its three verdicts — the per-rule fault report the submit paths read, filled
1150
+ from the same evaluation — and its type, `FieldRuleFaults`, is exported.
1151
+ **Added (`@object-ui/i18n`):** the `form.visibleWhenFaulted` key in all ten
1152
+ packs.
1153
+
1154
+ **Diagnosed, no verdict changed (ADR-0137 D4):** a blank CEL gate reaching
1155
+ `ExpressionEvaluator.evaluateCondition`, and a blank gate folded to "no gate" by
1156
+ `hasDeclaredPredicate`, now each report once through the same `[blank]` channel
1157
+ field-rule faults use. Both verdicts (objectui#3850 / #3960) are unchanged,
1158
+ `throwOnError` included.
1159
+ - 0758bd8: `ObjectChart` refuses an object-bound chart that declares no category axis (objectui#8168).
1160
+
1161
+ An object-bound chart that named no field to group by used to be composed anyway.
1162
+ `runAggregate` passed `schema.aggregate` to `ds.aggregate(objectName, { field,
1163
+ function, groupBy, filter })` with no guard on `groupBy`, and the `ds.find` leg
1164
+ handed the same bag to `aggregateRecords`, which buckets every record on
1165
+ `record[groupBy] ?? 'Unknown'`. So one path asked a driver to group by `undefined`
1166
+ and the other collapsed the whole object into a single `'Unknown'` bar — and which
1167
+ of those a reader saw was decided by the data source, not by the renderer. The only
1168
+ loud states this component had were a fetch `error` (`chart-error`) and a generic
1169
+ "No data yet"; neither is a statement about an absent binding.
1170
+
1171
+ `ObjectCalendar`, `ObjectGantt` and `ObjectTimeline` each already refuse a view that
1172
+ declares no axis. This is the fourth, following `ObjectTimeline`'s shape
1173
+ (objectui#7459): a `role="alert"` box, `data-testid="chart-missing-category-axis"`,
1174
+ naming the bindings the author can declare — `aggregate.groupBy`, `xAxisKey`,
1175
+ `xAxis.field` — rendered from the resolver's own vocabulary so the message cannot
1176
+ drift from what the resolver reads.
1177
+
1178
+ **Breaking, deliberately — and this repo ships breaking as `minor`.** A chart that
1179
+ previously rendered an `'Unknown'`-bucketed bar (or whatever the driver did with
1180
+ `groupBy: undefined`) now renders the refusal instead. That is the intent: the
1181
+ picture it drew was not a picture of the data.
1182
+
1183
+ It keys on the CATEGORY alone. A measure may legitimately be absent — `count` takes
1184
+ no field — so refusing on an absent measure would refuse `count` grouped by a
1185
+ declared category, a chart that renders correctly. Four shapes are deliberately
1186
+ untouched: an ADR-0021 `dataset` chart (which may declare no dimension), a chart
1187
+ carrying authored `data` or a `bind` scope (no field name is read to fetch those
1188
+ rows), a spec-shape `xAxis: { field }` with no `xAxisKey` (resolved through
1189
+ `normalizeChartSchema`, this package's one translation of the author-facing shape),
1190
+ and every schema the five in-repo producers compose today — all five floor their own
1191
+ category, so none of them can reach the refusal.
1192
+
1193
+ This does **not** retire the six `'name'` / `'value'` floors at the three relay faces;
1194
+ that is the remainder of objectui#7547 and is mechanical only once this screen exists.
1195
+
1196
+ New key `chart.unconfigured.noCategoryAxis` in all ten locale packs.
1197
+ - a695f50: `@object-ui/i18n` now publishes `TranslateFn` — i18next's `t` narrowed to
1198
+ `(key: string, options?: Record<string, unknown>) => string` — as the one
1199
+ authority for that name (objectui#8261, on the maintainer's ruling on
1200
+ objectui#8165, option A):
1201
+
1202
+ ```ts
1203
+ import type { TranslateFn } from '@object-ui/i18n';
1204
+ ```
1205
+
1206
+ `@object-ui/app-shell` (`writeWarningToast`, which its sibling toast modules
1207
+ re-export) and
1208
+ `@object-ui/fields` (`file-size-guard`) each declared their own copy of this
1209
+ type. Both copies were byte-identical to the new declaration, and both modules
1210
+ now re-export it from `@object-ui/i18n` instead of declaring it, so every
1211
+ existing import of `TranslateFn` from those modules resolves to the same type
1212
+ it did before — no call site's contract moves. Neither package publishes the
1213
+ name from its own entry; `@object-ui/i18n` is the one place to import it from.
1214
+
1215
+ The `TranslateFn` entry in the one-authority gate's `KNOWN_COLLISIONS` baseline
1216
+ (`scripts/__tests__/one-authority-per-exported-name-6273.test.ts`) is removed:
1217
+ the name now has one declaration.
1218
+ - 55ba3ff: A `lookup` reference that resolved to nothing now gets **one** answer instead of
1219
+ two opposite ones (objectui#8695), carrying objectui#8434's ruling to the second
1220
+ renderer that had the same defect.
1221
+
1222
+ `LookupCellRenderer` split a single epistemic state — *a reference this screen
1223
+ did not resolve* — by the **shape of the string**. `isLikelyOpaqueId` sent
1224
+ opaque-looking ids to a muted `—` and sent everything else to confident bare
1225
+ text. Measured with `reference_to: 'sys_user'`, `'Ada Lovelace'` rendered
1226
+ `<span class="block max-w-full truncate" title="Ada Lovelace">Ada Lovelace</span>`
1227
+ — **byte-identical** to what a `text` cell prints for the same string — while
1228
+ `'01HQZX9K2M4N6P8R'` rendered a muted `—`. So the name-shaped case stated a
1229
+ confident fact the screen did not have (a dirty row read exactly like a clean
1230
+ one), and the opaque case destroyed the raw id, which objectui#8434's triage
1231
+ named as "the only clue for diagnosing existing dirty rows". Two opposite
1232
+ failures, one state.
1233
+
1234
+ Both now render the affordance objectui#8434 settled on: the raw value kept
1235
+ **visible**, beside a muted marker glyph and a stated sentence, keyed as
1236
+ `detail.unresolvedLookupReference` in all ten locale packs. `master_detail` and
1237
+ `tree` route through the same renderer and get the same answer, as does the
1238
+ multi-value chip shape — including the `+N` overflow chip's `title`, which now
1239
+ lists the values it hides instead of a row of dashes.
1240
+
1241
+ **The sentence is epistemic, not ontological, and it is a sibling of the `user`
1242
+ one rather than the same key.** At least six causes reach this arm and the
1243
+ renderer distinguishes none of them: never fetched, in flight, the resolver
1244
+ threw, the resolver answered with no record, it answered with a record no
1245
+ display field could name, and — for array entries after the first — never asked.
1246
+ `useLookupName` returns `string | undefined`, dropping the `pending`/`err`/`ok`
1247
+ discriminator its own cache holds. Several of those also cover a record the
1248
+ viewer may simply not be allowed to read, and "cannot read" versus "does not
1249
+ exist" is a boundary this renderer cannot see across. So it states only what is
1250
+ true of all of them: this screen did not resolve it. The `user` pack value ends
1251
+ "was not resolved to a user", which is false on a lookup pointing at any other
1252
+ object, hence a separate key.
1253
+
1254
+ **Why `minor` and not `patch`.** The test this lane applies is whether EXISTING
1255
+ STORED DATA renders differently, and here some of it is not broken data.
1256
+ `LookupCellRenderer` auto-resolves only the FIRST primitive of an array
1257
+ (`primaryPrimitiveId`, and `resolveLabel` returns a name only when
1258
+ `val === primaryPrimitiveId`) — a documented cheapness policy, not a defect. So
1259
+ on a multi-value `lookup` holding perfectly clean ids, entries 2..n were never
1260
+ asked and now wear the unresolved marker where they previously printed a bare
1261
+ id or a dash. That is a visible change to a working screen over correct data,
1262
+ which is the line between the two levels; the name-shaped and opaque-shaped
1263
+ single-value cases would each have been `patch` on their own, since both were
1264
+ already wrong. `@object-ui/i18n` is `minor` for the ordinary reason: a key is
1265
+ ADDED — `detail.unresolvedLookupReference`, across all ten packs (the `user`
1266
+ sentence `detail.unresolvedReference` already shipped with objectui#8434).
1267
+
1268
+ **Nothing else moves.** A reference resolved by an expanded record, by the
1269
+ author's `options`, or by the fetch-on-demand resolver renders exactly as before,
1270
+ unmarked; an empty cell keeps `EmptyValue`; the affordance sits inside
1271
+ `ReferencedRecordLink`, so an unresolved reference is still navigable
1272
+ (objectui#4336). Display only — no query, sort, export or save path reads a cell
1273
+ renderer's output. `isLikelyOpaqueId` stays exported (removing it would be a
1274
+ breaking change) but no longer decides how anything is drawn.
1275
+ - ef52001: Retire 23 measured-dead locale keys from all ten packs — two whole families and
1276
+ eleven individual leaves (objectui#8754; director seat summon #22, 2026-09-12,
1277
+ maintainer verbatim 「其他同意」).
1278
+
1279
+ **Breaking, deliberately.** These keys no longer exist in any of the ten built-in
1280
+ packs. This repository has no reader for any of them, but `@object-ui/i18n` is a
1281
+ published package: an application outside this repository that calls `t()` with
1282
+ one of the keys below now renders the raw key name instead of a sentence, with no
1283
+ error thrown (i18next's missing-key fallback). That risk is accepted rather than
1284
+ overlooked — the standing maintainer ruling is immediate retirement with no staged
1285
+ window and no double-spelling grace, on the grounds that 「已发布零消费的能力不因
1286
+ 沉没成本获得豁免」.
1287
+
1288
+ Marked `minor` rather than `major` because this repository's fixed release group
1289
+ tracks `@objectstack`'s major line; the breaking semantics are stated here instead.
1290
+
1291
+ **Migration.** If you call one of these keys, supply the string yourself — pass
1292
+ `defaultValue` to your `t()` call, or add the key to your own application's
1293
+ resource bundle. For the two families there is a better answer than re-adding the
1294
+ key, because a live surface already exists and the retired family was never wired
1295
+ to it:
1296
+
1297
+ - `approvals.*` — the shipping approvals UI renders through **`approvalsInbox.*`**,
1298
+ a different namespace that is fully populated in all ten packs. Point at that.
1299
+ - `marketplace.pricing.*` — nothing renders pricing labels in this repo at all.
1300
+ There is no replacement key; author your own.
1301
+
1302
+ **The two families, retired whole** (the unit of this retirement is the family:
1303
+ retiring half a dead family leaves the other half looking live by contrast, and
1304
+ the namespace roots are removed, not merely emptied):
1305
+
1306
+ - `approvals.approve`, `approvals.approveSuccess`, `approvals.comment`,
1307
+ `approvals.reject`, `approvals.rejectConfirm`, `approvals.rejectSuccess`
1308
+ - `marketplace.pricing.contact-sales`, `marketplace.pricing.free`,
1309
+ `marketplace.pricing.freemium`, `marketplace.pricing.paid`,
1310
+ `marketplace.pricing.subscription`, `marketplace.pricing.usage-based`
1311
+
1312
+ **The eleven individual leaves.** Each was held out of an earlier sweep by a
1313
+ LONGER live sibling that merely contained it, until PR objectui#8753 put a
1314
+ key-boundary requirement on the text probe. The sibling that actually renders is
1315
+ named beside each, and every one of them is unaffected by this change:
1316
+
1317
+ - `appDesigner.noNavItems` — renders as `appDesigner.noNavItemsHint`
1318
+ - `appDesigner.separator` — renders as `appDesigner.separatorLabel`
1319
+ - `console.objectView.groupBy` — renders as `console.objectView.groupByField`
1320
+ - `console.objectView.toolbar` — the live sibling is `…toolbarEnabledCount`
1321
+ - `dashboard.removeWidget` — the live string is the designer table's own
1322
+ `engine.inspector.dashboard.removeWidget`, a different corpus
1323
+ - `form.addItem` — likewise the designer table's `engine.form.addItem`
1324
+ - `home.recent` — renders as `home.recentApps.title`
1325
+ - `home.starred` — renders as `home.starredApps.title`
1326
+ - `home.subtitle` — the live sibling is `home.build.subtitle`
1327
+ - `sidebar.help` — renders as `sidebar.helpTooltip`
1328
+ - `workspace.create` — renders as `workspace.createTitle` / `…createButton`
1329
+
1330
+ **How the set was admitted.** Not from the card's body: `pnpm check:i18n-dead-keys`
1331
+ was re-run on this change's own base after the objectui#9222 instrument repair,
1332
+ and every key above is CONFIRMED by that run, or is NEEDS-REVIEW whose only hit is
1333
+ non-liveness (a changelog line; a pin test). The retirement is pinned negatively by
1334
+ `packages/i18n/src/__tests__/dead-pack-keys-retired-8754.test.ts`, because every
1335
+ other i18n gate in this repo runs call site → key and none of them can see a key
1336
+ coming back.
1337
+ - 804831c: A render-time filter refusal now renders a named "this view's filter is malformed" state instead of throwing out of render (objectui#9050).
1338
+
1339
+ `convertFiltersToAST` refuses eleven authored shapes with a `FilterOperatorError`, and
1340
+ `toFilterNode` delegates to it from four call sites that are not on the wire path:
1341
+ `RelatedList`, `LineItemsPanel` and `ObjectGrid` read it from a render-time `useMemo`
1342
+ (where a throw is a render error, with no `classifyLoadError` to turn it into "the
1343
+ filter is malformed"), and `ObjectGrid`'s deprecated `defaultFilters` leg reads it
1344
+ inside the load effect, which caught the refusal but reported it under "Error loading
1345
+ grid" over the converter's English paragraph.
1346
+
1347
+ What changes:
1348
+
1349
+ - `@object-ui/core` gains `toFilterNodeSafely`, whose result is a UNION — `{ ok: true,
1350
+ node }` or `{ ok: false, refusal }`. The refusal is deliberately NOT representable as
1351
+ `undefined`: `undefined` means "no filter", i.e. every row, which is the silently
1352
+ unconstrained query objectui#9001 closed. A caller must narrow before it can build a
1353
+ query.
1354
+ - `FilterOperatorError` now carries `operator` and `field` as data, and
1355
+ `filterRefusalSubject` picks the token a diagnostic names. The eleven messages
1356
+ deliberately share no idiom, so recovering that token by pattern would be a twelfth
1357
+ dialect. `operator` is the spelling the AUTHOR wrote, never a canonical form
1358
+ substituted for it; it is absent on exactly the two arms that judge a comparand
1359
+ written with no operator in it, where `field` is the only handle.
1360
+ - The three render-time readers keep the refusal as a value, send nothing, and render a
1361
+ state that names the operator. The subtree around them is unaffected — on the
1362
+ schema-rendered path a `SchemaErrorBoundary` already contained the throw, so what is
1363
+ new there is the DIAGNOSIS in place of a generic "Component failed to render"; mounted
1364
+ directly, which every one of these components supports through its package entry,
1365
+ there was no boundary at all and the throw reached the host.
1366
+ - `view.malformedFilter` is added to all ten locale packs and to the three affected
1367
+ `createSafeTranslation` tables.
1368
+
1369
+ ⛔ Not changed: which shapes convert. The converter's accept set is byte-identical —
1370
+ this is a delivery change, not an acceptance one. Where the protocol's own parse accepts
1371
+ an input this layer refuses, that gap is the protocol's to close and is filed against
1372
+ `@objectstack/spec` rather than papered over here.
1373
+
1374
+ ⚠️ **Dated note, 2026-09-27 — one more refusal carries no operator — objectui#9164.**
1375
+ Later in this same release `convertFiltersToAST` also refuses an empty operator map that
1376
+ is all a filter says (`{ a: {} }`). Its `FilterOperatorError` carries `field` and no
1377
+ `operator`, like the two comparand arms, so `filterRefusalSubject` names the field and
1378
+ the three render-time readers show the malformed-filter state for it. The counts above
1379
+ (eleven shapes, two arms without an operator) are this change's reading. The rest of this
1380
+ entry is kept as the reading of this change; the objectui#9164 entry states the new
1381
+ refusal.
1382
+ - 2bf34f7: The console's "app not available" screen now says what it measured, and the by-name app
1383
+ probe stopped folding four answers into one (objectui#9262).
1384
+
1385
+ **BREAKING for consumers of the published `AppAccessVerdict` type.** `probeAppAccess`
1386
+ widens by two members ruled by the maintainer: `granted | denied | unknown` becomes
1387
+ `granted | denied | not_found | unreachable | unknown`. A consumer that exhaustively
1388
+ switches on the verdict gains two cases. The meaning of `unknown` also narrows — it no
1389
+ longer means "an absent app, an unreachable server, or an adapter that cannot ask", it
1390
+ means only that nothing was asked (a host DataSource without this probe). Code that read
1391
+ `unknown` as a positive absence must move to `not_found`.
1392
+
1393
+ **What users see.** The sentence `This app is not available yet — it may still be
1394
+ publishing. Try again in a moment.` is retired from all ten locale packs. It was asserted
1395
+ for seven distinct causes, only one of which is a publish, and it is the screen an author
1396
+ lands on after any of them — the same defect objectui#4252 split `denied` off this screen
1397
+ for. In its place: `This app can't be opened` for a measured absence, `Couldn't reach the
1398
+ server` for a probe that got no answer, the unchanged denial screen for a permission
1399
+ refusal, and a neutral `App not available` when nothing was measured. All but the denial
1400
+ keep the Retry button, because all but the denial can change on their own. A genuine
1401
+ post-publish lag reads as an absence, and the one forced metadata refresh that gives that
1402
+ lag its chance to resolve is unchanged.
1403
+
1404
+ **Measured before the branches were written.** On a real server the by-name route answers
1405
+ `200` with the declared envelope MINUS its `item` for a name that does not exist — not the
1406
+ `404` this code assumed — so `probeAppAccess` returned `granted` for every typo, every
1407
+ never-created app and every unpublished draft. `granted` fell through to the same screen as
1408
+ `unknown`, which is why nothing showed. Absence is therefore read from the envelope as well
1409
+ as from the `404` code; a `not_found` branch keyed on the status alone would have been dead
1410
+ for the commonest case.
1411
+ - 15b33ae: Build history rows now state their item count in each language's own grammar
1412
+ (objectui#9266).
1413
+
1414
+ The commit timeline composed the count by concatenation — the row rendered the
1415
+ number itself and then asked the locale pack for a bare unit word to sit beside
1416
+ it. A pack that never sees the number cannot agree with it, so, measured through
1417
+ a real render at one item, six of the ten packs read `1 Elemente`,
1418
+ `1 éléments`, `1 elementos`, `1 itens`, `1 элементов` and `1 عناصر`.
1419
+
1420
+ `preview.history.items` now carries the count itself, one interpolated value per
1421
+ pack, and the call site passes `count` instead of printing the digit beside the
1422
+ translation. Each pack states the quantity in an idiom whose grammar does not
1423
+ bend on the number: `de` / `fr` / `es` / `pt` / `ru` / `ar` take the label-colon
1424
+ form (`Elemente: 3`, `Элементов: 3`), which is what
1425
+ `fields.textarea.charactersRemaining` already uses for this same reason.
1426
+
1427
+ `en`, `zh`, `ja` and `ko` render exactly what they rendered before, at every
1428
+ count — the three CJK packs because their grammar marks no plural, and `en`
1429
+ because its value already carried the count-invariant `(s)` marker. No copy that
1430
+ was correct has been changed.
1431
+
1432
+ A real `_one` / `_other` plural family was measured and not adopted: identical
1433
+ key sets across the ten packs leave `ru` without `_few` and `ar` without
1434
+ `_two` / `_many`, so those categories fall through to the base key and `ru` would
1435
+ still read `2 элементов` — wrong at the counts a build history shows most often.
1436
+
1437
+ The count is kept. Dropping the number (the route taken for `kanban.columns`,
1438
+ where the lanes are visible anyway) would delete information here rather than
1439
+ de-duplicate it.
1440
+ - 641fb55: The `FilterBuilder` dropdown speaks the protocol's operator ids (objectui#9306).
1441
+
1442
+ `defaultOperators` now emits the twenty members of `@objectstack/spec`'s
1443
+ `VIEW_FILTER_OPERATORS`, spelled as the spec spells them (`not_equals`,
1444
+ `greater_than_or_equal`, `is_null`, `icontains`, …), plus the two opt-in
1445
+ existence ids `exists` / `notExists`, which the protocol has no member for and
1446
+ which stay unfolded (objectui#9559 ruling B). The camelCase ids the dropdown used
1447
+ to emit (`notEquals`, `greaterOrEqual`, `isNull`, …) are the spec's deprecated
1448
+ alias form (objectui#7993); `containsCaseInsensitive` is the one former id the
1449
+ spec's alias table has no row for, and the builder reads it itself (below).
1450
+
1451
+ **Stored filters keep loading.** The builder folds a stored spelling at its read
1452
+ boundary, through the spec's `normalizeFilterOperator` plus one local row the
1453
+ spec's alias table lacks (`containsCaseInsensitive` → `icontains`,
1454
+ objectstack-ai/objectstack#20092), and the author's next edit writes the
1455
+ canonical id back. Opening a stored filter writes nothing. No row changes the
1456
+ predicate it stores: the sharing-rule criteria, the dataset filter, the saved-view
1457
+ fold, the live grid and the override recovery pass were each measured over all
1458
+ 22 former ids against the ids they became, and store the same predicate. The only
1459
+ cells that differ are `icontains` on the three consumers that never offered the
1460
+ case-insensitive contains (dataset filter, saved-view fold, live grid), where
1461
+ the old id produced no storable filter at all and the new one does.
1462
+
1463
+ **Breaking, stated here because the group never takes a `major`:**
1464
+
1465
+ - `@object-ui/components`: the published `FilterBuilderOperator` type NARROWS
1466
+ from the camelCase union to the spec's `ViewFilterOperator` plus `'exists' |
1467
+ 'notExists'` — a `'greaterOrEqual'` literal typed against it no longer
1468
+ compiles. `FILTER_BUILDER_OPERATORS` and `VALUELESS_FILTER_BUILDER_OPERATORS`
1469
+ hold the canonical ids, and a host's `onChange` receives them. New export:
1470
+ `normalizeFilterBuilderOperator`, the builder's read-side fold.
1471
+ - `@object-ui/components`: `icontains` ("Contains (ignore case)") is no longer
1472
+ opt-in. Its old reason — only the Mongo criteria dialect could carry a
1473
+ case-insensitive contains — stopped being true when `VIEW_FILTER_OPERATORS`
1474
+ gained `icontains` (spec 17.1.0), and `OPT_IN_OPERATORS`' own docblock recorded
1475
+ deleting the entry as the planned outcome. It is offered on the text bucket to
1476
+ every consumer; `contains` and `icontains` stay two operators (objectui#7379).
1477
+ - `@object-ui/i18n`: the `filterBuilder.operators.*` keys are re-keyed to the
1478
+ canonical ids in all ten packs (`filterBuilder.operators.is_null`, …); every
1479
+ translated value is unchanged. A host that overrides one of these keys must
1480
+ re-key its override.
1481
+ - `@object-ui/fields`: `FILTER_CONDITION_EXTRA_OPERATORS` is `['exists',
1482
+ 'notExists']` — the case-insensitive contains needs no grant any more.
1483
+ `FilterConditionField` still writes `$icontains` for it, and its builder rows
1484
+ (`kvToCondition`) carry the canonical ids.
1485
+ - `@object-ui/plugin-view`: `toFilterGroup` emits the canonical ids.
1486
+
1487
+ Also in `@object-ui/app-shell`: the dataset inspector's bridge maps `icontains`
1488
+ to `$icontains`; the drill-down "is null" chip reads the re-keyed label; and the
1489
+ view-override recovery pass folds a row's operator before its value-less check,
1490
+ so a stored `{ operator: 'isEmpty', value: '' }` row is kept rather than dropped
1491
+ as unfinished.
1492
+ - c698a81: Render the environment admin's read-rate report from the usage endpoint's `readRate`
1493
+ reading (objectui#9954; maintainer ruling on cloud#2333, batch #164 item 3).
1494
+
1495
+ The tenant runtime's `GET /api/v1/usage/storage` gained one optional nested key,
1496
+ `readRate`, carrying the control plane's verdict (`state`), the ratio it measured
1497
+ (`readsPerWrite`) and the line that verdict was taken against (`ratioThreshold`). The
1498
+ data half landed on the cloud side; nothing in this repo consumed it, so a measured
1499
+ anomaly reached nobody. This is the rendering half.
1500
+
1501
+ **New:** `useReadRateReading` (a hook beside `useAiUsage`) and `ReadRateBanner` (a
1502
+ layout surface beside `ImpersonationBanner`, mounted in `ConsoleShell` so every console
1503
+ route including `/home` carries it). Neither is exported from the package entry; a host
1504
+ reaches the banner only through `ConsoleShell`, which mounts it.
1505
+
1506
+ Three properties of the contract shape the implementation, and each is pinned by a test:
1507
+
1508
+ - **An absent `readRate` is not "fine".** It means the control plane reported NO
1509
+ reading. The hook reports it as `unmeasured`, which is a different value from a
1510
+ measured `ok` and from an unreadable endpoint. All three render nothing, and the code
1511
+ keeps all three apart — "why does my environment show no banner" has more than one
1512
+ answer and one of them is *nobody has measured it*.
1513
+ - **An absent `readsPerWrite` is the worst case, not a missing number.** It means the
1514
+ environment made no writes at all, so the ratio has no upper bound. It gets its own
1515
+ title, its own sentence and the heavier tone — never a dash, and never a hidden
1516
+ banner.
1517
+ - **The threshold is data.** It is rendered from `ratioThreshold` on the wire; this repo
1518
+ holds no copy of the line, and the verdict is never re-derived from the ratio.
1519
+
1520
+ It is a **report**: no gate, no throttle, no upgrade call to action, and the copy says in
1521
+ as many words that nothing is limited or blocked. It is shown only to a workspace admin,
1522
+ who is also the only session that issues the request.
1523
+
1524
+ `@object-ui/i18n` gains the four `console.readRate.*` keys in all ten locale packs.
1525
+ - 77f846a: The approval panel identifies the pending approver by name, not by a truncated raw id.
1526
+
1527
+ A record waiting on a position rendered its approver as `positi…ager` — the
1528
+ engine reference `position:sales_manager`, 22 characters, past the identity
1529
+ formatter's 14-character truncation arm and middle-truncated to fit its chip. The
1530
+ step names beside it were human prose; the one line answering *who is holding
1531
+ this record* was an internal identifier, and not even a complete one. The same
1532
+ reference reached the admin-override confirm dialog un-truncated, so a paragraph
1533
+ of plain governance prose ended `— position:sales_manager`.
1534
+
1535
+ Both surfaces now resolve the reference before rendering, in three tiers, most
1536
+ authoritative first. The server's own `pending_approver_names` wins whenever it
1537
+ answers, and a backend that resolves its own slate costs the record page no extra
1538
+ request. Otherwise the console reads the directory row the spec's approver
1539
+ binding names — `sys_position.label` gives `Sales Manager` / `销售经理` — and,
1540
+ for a position, who fills the seat (`Sales Manager · Zhang Wei, Li Na`). With no
1541
+ adapter and no row, the machine name still prettifies into prose rather than
1542
+ truncating. The raw reference stays on hover, which is where an internal
1543
+ identifier belongs.
1544
+
1545
+ An unstaffed position is surfaced rather than hidden: `销售经理(暂无在岗人员)`
1546
+ is actionable where `positi…ager` is not, and it is the motivating rescue case
1547
+ for the admin-override path. Staffing is deliberately tri-state — a
1548
+ `sys_user_position` read the viewer is not permitted to make leaves the seat's
1549
+ staffing UNKNOWN and says nothing, because "I could not look" is a different
1550
+ claim from "nobody holds it" and only one of them is safe to print on a
1551
+ governance surface.
1552
+
1553
+ Two locale keys are added across all ten packs: `approvalsInbox.approverUnstaffed`
1554
+ and `approvalsInbox.approverNameSeparator`. The separator is a translated
1555
+ punctuation key rather than `Intl.ListFormat`, which was measured on this tree
1556
+ joining `['张伟','李娜']` into `张伟李娜` for `zh` — two names run together with
1557
+ no separator, reading as one person's name.
1558
+
1559
+ The directory-backed kinds and their value columns are read from
1560
+ `@objectstack/spec`'s `APPROVER_VALUE_SOURCES` rather than restated, so a new
1561
+ approver type is covered the day the spec publishes it. Id-valued kinds
1562
+ (`user` / `team` / `department`) keep the existing middle-truncation: a row id
1563
+ has no prose to recover, and that arm is objectui#3461's answer, not this card's
1564
+ defect.
1565
+ - c40f3b8: A screen flow's resume result reaches the user — on both outcomes.
1566
+
1567
+ A dogfood walkthrough reported that a refused `resume` and a successful one
1568
+ "render identically: the dialog closes and the page is unchanged", leaving no
1569
+ gesture that distinguishes "created" from "rejected". Re-measured against `main`
1570
+ before any change, one half of that was already fixed — `interpretFlowResponse`
1571
+ reads the ADR-0112 envelope, and `FlowRunner`'s `toast.error` has carried its
1572
+ prose since the `400 FLOW_FAILED` classification landed in `17.6.0`, five minors
1573
+ after the version the report was measured on. There was no interpreter bug and
1574
+ no un-consolidated fourth call site. Three gaps in the RUNNER's disposition were
1575
+ real, and they are what changed:
1576
+
1577
+ - **A terminal failure no longer closes the dialog.** The reason it closed is
1578
+ unchanged and is not reversed: on a `FLOW_FAILED` the engine has already
1579
+ consumed the suspension, so a resubmit can only reach "No suspended run" and
1580
+ must not be offered. Closing was one way to withhold that dead retry and the
1581
+ expensive one — the user had just typed a form they could no longer see, and
1582
+ the engine's sentence names a value that left the screen with it. The dialog
1583
+ now stays open with the submit affordance withdrawn: the flat footer swaps
1584
+ Submit for Close, and an `object-form` step drops its Save (which also stops a
1585
+ second click from duplicating the record it had already persisted).
1586
+ - **The refusal has a second, non-expiring carrier.** The toast stays — it is
1587
+ viewport-fixed, so it still reaches a user scrolled past a tall step's header
1588
+ — and an inline destructive `Alert` (`role="alert"`) now holds the same
1589
+ sentence inside the dialog, beside the values that produced it. A retryable
1590
+ refusal (`INVALID_SCREEN_INPUT`, transport, 5xx) keeps Submit live as before,
1591
+ and its banner clears as soon as the user starts editing.
1592
+ - **A successful run invalidates what the flow WROTE, not just what the user is
1593
+ looking at.** Both hosts answered `onComplete` with
1594
+ `notifyDataChanged({ objectName: <this page's object> })`, so a flow that
1595
+ created a quote from an Opportunity page never told the related list that
1596
+ would now contain it — the record did not appear until a manual reload. The
1597
+ runner cannot know which objects a flow touched, so it emits
1598
+ `{ objectName: '*' }`: the same scope, for the same stated reason, that the
1599
+ record page's manual ⟳ already uses. Everything mounted refetches in place
1600
+ over the invalidation bus, with no remount.
1601
+
1602
+ The runner's copy now goes through `@object-ui/i18n` instead of being hardcoded
1603
+ English: a new `flowRunner` namespace (`title`, `submitting`, `saveAndContinue`,
1604
+ `nextStep`, `completed`) in all ten packs, plus reuse of
1605
+ `common.{loading,cancel,close,submit}` and `wizard.missingRequired`. The
1606
+ server's own refusal sentence is still passed through untranslated — it is prose
1607
+ the automation engine composed for a human, not copy with a key.
1608
+ - 6a576c9: Announce inbox messages when they arrive — an in-app toast while the tab is visible, a
1609
+ desktop notification while it is hidden (objectui#7011).
1610
+
1611
+ The inbox was completely silent about arrivals. `sharedUserFeeds` polls
1612
+ `sys_inbox_message` every 10s, the rows landed in the store, the bell badge counted
1613
+ them — and a user not staring at the bell learned nothing, so approvals and @-mentions
1614
+ were routinely missed. Three candidate popup paths existed and none was connected to the
1615
+ inbox: the feed had no diff logic, the console's sonner bridge only serves notifications
1616
+ that explicitly declare `displayType: 'toast'`, and there was no `new Notification(` call
1617
+ anywhere in `packages/` or `apps/`.
1618
+
1619
+ **Presentation layer only.** The transport is untouched: the same two reads, the same
1620
+ 10s / 60s cadence, the same backoff, and no push channel. The accepted consequence is
1621
+ that a backgrounded tab can be up to a minute late — speeding the poll up to shave that
1622
+ would trade a server-wide cost for one surface's latency.
1623
+
1624
+ What arrives:
1625
+
1626
+ - **`useInboxArrivalNotifier`**, mounted from `useInboxBell` — the one wiring of the
1627
+ shared feed onto a bell — so the header bell and the `global:notifications` page block
1628
+ announce by the same rules and through the same `markRead`.
1629
+ - **`inboxArrivals`**, the pure diff: a session-scoped seen set, `(topic, title)`
1630
+ collapse reused from the inbox's own `groupNotifications`, and a bounded memory.
1631
+ - **`desktopNotifications`**, the single door to the browser Notification API.
1632
+ - **Two switches** in the account menu's Preferences section, stored per user in
1633
+ localStorage: in-app alerts (on by default) and desktop notifications (off).
1634
+
1635
+ Four rules decide when nothing happens, and they matter more than the positive case — an
1636
+ announcer that pops for everything is worse than the silence it replaces, because users
1637
+ switch it off and then miss the approvals too:
1638
+
1639
+ - the FIRST answered read primes the seen set and announces nothing, so historical unread
1640
+ at login or after a refresh updates the badge only;
1641
+ - several rows in one cycle announce once, collapsed by `(topic, title)`;
1642
+ - a row that already carries a read receipt never announces;
1643
+ - a hidden tab gets the desktop notification and no toast; a visible tab gets the toast
1644
+ and no desktop notification.
1645
+
1646
+ **`Notification.requestPermission()` is called from the settings toggle's change handler
1647
+ and from nowhere else** — never on mount, on a feed refresh, or on a first message. A
1648
+ browser answers that prompt once and `denied` is permanent for the origin, so a
1649
+ load-time request spends the channel for every user who reflexively blocks, and no later
1650
+ release can undo it. A browser that has not granted permission behaves exactly as it did
1651
+ before this change: completely silent.
1652
+
1653
+ Seven `notifications.*` keys are added to all ten locale packs.
1654
+ - fffa30d: `listViews` no longer renders a refused metadata read as "this object has no saved views"
1655
+ (objectui#8151).
1656
+
1657
+ `ObjectStackAdapter.listViews` degrades every failure to an empty list, and every consumer
1658
+ reads only the return. So "the server served zero saved views" and "the server refused, or
1659
+ broke" produced the identical UI — with a `console.warn` as the only discriminator, in the
1660
+ browser console, with nothing pointing at it. It is the same defect objectui#7741 removed
1661
+ from `listImportMappings` one method over, and the user-visible cost is the higher one: an
1662
+ empty `listViews` is an object's **view switcher**, so a user whose token lapsed
1663
+ mid-session could be shown an object that appears to have no saved views at all —
1664
+ including views they created themselves.
1665
+
1666
+ **The empty-list return is unchanged.** `listViews` still answers `Promise<any[]>` and
1667
+ still never throws, on every arm including the loud ones — this is a channel added
1668
+ ALONGSIDE that contract, not a change to it. `listImportMappings` is likewise unchanged,
1669
+ down to its wording.
1670
+
1671
+ - **`ObjectStackAdapter.listViews` now emits on `onMetadataReadWarning`** — the channel
1672
+ objectui#7741 added — when the read failed in a way that is not the supported "this host
1673
+ mounted no metadata door" shape. The event carries the object, whether the server
1674
+ `refused` this caller or the answer was `unreadable`, and the server's own ADR-0112
1675
+ code, HTTP status and message.
1676
+ - **New: `classifyViewsFailure(err)`.** A SEPARATE reading, deliberately not a second
1677
+ caller of `classifyImportMappingsFailure`: `view`'s quiet set is strictly smaller. The
1678
+ arm the mapping classifier is built around — 400 `INVALID_REQUEST`, the metadata list
1679
+ door's "this deployment carries no such kind" — is unreachable for `view`, which is in
1680
+ the platform's static spelling contract, so reading it as kind-absence would swallow a
1681
+ real refusal. On `view`, only a host with no `/meta` door at all stays quiet.
1682
+ - **`MetadataReadWarningEvent`'s `operation` and `kind` gain their second members**
1683
+ (`'listViews'` / `'view'`). This is the additive, reviewed widening the single-member
1684
+ unions were designed for, and it worked as designed: the consumer that renders these
1685
+ events had a `switch` naming one operation, so the widening turned "a views failure is
1686
+ toasted as an import-mapping failure" into a compile error rather than a runtime lie.
1687
+ - **New: `MetadataReadFailureKind`**, the neutral spelling of the three verdicts.
1688
+ `ImportMappingsFailureKind` is now an alias of it — identical members, so existing
1689
+ consumers are unaffected in both directions.
1690
+ - **The console says which list it was.** `metadataReadWarningToast` picks its title and
1691
+ its remedy by `operation`, so a failed view read reads *"Saved views for {{object}}
1692
+ could not be loaded … not because this object has no saved views"*. Three new
1693
+ `console.savedViews*` keys ship in all ten locale packs; the `console.importMappings*`
1694
+ copy is untouched.
1695
+
1696
+ This applies framework objectstack#13906 decision 1 option A — *a thing that could not be READ is not
1697
+ a thing that is ABSENT* — at the second seam that needed it.
1698
+ - 20e317c: Marketplace-less runtimes now say so instead of erroring: `OS_CLOUD_URL=off` is a
1699
+ first-class disabled state, and the load-failure hint describes the control plane
1700
+ the runtime was actually pointed at (objectui#5504).
1701
+
1702
+ `apps/objectos-ee/deploy/.env.example` ships `OS_CLOUD_URL=off` as its factory
1703
+ default, so a stock self-hosted stack has no marketplace at all. The Console still
1704
+ recommended one: Home led with "Start with a template" and "Browse App
1705
+ Marketplace", and the click landed on a red **Failed to load marketplace / Not
1706
+ found** card whose hint claimed this runtime "points at the public ObjectStack
1707
+ cloud by default" and advised setting `OS_CLOUD_URL`. Both claims were false for
1708
+ exactly the deployment reading them — the operator had not left the default, and
1709
+ the advice pointed back at the template that told them to set `off`. "Marketplace
1710
+ disabled by configuration" is a configuration conclusion, not a load failure.
1711
+
1712
+ - `isMarketplaceEnabled()` (`runtime-config`) reads the server's own
1713
+ `features.marketplace`, which `RuntimeConfigPlugin` derives per request from the
1714
+ serving app's route table (objectstack#8356). It is never inferred from the shape
1715
+ of a failed request: a control plane that is merely DOWN leaves the flag `true`,
1716
+ so an outage still renders as an outage. Unknown fails OPEN.
1717
+ - The marketplace page renders an informational "App Marketplace is turned off"
1718
+ state — muted, not `destructive` — and issues no request it knows will 404.
1719
+ - Home's "Start with a template" cover greys out with a visible localized reason,
1720
+ and the "Browse App Marketplace" shortcut is withheld, exactly as they already
1721
+ are for the `manage_metadata` capability gate.
1722
+ - `marketplace.load.failedHint` is replaced by `failedHintConfigured` (naming the
1723
+ configured control plane) and `failedHintSameOrigin`. The "points at the public
1724
+ cloud by default" sentence is gone: it was rendered unconditionally, including on
1725
+ every runtime whose operator had overridden `OS_CLOUD_URL`.
1726
+
1727
+ All ten locale packs carry the new keys.
1728
+ - 19cbf10: fix(app-shell): the report inspector no longer destroys a localized title or label
1729
+
1730
+ `ReportChartSchema.title` and `ReportSchema.label` are `I18nLabel` — a plain
1731
+ string **or** an inline per-locale map. The report inspector narrowed both to
1732
+ the string arm when reading and wrote the typed string back over the whole
1733
+ value, so against a stored map the two halves failed in opposite directions:
1734
+ Studio painted an empty box over a report that has a title, and the retype that
1735
+ empty box invited replaced the map with a bare string, losing every other
1736
+ locale. Committing the empty box untouched deleted the stored map outright.
1737
+
1738
+ Reads now go through `pickLocalized` and writes through `setLocalized`, so an
1739
+ edit lands in the active locale's entry and every other locale survives. An
1740
+ author shown a display fallback from another locale can neither overwrite nor
1741
+ delete the locale it was borrowed from.
1742
+
1743
+ **Clearing is ruled, not inherited.** Clearing the box removes only the active
1744
+ locale's entry; when that was the last entry the key is dropped entirely, which
1745
+ is byte-for-byte what clearing a plain-string value already did.
1746
+
1747
+ **What an author who does nothing differently now gets:** a report whose title
1748
+ or label is a plain string behaves exactly as before — same box, same committed
1749
+ value, including on clear. A report whose title or label is a locale map now
1750
+ shows their own locale's string instead of a blank box, and editing it keeps
1751
+ every other language instead of deleting it.
1752
+
1753
+ `@object-ui/i18n` gains `clearLocalized`, the clear arm of the same
1754
+ single-locale editor `setLocalized` serves. Additive: no existing export
1755
+ changed shape.
1756
+ - 8033ad1: The built-in record-detail tab labels and the last two English landmark names now speak the session locale (objectui#4645).
1757
+
1758
+ `buildDefaultPageSchema` synthesizes the record-detail tab strip with plain
1759
+ English tokens on the nodes (`Details`, `Related`, `Attachments`, `Activity`,
1760
+ `History`, `Approvals`), and three of its own comments said those tokens
1761
+ "localize through the tab strip's `KNOWN_LABEL_DICT`". That dict shipped
1762
+ exactly two arms — `zh-CN` and `zh-TW` — so the claim held for Chinese and
1763
+ silently failed for the eight other shipped packs: a ja-JP or es-ES record
1764
+ detail rendered `Details / Related / Attachments` inside otherwise fully
1765
+ localized chrome, measured in a real browser on `@objectstack/console` 17.0.0
1766
+ GA and re-measured red on current `main`.
1767
+
1768
+ Every one of those strings was already in all ten packs (`detail.details`,
1769
+ `detail.related`, `detail.activity`, `detail.history`, `detail.attachments`,
1770
+ `detail.approvalsPanelTitle`); the strip simply never asked. It asks now,
1771
+ BEHIND the exact-locale dict, which stays first because `zh-TW` has no pack of
1772
+ its own — i18next resolves it to the Simplified `zh` resource, so the dict is
1773
+ the only source of the Traditional forms — and because it carries tokens
1774
+ (`Notes`, `Files`, `Tasks`, `Events`, object names) that no pack does. `en` and
1775
+ `zh` render byte-identically to before; the other eight locales change from the
1776
+ English token to their pack value. `page:accordion` reads the same lookup, so
1777
+ the two renderers cannot answer differently for one token.
1778
+
1779
+ Alongside it, the two `aria-label`s that were English in *every* locale —
1780
+ `HeaderHighlight`'s `Record highlights` and `RecordActivityTimeline`'s
1781
+ `Discussion` — no longer read English. `HeaderHighlight`'s now routes through
1782
+ the bundle. Its section carries no visible label, so the `aria-label` is the
1783
+ landmark as far as assistive tech is concerned (the argument objectui#4024 made
1784
+ for the dialog `Close` label, and objectui#5956 made for `record:path`'s own
1785
+ container name). `RecordActivityTimeline`'s section always had a visible
1786
+ heading, so it no longer has a fixed `Discussion` name. Its landmark is named by
1787
+ that heading's title: `detail.activity`, or an authored `titleLabel` such as
1788
+ `record:chatter`'s `detail.discussion` (objectui#9998). `detail.activity`
1789
+ already existed in all ten packs; `detail.highlightsLabel` is the single new
1790
+ key, added to all ten and mirrored byte-identically into
1791
+ `DETAIL_DEFAULT_TRANSLATIONS`.
1792
+ - f157423: Studio workbench and AI tool cards speak the author's language (objectui#7254)
1793
+
1794
+ - The Interfaces breadcrumb, canvas caption and navigation rail show the
1795
+ metadata label plus a translated kind; the internal `type · name` pair moves
1796
+ to the tooltip. An unlabelled nav leaf now falls back to its object name
1797
+ instead of rendering an empty row.
1798
+ - The Studio top-bar package switcher reads the package's human name from
1799
+ either position the packages endpoint serves it in, instead of degrading a
1800
+ registry-shaped entry to its reverse-domain id.
1801
+ - The dashboard property panel is localized: the spec's authoring form is
1802
+ overlaid through the platform's own `metadataForms.<type>` convention, so
1803
+ section headings, field labels, hints and the `header` composite's sub-fields
1804
+ render in Chinese (developer vocabulary such as "Tailwind units" is replaced
1805
+ with something an author can act on, not transliterated).
1806
+ - AI tool cards: tool titles resolve through `chatbot.tool.<name>` (all thirty
1807
+ platform-provided tools, ten locale packs), the header status badge is
1808
+ localized, and the plan count strip is a real plural family instead of an
1809
+ English `+ "s"` concatenation.
1810
+ - The tool card's header badge and its body badge now come from one producer:
1811
+ a proposal that has been confirmed, built or published no longer keeps a
1812
+ header reading "Awaiting Approval".
1813
+ - 7a90afd: Studio's `新建对象` asks for the record-sharing baseline, and an unauthored one is reported before Publish rather than by it.
1814
+
1815
+ Creating an object through Studio collected exactly two things — display name and
1816
+ identifier — and saved a draft that declared no `sharingModel`. The draft saved
1817
+ happily, the form designer worked, and the object was then refused at 发布 →
1818
+ 全部发布 by `security-owd-unset`: a required decision the surface never asked
1819
+ for, delivered by failing, as English ADR prose in a toast that then vanished on
1820
+ a timer. The one actionable word in it named a control three clicks away that
1821
+ nothing routed to.
1822
+
1823
+ The publish gate is correct and is unchanged — an org-wide default has to be an
1824
+ authored decision, not an accident. What changes is when the console asks and
1825
+ when it answers:
1826
+
1827
+ - **The create dialog asks.** A third field collects the baseline, pre-selected
1828
+ to `private` and glossed with the Settings tab's own strings, so a new object
1829
+ is publishable by construction. `buildObjectSkeleton` now takes the value as a
1830
+ required parameter — a future create path cannot omit the baseline without
1831
+ failing to type-check. `controlled_by_parent` is deliberately not offered at
1832
+ creation: it derives access from a master relation a brand-new object does not
1833
+ have yet, so offering it would trade one publish refusal for another.
1834
+ - **The review sheet reports it.** The pending-changes panel now runs the
1835
+ framework's own `validateSecurityPosture` over the pending object drafts and
1836
+ names any blocking finding, with its fix-it hint, next to the Publish button.
1837
+ It mirrors the producer's rule rather than re-deriving it, and it reports
1838
+ without blocking — the server door stays the authority.
1839
+ - **The Settings tab stops calling an unset baseline safe.** It described unset
1840
+ as "defaults to Private", which answers what the runtime does and not whether
1841
+ the object can ship. It now reads as the publish-blocking problem it is,
1842
+ styled like the external-wider warning beside it.
1843
+ - fb96ecb: `WidgetConfigPanel` reads an inline-locale-map title, and a save no longer destroys the other locales.
1844
+
1845
+ The dashboard widget config panel carried a private `resolveLabel` documented as
1846
+ resolving an `I18nLabel` while reading `defaultValue || key` — the key-reference
1847
+ form `@objectstack/spec` retired at 17.0.0-rc.6 (objectstack#5055). The inline
1848
+ per-locale map `I18nLabelSchema` actually admits has neither limb, so
1849
+ `{ en: 'Revenue', zh: '收入' }` resolved to `''`. It was the fourth private copy
1850
+ of that resolver; objectui#4032 swept the other three out of `DashboardRenderer`,
1851
+ `MetricWidget` and `MetricCard`.
1852
+
1853
+ This was not a display bug. The resolved value seeds the panel's editable draft,
1854
+ so a widget whose stored title was a map opened with an **empty** Title field and
1855
+ the next save wrote `''` over the author's map — on the ordinary path, not an
1856
+ exotic one: open the widget, change anything, save.
1857
+
1858
+ Both halves are fixed, per the maintainer's 2026-08-20 ruling on objectui#5301:
1859
+
1860
+ - **Reading** goes through `pickLocalized(value, language)`, so the panel shows
1861
+ the active locale like every sibling surface post-objectui#4032.
1862
+ - **Writing** replaces only the active locale's entry and carries every other
1863
+ locale across. A title the author never touched round-trips the stored object
1864
+ itself through an unrelated config edit; an edited one merges into the entry
1865
+ that was displayed. The live-update callback (`onFieldChange`) forwards the
1866
+ merged map for the same reason — hosts feed it back into the widget the panel
1867
+ re-opens from, so a bare string there dropped the map before a save ever ran.
1868
+
1869
+ `@object-ui/i18n` gains `setLocalized(value, language, next)`, the write-side
1870
+ inverse of `pickLocalized`, so the rule is stated once instead of re-derived per
1871
+ panel. It follows `pickLocalized`'s first three limbs — exact tag, base language,
1872
+ region-qualified sibling — and deliberately stops there: the `default` / `en` /
1873
+ first-value limbs are display fallbacks that hand back *another* locale's string,
1874
+ and writing to one would let an author editing in `fr` overwrite English. With no
1875
+ entry for the active locale the edit adds one. The pairing
1876
+ `pickLocalized(setLocalized(map, lang, s), lang) === s` is pinned, because a
1877
+ write that lands where the read does not look is how a "saved" string disappears.
1878
+
1879
+ A full multi-locale editing UI remains out of scope (objectui#4163).
1880
+
1881
+ ### Patch Changes
1882
+
1883
+ - a507334: Full-page search results now read correctly in Russian and Arabic at every
1884
+ count (objectui#10024).
1885
+
1886
+ The results line picks `search.resultsCount` at exactly one result and
1887
+ `search.resultsCountPlural` at every other count. Two slots cover English; they do
1888
+ not cover Russian, which needs three noun forms for whole numbers, or Arabic, which
1889
+ needs more. Russian's second slot held the form for 5 to 20, so two results read
1890
+ `2 результатов` (the language needs `2 результата`) and twenty-one read
1891
+ `21 результатов` (it needs `21 результат`). Arabic's two slots were the same string
1892
+ byte for byte, so the switch gave Arabic nothing at 2 or at 3 to 10.
1893
+
1894
+ In both packs the count-not-one half is now a count label that reads correctly at
1895
+ any number: `Результатов по запросу "…": N` and `عدد نتائج البحث عن "…": N`. That is
1896
+ the device both packs already use where two slots are all there are — Russian's
1897
+ `collaboration.commentCount` (`Комментариев: N`) is the same shape on the same
1898
+ two-key convention. The singular half, the call site, the key set and the other
1899
+ eight packs are unchanged; `zh`, `ja` and `ko` keep one string in both halves
1900
+ because their languages have a single plural category.
1901
+
1902
+ ⚠️ **Dated note, 2026-10-02 — superseded in this release — objectui#11445.**
1903
+ Later in this same release the `search.resultsCount` / `search.resultsCountPlural`
1904
+ switch gave way to a `search.resultsCount` count family in all ten packs:
1905
+ `ru` and `ar` now write a noun form for each CLDR category instead of the count
1906
+ label above (`2 результата`, `21 результат`), and `search.resultsCountPlural` left
1907
+ every pack. The rest of this entry is kept as the reading of this change.
1908
+ - afb2284: `ObjectCalendar`'s user-visible copy now reaches the locale packs. The component
1909
+ was already i18n-aware — it imports and calls both translation hooks — and a set
1910
+ of English sentences sat beside those calls as literals, so a non-English session
1911
+ read a half-translated calendar: the unscheduled label and the two write-failure
1912
+ toasts in its own language, and the loading screen, the error screen, the refusal
1913
+ screen, the pull-to-refresh affordance, the whole quick-create dialog and the
1914
+ record overlay's fallback title in English (objectui#10031).
1915
+
1916
+ Every one of those now reads a key. Two of them reuse keys the packs already
1917
+ carried (`common.cancel`, `common.create`), one reuses `calendar.newEvent`, and
1918
+ twelve are new under `calendar.`.
1919
+
1920
+ **The hook is chosen per site by one criterion, which the file already stated for
1921
+ its existing call:** `useSafeTranslate`'s `tt(key, fallback)` passes no options to
1922
+ i18next and therefore cannot fill a hole, so the two sentences that carry one —
1923
+ the error prefix and the quick-create date line — read their key through
1924
+ `useObjectTranslation`'s `t()` with an inline default, and every hole-free string
1925
+ uses `tt()`.
1926
+
1927
+ **⚠️ A key added to `en` is not an `en`-only change in this repo**, and that is
1928
+ worth stating because the cheap route looks available: the English fallback does
1929
+ carry the English text at the call site, but the key-set invariant is a
1930
+ pack-vs-pack one — every pack defines every `en` key — so it is answered by the
1931
+ locale packs, not by the call site. All ten packs therefore carry the twelve new
1932
+ keys, with real translations rather than the English value. The instrument that
1933
+ says so is `all-locales-key-parity.test.ts`; `pnpm check:i18n-drift` prints which
1934
+ of the two owns an addition.
1935
+
1936
+ **One rendered English string moved**: the loading placeholder's three ASCII full
1937
+ stops became the typographic ellipsis, because its sentence is now a pack value
1938
+ and `ellipsis-glyph-3878.test.ts` holds every pack value to U+2026. The refusal
1939
+ screen's copy is byte-identical to what it rendered before, which is what keeps
1940
+ the five suites that pin it green.
1941
+ - 3ac2de8: Localize the Studio front door's wordmark tooltip (objectui#10043).
1942
+
1943
+ The `/studio` landing's wordmark carried its `title` tooltip as a hard-coded literal
1944
+ in one language, outside i18n entirely — the only user-visible string on that surface
1945
+ that never reached a translation bundle, so every other locale read it in that one
1946
+ language. AGENTS.md commandment #-1 names titles in the categories it covers.
1947
+
1948
+ It now resolves through `useObjectTranslation` and a new `console.studio.backToHome`
1949
+ key, which is the mechanism the rest of this app's chrome already uses (the same route
1950
+ objectui#4024 took for the settings screen, whose chrome was hard-coded beside a keyed
1951
+ sibling). The key ships in all ten locale packs, and each pack's value is that pack's
1952
+ own existing wording for this affordance rather than a new translation — the sibling
1953
+ Home button one route away inside the same frame says the same thing, and the two must
1954
+ not name the same home two different ways.
1955
+ - 63f4f92: A signed-in user's language has one source of truth: `sys_user.locale`.
1956
+
1957
+ One word 「语言」 named two settings. The profile page's language card wrote the
1958
+ server column `sys_user.locale`; the console's globe menu switched a
1959
+ device-local UI language in `localStorage`. Neither writer could see the other,
1960
+ so a user who chose 日本語 on their profile went on reading an English console,
1961
+ with no refusal and no notice — and the profile card's own description said the
1962
+ two were separate, which was true and is what this change ends.
1963
+
1964
+ For a signed-in user the column now decides, and the wiring is what makes that
1965
+ hold rather than anyone's discipline:
1966
+
1967
+ - the globe writes `sys_user.locale` through the same adapter the profile card
1968
+ already used, so there is one writer path rather than two settings;
1969
+ - `@object-ui/app-shell` reads the column back once per shell, on mount and on
1970
+ every `sys_user` mutation the invalidation bus reports — which includes the
1971
+ profile card's save, so changing the language in either place changes it in
1972
+ both, in the same session and with no reload;
1973
+ - the device-local value is kept and demoted to a CACHE: it still decides before
1974
+ sign-in and for the first paint of a boot (the column cannot be read
1975
+ synchronously), and it is overwritten by the column as soon as the row is
1976
+ read. A stale device value can no longer outlive one round trip.
1977
+
1978
+ A failed write is reported to the user and the local switch stands until the
1979
+ next successful read, which then takes the UI back to what the column says —
1980
+ never a silent no-op. A signed-out visitor is unaffected: with no `sys_user`
1981
+ row nothing is read and nothing is written, and the globe is the device-local
1982
+ switch it has always been.
1983
+
1984
+ `@object-ui/i18n` is prose only here. Its precedence chain still adjudicates
1985
+ what it can see (device choice → tenant seed → browser → `en`), and its
1986
+ docblocks now say that a signed-in user's language is not one of those tiers:
1987
+ reading a `sys_user` row needs an authenticated data adapter, which a renderer
1988
+ package does not take as a dependency, so the host applies the column through
1989
+ the ordinary `changeLanguage`.
1990
+ - 8cedb0d: The two declared display-locale contracts now each name the caller they govern,
1991
+ and each points at the other (objectui#10098). This is documentation only: no
1992
+ module's behaviour moves.
1993
+
1994
+ - `DisplayNumberFormatOptions.locale` (`@object-ui/core`): leaving the tag
1995
+ `undefined` means the runtime default, and that rule is written for a non-React
1996
+ display caller with no tag in hand. For that caller the viewer's own environment
1997
+ is the honest locale for a user-facing display. A malformed tag lands in the
1998
+ same place through `formatDisplayNumber`'s retry.
1999
+ - `useDisplayLocale` (`@object-ui/i18n`): its concrete `'en'` last resort, kept for
2000
+ determinism, is the rule for a React renderer whose provider chain yields no tag
2001
+ at all, not a rule for every caller without a tag.
2002
+ - `formatNumberInDisplayLocale` (`@object-ui/plugin-report`): the note on its
2003
+ malformed-tag retry no longer calls the dropped-tag retry behind
2004
+ `formatDisplayNumber` a wrong answer. Each retry follows its own package's
2005
+ declared contract.
2006
+
2007
+ Read alone, either docblock used to look like a rule for every caller, and each
2008
+ contradicted the other. The new wording reaches each package's published type
2009
+ declarations, which is why this is declared as a patch rather than left
2010
+ undeclared.
2011
+ - b57107d: fix(auth,app-shell,console): a browser that changes hands no longer keeps the previous account's UI language
2012
+
2013
+ Both device language slots (`objectui-locale` and `objectui-locale-seed`) hold the
2014
+ language of whoever last signed in on the browser. A boot resolves its language from
2015
+ them before anyone is signed in. When the session then turns out to belong to someone
2016
+ else (an SSO redirect back into the console, or a sign-in in another window),
2017
+ `SessionUserScope.adopt` already purged both slots (objectui#5664). But the live
2018
+ language had already been derived from them, and nothing re-derived it. A new account
2019
+ with no `sys_user.locale` of its own therefore read the previous account's language
2020
+ for its whole first session.
2021
+
2022
+ - `@object-ui/auth` (new exports, hence minor): `getSessionOwnerChangeCount()` and
2023
+ `subscribeSessionOwnerChange(listener)` report that the change-of-owner purge ran
2024
+ in this page-load. They are held in memory, with no new storage key.
2025
+ `SessionUserScope.adopt` now returns `true` when it purged a previous owner.
2026
+ - `@object-ui/app-shell`: `useSignedInUserLocale` resets the UI language on an owner
2027
+ change, the same way a boot of the swept storage would resolve it. It then
2028
+ re-reads the new owner's `sys_user.locale`, which applies after the reset.
2029
+ - `@object-ui/console`: `LocalizationFetchProvider` and the boot-time seed fetch no
2030
+ longer write an answer that was requested before an owner change. Such an answer
2031
+ describes the previous owner.
2032
+ - `@object-ui/i18n` (docs only): the seed was documented as the "tenant's" locale.
2033
+ It is the server's resolved locale for the signed-in caller: their own
2034
+ `sys_user.locale`, else `Accept-Language`, else the deployment default. The docs
2035
+ now say so, and declare what a visitor who has not signed in reads: the last
2036
+ owner's language, until someone else signs in.
2037
+ - bb5d4ee: Two display-locale faces now follow the session's display locale (objectui#10232).
2038
+
2039
+ - `@object-ui/plugin-ai` is wired to `@object-ui/i18n` as a whole. The `nl-query`,
2040
+ `ai-form-assist` and `ai-recommendations` components read their strings from a new
2041
+ `ai.*` namespace, translated in all ten locale packs, instead of hard-coded English.
2042
+ The `nl-query` history date, which was formatted in the machine's locale, and the
2043
+ confidence and score percentages now format in the display locale
2044
+ (`useDisplayLocale()`). The package's manifest now names `@object-ui/i18n`, which also
2045
+ puts it inside the repo-wide machine-locale census.
2046
+ - `@object-ui/app-shell`: the Studio home page's "recently viewed" times and the Data
2047
+ pillar's last-saved time format in the display locale. They used to be handed
2048
+ `useMetadataLocale()`, which picks the designer's string table and is `en-US` for every
2049
+ language other than zh, so a de-DE session read US English. That hook's doc comment
2050
+ now says it must never be used as an `Intl` locale.
2051
+ - dc666f7: fix(i18n): a translation bundle with no field label is recognised as a spec payload (objectui#10235)
2052
+
2053
+ The console's `loadLanguage` asks `isSpecTranslationData` whether the payload it fetched is a
2054
+ `@objectstack/spec` `TranslationData`. On `true` it transforms the payload and namespaces it under
2055
+ `app`, where `useObjectLabel` and the screen-flow runner read it; on `false` it returns the payload
2056
+ untouched, for a mock or local-dev server that already speaks i18next namespaces. The predicate
2057
+ answered `true` only when some `objects` entry carried `fields`, so a genuine bundle that translated
2058
+ object labels, apps, pages, dashboards or flows — and no field label — took the untouched branch, sat
2059
+ at the root of the i18next resource tree, and was read by nothing: every screen drew the authored
2060
+ copy in every locale, with nothing reported.
2061
+
2062
+ `isSpecTranslationData` now answers `true` for a payload carrying any top-level group of the served
2063
+ translation document — `objects`, `apps`, `messages`, `globalActions`, `dashboards`, `datasets`,
2064
+ `pages`, `flows`, `settings`, `metadataForms` or `settingsCommon` — whose value is an object. The
2065
+ console's own tests walk that group list off `@objectstack/spec`'s `GetTranslationsResponseSchema`,
2066
+ so a group the spec adds and the predicate does not know fails a test instead of being dropped.
2067
+
2068
+ An already-namespaced i18next tree still takes the untouched branch: its top-level keys are namespace
2069
+ names (`common`, an app namespace such as `crm`, or the `app` the transform itself emits), none of
2070
+ which is a spec group, and no built-in locale pack namespace is spelled like one. A group-named key
2071
+ holding a string or an array is not treated as a group.
2072
+
2073
+ What a user sees change: in the console, an app whose translation bundle carries no field label now
2074
+ shows its translated object names, app and navigation labels, dashboard and page titles, and
2075
+ screen-flow wizard copy, where it used to show the authored copy.
2076
+ - 3335767: Russian now reads correctly at every count on the tab-count badge's accessible
2077
+ name and the activity-feed reaction chip, and Arabic on the badge and the comment
2078
+ thread's count and reaction tooltip (objectui#10242).
2079
+
2080
+ Each of these surfaces picks its `…One` key at exactly one and the plain key at
2081
+ every other count. Two slots cover English. They do not cover Russian, which needs
2082
+ three noun forms for whole numbers, or Arabic, which needs more. Where the plain key
2083
+ held a single noun form, it was wrong at some of the counts it serves. A Russian tab
2084
+ badge read `2 элементов` at two (the language needs `2 элемента`) and
2085
+ `21 элементов` at twenty-one (it needs `21 элемент`), and a Russian reaction chip
2086
+ read `2 реакций` at two. The Arabic badge, comment count and reaction tooltip used
2087
+ the plural form for three to ten, which is wrong at two and from eleven up.
2088
+
2089
+ The count-not-one half of these five values is now a count label that reads
2090
+ correctly at any number:
2091
+
2092
+ - `ru` `common.itemCount`: `Элементов: N`
2093
+ - `ru` `detail.reactionCount`: the emoji, then `Реакций: N`
2094
+ - `ar` `common.itemCount`: `عدد العناصر: N`
2095
+ - `ar` `collaboration.commentCount`: `عدد التعليقات: N`
2096
+ - `ar` `collaboration.reactionCount`: `عدد التفاعلات: N`
2097
+
2098
+ That is the device objectui#10024 used for `search.resultsCountPlural`, and the one
2099
+ Russian's `collaboration.commentCount` and `collaboration.reactionCount` already
2100
+ use. The singular halves, the key set, the call sites and the other eight packs are
2101
+ unchanged.
2102
+
2103
+ ⚠️ **Dated note, 2026-10-02 — superseded in this release — objectui#11445.**
2104
+ Later in this same release the `…One` / plain-key switch on `common.itemCount`,
2105
+ `detail.reactionCount`, `collaboration.commentCount` and
2106
+ `collaboration.reactionCount` gave way to one count family per key in all ten
2107
+ packs: a numeric count picks the noun form its CLDR category needs (`ru`
2108
+ `2 элемента`, `21 элемент`), the `…One` siblings left every pack, and the count
2109
+ labels above remain only on the base key, which answers a call made without a
2110
+ count. The rest of this entry is kept as the reading of this change.
2111
+ - 1dbb993: fix(plugin-designer): the Navigation Designer has an entry for the spec's `doc` navigation item type
2112
+
2113
+ objectstack#19789 added a `doc` member to the spec's navigation item union (an
2114
+ item that targets a book and/or a doc). `NAV_TYPE_META` in `NavigationDesigner`
2115
+ is keyed by that spec-derived union, so objectui stopped compiling against
2116
+ `@objectstack/spec` built from objectstack `main`, and every row reads its badge,
2117
+ colour and icon from that map. The map now has a `doc` entry (a `BookOpen` icon,
2118
+ its own colour and the `appDesigner.navTypeDoc` label key). The key has an
2119
+ English fallback in the designer's defaults and a translation in all ten locale
2120
+ packs.
2121
+
2122
+ The map is typed `Record<NavigationItemType | 'doc', ...>` so it compiles both
2123
+ against the pinned `@objectstack/spec`, which predates `doc`, and against
2124
+ objectstack `main`. The `| 'doc'` goes away at the pin bump that ships `doc`.
2125
+ `doc` is not added to the quick-add buttons: an empty `doc` item fails the spec's
2126
+ book-or-doc requirement, and authoring one is objectui#10188.
2127
+ - 45362a3: Russian now reads correctly at every count on the list view's record-count bar,
2128
+ and Arabic on that bar, the record picker's count, the activity-feed reaction chip,
2129
+ the presence avatars' accessible name and overflow tooltip, and the full-page
2130
+ search count while browsing (objectui#10425).
2131
+
2132
+ Each of these surfaces picks its `…One` key at exactly one and the plain key at
2133
+ every other count. Where the plain key held a single noun form, it was wrong at
2134
+ some of the counts it serves. The Russian record-count bar read `2 записей` at two
2135
+ (the language needs `2 записи`) and `21 записей` at twenty-one (it needs
2136
+ `21 запись`). The Arabic values each held one noun form (the plural for three to
2137
+ ten, or in `lookup.recordCount` the singular), while Arabic needs different forms
2138
+ at two, at three to ten and from eleven up.
2139
+
2140
+ The count-not-one half of these seven values is now a count label that reads
2141
+ correctly at any number:
2142
+
2143
+ - `ru` `list.recordCount`: `Записей: N`
2144
+ - `ar` `list.recordCount`: `عدد السجلات: N`
2145
+ - `ar` `lookup.recordCount`: `عدد السجلات: N`
2146
+ - `ar` `detail.reactionCount`: the emoji, then `عدد التفاعلات: N`
2147
+ - `ar` `collaboration.presentUserCount`: `عدد المستخدمين المتواجدين: N`
2148
+ - `ar` `collaboration.moreUserCount`: `عدد المستخدمين الآخرين: N`
2149
+ - `ar` `search.itemsAvailable`: `عدد العناصر المتاحة: N`
2150
+
2151
+ That is the device objectui#10024 and objectui#10242 used, and the one Russian's
2152
+ `lookup.recordCount` already uses. The singular halves, the key set, the call sites
2153
+ and the other eight packs are unchanged.
2154
+
2155
+ ⚠️ **Dated note, 2026-10-02 — superseded in this release — objectui#11445.**
2156
+ Later in this same release the `…One` / plain-key switch on `list.recordCount`,
2157
+ `lookup.recordCount`, `detail.reactionCount`, `collaboration.presentUserCount`,
2158
+ `collaboration.moreUserCount` and `search.itemsAvailable` gave way to one count
2159
+ family per key in all ten packs: a numeric count picks the noun form its CLDR
2160
+ category needs (`ru` `2 записи`, `ar` `3 سجلات`), the `…One` siblings left every
2161
+ pack, and the count labels above remain only on the base key, which answers a
2162
+ call made without a count. The rest of this entry is kept as the reading of this change.
2163
+ - 2a943bf: fix(app-shell): a cloud Re-seed / Purge refused with `ENVIRONMENT_KERNEL_UNAVAILABLE` now says why and stops offering the retry
2164
+
2165
+ `reseedSampleData` and `purgeSampleData` kept only the text of a refusal and
2166
+ dropped its error code. On a 500 that text is the control plane's withheld
2167
+ generic sentence, so the package page showed the same message for every server
2168
+ fault. That included a control plane with no environment kernel, where the two
2169
+ actions can never succeed (cloud#2072).
2170
+
2171
+ Both calls now return the code beside the text. When the code is
2172
+ `ENVIRONMENT_KERNEL_UNAVAILABLE`, the package page says that this control plane
2173
+ has no environment kernel and that the action must be run from the
2174
+ environment's own runtime, and it disables both cloud sample-data actions for
2175
+ the rest of the visit. Every other code, `INTERNAL_ERROR` included, keeps
2176
+ today's message and leaves the actions enabled. The HTTP status handling is
2177
+ unchanged.
2178
+
2179
+ `@object-ui/i18n` carries the new sentence as `marketplace.detail.sampleDataKernelUnavailable`
2180
+ in all ten locale packs.
2181
+ - 8740e86: fix(fields): the editable date-time faces no longer roll a stored impossible day into a real one
2182
+
2183
+ `toDateTimeInputValue` let a `Z`, offset or date-only spelling fall through to
2184
+ `new Date(...)`, which accepts a day of 01-31 for every month and rolls the
2185
+ surplus forward: a stored `2026-02-30T10:00:00Z` reached the `datetime-local`
2186
+ control as `2026-03-02T10:00`, and an edit could save that day back. The read
2187
+ faces already refuse such a value (objectui#10026, objectui#10301).
2188
+
2189
+ The adapter now asks the same judgement (`isRealCalendarDate`) of the day AS
2190
+ WRITTEN, before any conversion, in every spelling, and `fromDateTimeInputValue`
2191
+ never re-emits a nonexistent day as a rolled one. A `datetime-local` control can
2192
+ paint a nonexistent day only blank (measured in Chromium), so the editable
2193
+ `DateTimeField` and the sub-grid's editable `datetime` cell leave the control
2194
+ empty, mark it `aria-invalid`, and name the stored string beside it with the new
2195
+ `fields.dateTime.impossibleDay` sentence (all ten packs). Nothing is written until
2196
+ the user picks a new value; a real date-time and a zone-less value behave as before.
2197
+ - ac2d6f1: fix(fields): the editable date-only faces no longer blank a stored impossible day silently
2198
+
2199
+ `toDateInputValue` keeps a stored `2026-02-30` as written and never rolls it,
2200
+ but an `<input type="date">` sanitises a day that does not exist to an empty
2201
+ value (measured in Chromium). So `DateField` and the sub-grid's editable `date`
2202
+ cell showed an empty control with no marker for a value that was stored. That
2203
+ is a silent blank, which the direction of objectui#10026 rules out, and it is
2204
+ the date-only half of objectui#10474.
2205
+
2206
+ Both faces now judge the day as written with `isImpossibleStoredDay` (the check
2207
+ objectui#10474 added). For such a day they hand the control an empty value,
2208
+ mark it `aria-invalid`, and name the stored string beside it with the new
2209
+ date-only sentence `fields.date.impossibleDay`, in all ten packs. The control's
2210
+ `aria-describedby` points at that sentence. Nothing is written until the user
2211
+ picks a new day, and picking a real day clears the marker. Real days and empty
2212
+ values render as before.
2213
+ - 9fbbb17: BREAKING (`@object-ui/components`): `RefreshIndicator`'s `ariaLabel` prop is now required and has no default. It used to default to the English literal "Refreshing", so the progress bar on every view that passed no name was announced in English to screen-reader users in every locale (objectui#10580). A `RefreshIndicator` rendered without `ariaLabel` now fails to type-check.
2214
+
2215
+ (The bump is `minor` by this repo's release model: objectui's major follows the `@objectstack` family major, and its own breaking changes ship as `minor` with the breaking semantics stated here.)
2216
+
2217
+ Migration: pass the bar's accessible name from your own translation layer, for example `ariaLabel={t('grid.refreshing')}`. The component renders the string you pass as the bar's `aria-label` and does not translate it.
2218
+
2219
+ `ObjectGrid`, `ListView`, `ObjectChart` and `ObjectDataTable` now name their refresh bar in the active locale. The grid and the list read the `grid.refreshing` and `list.refreshing` keys their pull-to-refresh text already uses. The chart reads `chart.refreshing` and the dashboard data table reads `dashboard.refreshing`, both with no English literal behind them, so a host that renders either one with no i18next instance at all gets the key itself as the bar's name.
2220
+
2221
+ `@object-ui/i18n`: new keys `chart.refreshing` and `dashboard.refreshing` in every built-in locale pack.
2222
+ - f905090: The console's record-count footer, drawn under the list when a record is open
2223
+ beside it in split navigation, now reads `1 record` at one record instead of
2224
+ `1 records` (objectui#10636).
2225
+
2226
+ The footer rendered one key, `console.objectView.recordCount`, at every count.
2227
+ It now picks the new `console.objectView.recordCountOne` at exactly one record
2228
+ and `console.objectView.recordCount` at every other count, the same switch the
2229
+ list view's record-count bar uses. Every built-in pack words the pair the way it
2230
+ words the list bar's `list.recordCount` / `list.recordCountOne`, so the footer
2231
+ and the bar count the same records in the same words. In Japanese, Korean and
2232
+ Chinese, which have no separate singular, the two values are the same string.
2233
+
2234
+ Russian and Arabic also read correctly at other counts now. The count-not-one
2235
+ value held one noun form: Russian read `2 записей` at two and `21 записей` at
2236
+ twenty-one, and Arabic used the singular noun at every count. Both now use the
2237
+ count label their list bar already uses, `Записей: N` and `عدد السجلات: N`,
2238
+ which is right at any number. At one record they read `1 запись` and `1 سجل`.
2239
+
2240
+ ⚠️ **Dated note, 2026-10-02 — superseded in this release — objectui#11445.**
2241
+ Later in this same release the `console.objectView.recordCountOne` /
2242
+ `console.objectView.recordCount` switch gave way to a
2243
+ `console.objectView.recordCount` count family in all ten packs, worded like
2244
+ `list.recordCount`'s: the footer calls the family key with `{ count }`,
2245
+ `console.objectView.recordCountOne` left every pack, and `ru` and `ar` read a
2246
+ noun form at each count (`2 записи`, `3 سجلات`) where the count label stood. The rest of this entry is kept as the reading of this change.
2247
+ - eb97ce6: fix(plugin-timeline,plugin-charts,i18n): a stored date-only day reads as that day in every viewer zone on the timeline, on a chart's date axis and in the published date helpers (objectui#10866, slice 2)
2248
+
2249
+ These readers parsed a `YYYY-MM-DD` value with the engine's own `Date` parse, which reads it as UTC midnight, so every viewer west of UTC saw the day before. Each now reads the value through `toDisplayDate` from `@object-ui/core`, which rebuilds a date-only value at local midnight of the day it names. A value with a time part keeps its instant.
2250
+
2251
+ - **Timeline.** The object timeline's date bucket, its sort and the item date the renderer prints read the value that way. West of UTC an item due today no longer sits under "Overdue", one due tomorrow no longer sits under "Today", and the `short` and `long` item faces print the stored day. The `iso` face prints a date-only value's day from local getters, and an instant's UTC day as before. A day its month does not have, such as `2026-02-30`, is no longer rolled into March: the item sits under "No date", sorts with the dateless items, and its `short` and `long` faces are the ones an unparsable value already had. An unparsable value now sorts with the dateless items too, and the `iso` face prints such a value as written where it used to throw. The gantt variant's axis headers, extent and bar positions are not changed here, so west of UTC its bar tooltip, which prints through the same item-date function, now names the stored day while the axis above it still reads the day before, until a later slice of objectui#10866.
2252
+ - **Charts.** A date-only category on the x axis reads `Sep 1` for `2026-09-01`, and `Sep 2026` on a month-grained axis, in every zone. When the display locale is a tag `Intl` refuses, the tick still prints the stored day: it reads the local-midnight value with local getters, because its `toISOString()` would name the day before east of UTC. An instant's fallback keeps its UTC day. A date-only category naming a day its month does not have falls back to the raw category, the face any other non-date category gets, instead of the rolled day.
2253
+ - **i18n helpers.** `formatDate`, `formatDateTime`, `formatRelativeTime` and `formatDateSpec` read a date-only string as the day it names: `formatDateTime` shows midnight of that day, and `formatRelativeTime` counts to the start of it. `formatDateSpec` applies its `timeZone` to an instant only, and formats a date-only value in the local zone, where the shared step's local midnight reads back as its day; a `timeZone` west of UTC no longer turns `2026-09-01` into August 31st for every viewer. All four return a day its month does not have as the raw string. `formatRelativeTime` now returns an unparsable value as its string, the face the other three already gave one, where it used to throw a `RangeError`.
2254
+ - ac15833: fix(data-objectstack, plugin-dashboard): a dataset tile the viewer may not read shows a localized "no access" state (objectui#10899)
2255
+
2256
+ When the analytics read admission refused a dashboard dataset query — `403`
2257
+ with ADR-0112 `PERMISSION_DENIED` — `queryDataset` had no branch for that code,
2258
+ so it threw the generic `Dataset query failed: 403 Forbidden — [Analytics]
2259
+ Access denied: …` string, and `DatasetWidget` printed it verbatim in a red
2260
+ alert. The list view over the same object already says 「无权访问」.
2261
+
2262
+ - `queryDataset` now throws a typed `AnalyticsForbiddenError` for that code,
2263
+ carrying `httpStatus: 403` and `code: 'PERMISSION_DENIED'` (plus the server's
2264
+ code and message for diagnostics). A code-less 403 — no ObjectStack route
2265
+ wrote it — keeps the generic error.
2266
+ - `DatasetWidget` classifies a failed query with the shared `classifyLoadError`
2267
+ and, for `forbidden`, renders a localized no-access state
2268
+ (`dashboard.widgetForbiddenTitle` / `dashboard.widgetForbiddenMessage`, new in
2269
+ all ten packs) instead of the exception text. Every other failure keeps the
2270
+ detailed alert.
2271
+ - ac15833: fix(app-shell, plugin-detail): a record comment whose write fails is never shown as sent (objectui#10899)
2272
+
2273
+ The record page's discussion writers appended the new comment (or reply) to the
2274
+ panel first and then fired the `sys_comment` create, swallowing its rejection.
2275
+ On a tenant with no `sys_comment` the write answered 404 while the panel showed
2276
+ the comment and a bumped count, with no error — and the comment was gone after a
2277
+ reload.
2278
+
2279
+ `RecordDetailView` now appends a comment or reply only after its write resolves.
2280
+ A failed write raises a localized error toast (`detail.commentFailed`, new in all
2281
+ ten packs) and rejects back to the composer. The composers — the activity
2282
+ timeline's comment box, the threaded reply input, and `RecordComments` — now
2283
+ treat a rejected `onAddComment` / `onAddReply` explicitly as "not written": they
2284
+ keep the draft for a retry instead of letting the rejection escape unhandled.
2285
+ - e2dffc9: fix(app-shell): a failed reaction write takes the reaction back and says so, instead of staying shown as applied (objectui#10899)
2286
+
2287
+ A reaction click on the record page's discussion shows at once and is stored in
2288
+ the background with a `sys_comment` update. That update was issued from inside
2289
+ the React state updater and its rejection was discarded, so a refused write
2290
+ left the reaction on screen as applied, with no message, until a reload. In a
2291
+ StrictMode development build the updater also ran twice, so one click sent two
2292
+ updates.
2293
+
2294
+ `RecordDetailView` now computes the toggle and issues the write from the click
2295
+ handler, one write per click. When a write is refused, the row shows the newest
2296
+ reaction set a write stored or can still store, and a localized error toast
2297
+ (`detail.reactionFailed`, new in all ten packs) says the reaction was not
2298
+ saved. A refused click that a later click's write also carries is kept, because
2299
+ each write stores the row's whole reaction set. The stored
2300
+ `{ emoji: userIds[] }` shape is unchanged.
2301
+ - 328abeb: Four Console surfaces that read English under a zh-CN session now read the session's
2302
+ language (objectui#10900). English stays the default.
2303
+
2304
+ - **The generic action success toast.** When an action declares no `successMessage`, the
2305
+ runner falls back to "Action completed successfully".
2306
+ That fallback now goes through a translator: `ActionRunner.setTranslator(translate)` is
2307
+ new in `@object-ui/core`, and `<ActionProvider>` and `useActionRunner` in
2308
+ `@object-ui/react` install the session's `t` on the runner they build. The pack key is
2309
+ `actions.completedSuccessfully`. An author's `successMessage` still reaches the toast
2310
+ untranslated, and a runner with no translator installed still toasts the English sentence.
2311
+ - **The social sign-in buttons on the login and sign-up pages.** `SocialSignInButtons`
2312
+ takes a new `buttonText` prop, a template whose `{provider}` is replaced with the
2313
+ provider's display name; unset, the buttons keep "Continue with {provider}" and
2314
+ "Sign up with {provider}". `LoginForm` and `RegisterForm` take a new `socialButton`
2315
+ label and pass it, with their existing `orText` label, to the buttons. `orText` was
2316
+ documented as the divider label but rendered nowhere; it now sets the divider under the
2317
+ buttons, and its documented default is corrected from "or" to "or continue with email",
2318
+ the text that divider has always shown. The console's login and sign-up pages and
2319
+ `@object-ui/app-shell`'s `DefaultLoginPage` / `DefaultRegisterPage` pass the new
2320
+ `auth.login.*` and `auth.register.*` `socialButton` / `orText` keys. The provider's display
2321
+ name is inserted as-is: the component's own label for the branded providers it knows,
2322
+ otherwise the name the server reports.
2323
+ - **Build Doctor.** The build conversation's Build Doctor button (its accessible name and
2324
+ both tooltips) and the title of the drawer it opens read `console.ai.buildDoctor`,
2325
+ `console.ai.buildDoctorTitle` and `console.ai.buildDoctorDisabledTitle`.
2326
+ - **Setup → marketplace.** On `system/marketplace` and the pages under it, the breadcrumb
2327
+ segment after System reads `console.breadcrumb.marketplace` instead of the humanized URL
2328
+ slug; other `system/*` segments are unchanged. The zh marketplace search placeholder
2329
+ reads 「按名称或标识搜索应用…」 instead of 「按名称或 manifest ID 搜索应用…」. The search
2330
+ itself is unchanged: it matches the display name, the identifier and the description.
2331
+
2332
+ All ten locale packs carry the nine new keys; no existing `en` value changes.
2333
+
2334
+ **Clause-②: yes** — besides the nine pack keys, the public surface widens by four optional
2335
+ members: `ActionRunner.setTranslator`, `SocialSignInButtonsProps.buttonText`,
2336
+ `LoginFormLabels.socialButton` and `RegisterFormLabels.socialButton`. Nothing is removed,
2337
+ renamed or narrowed, no accept set changes, and `@object-ui/react` exports nothing new.
2338
+ - 24d3e65: New SDUI widget `cloud:plan-status`: a "Current plan" badge for one plan card on the Cloud pricing page, shown when that card's plan is the organization's plan (objectui#10919).
2339
+
2340
+ **Clause-②: yes** — the accept set of `AnyComponentSchema`, and so of `safeValidateSchema` and `objectui validate`, widens by one `type` literal, `cloud:plan-status`, and `@object-ui/types/zod` exports one new schema, `CloudPlanStatusSchema`. Nothing that parsed before is refused now.
2341
+
2342
+ **Why.** The pricing page is static metadata, and nothing in a page's expression scope carries the organization's plan, so the page could not tell which of its cards the organization is already on. The plan is available only from the org-scoped `GET /cloud/environment-entitlements` summary.
2343
+
2344
+ **What changed, in observable terms.**
2345
+
2346
+ - `@object-ui/app-shell` registers `cloud:plan-status`. A page places one node on each plan card and names that card's plan code in `properties.plan`: `{ "type": "cloud:plan-status", "properties": { "plan": "free" } }`. The widget reads the summary through the hook the environment list already uses, and renders the badge when the summary's `plan` equals `properties.plan`. The comparison is exact, so `Free` does not match `free`.
2347
+ - The widget renders nothing on every other card, while the summary loads, when the request fails, and when the body is not the `{ success, data }` envelope. It never guesses a plan.
2348
+ - The node's `className` and `responsiveStyles` reach the badge. Each node reads the summary itself, so a page with three cards makes three requests.
2349
+ - The widget is registered under one key, `cloud:plan-status`. There is no bare `plan-status` fallback and no `app-shell:`-prefixed twin.
2350
+ - `@object-ui/types/zod` exports `CloudPlanStatusSchema`, a member of `AnyComponentSchema`. `properties` is required and must be exactly `{ plan }`, with `plan` a non-empty string. A missing bag, a missing or empty `plan`, and any other key in the bag are each refused at that path.
2351
+ - `body` and `children` are refused by name on this node, because the widget reads neither.
2352
+ - `plan` is not an enum: the plan catalog belongs to the control plane, and ObjectUI does not list its codes.
2353
+ - `@object-ui/i18n` adds `cloudPlanStatus.current` ("Current plan") to all ten locale packs.
2354
+ - `@object-ui/cli`: `objectui check` knows `cloud:plan-status` as a registered type.
2355
+ - 4b742f4: fix(components): an `element:number` that asks for an aggregate and names no object says so instead of painting a silent dash
2356
+
2357
+ Since `element:number` accepts its object from the node-level `dataSource`
2358
+ binding (objectui#10909), `object` is no longer a required input, and the
2359
+ manifest has no way to say "one of `object` and `dataSource.object`". A node
2360
+ that authors an `aggregate` with neither therefore passes the html tier with no
2361
+ diagnostic, and the renderer used to paint "—", which reads like a real empty
2362
+ value.
2363
+
2364
+ The renderer now draws a short muted notice in that case: "No object named: set
2365
+ object or dataSource.object." It is renderer chrome, so it reads the locale
2366
+ packs (`element.number.noObject`, added to all ten packs) and speaks the session
2367
+ language. It does not throw and it queries nothing.
2368
+
2369
+ Only authored absence draws it. A binding that names an object keeps its own
2370
+ panels while its `view` is resolving or after it failed to resolve, and a node
2371
+ with no `aggregate` still paints the dash exactly as before.
2372
+ - 6cd8f66: The console strings objectui#10900 left English under zh-CN now read the session's language (objectui#10969).
2373
+
2374
+ - **The action runner's other own text.** `ActionRunner` asks the translator installed through `setTranslator` (objectui#10900) for three more strings it writes itself: the error toast when the error that reached it carries no readable message (`actions.failed`), a parallel chain's error when no failed action reported one (`actions.parallelFailed`), and the label of an undoable success toast's Undo button (`actions.undo`). An author's `errorMessage` / `successMessage` and an action's own error stay untranslated. With no translator the text stays English.
2375
+ - **The Undo label now arrives from the runner.** For an undoable result the runner hands the toast handler `undo: { label }` where it used to hand `undo: {}`; `ToastHandler`'s type is unchanged (`label` was already optional). A handler that renders `options.undo.label` shows the translated label; one that ignores it keeps its own.
2376
+ - **The console app's `system/*` breadcrumbs.** In `AppHeader`, the segment after `System` for `settings`, `apps`, `profile`, `approvals`, `ai-approvals` and `audit-log` reads `console.breadcrumb.*` (as `marketplace` has since objectui#10900). In English, `ai-approvals` now reads `AI Approvals` instead of the humanized slug `Ai Approvals`; the other five read as before. A segment the header does not know keeps its humanized slug.
2377
+ - **The Build Doctor drawer's body.** Its description, loading and not-found states, summary line, verdict, and each section's title and hint read `console.ai.buildDoctorDrawer.*`. The report's own data (tool, artifact and status names, timeline text) stays verbatim.
2378
+ - **"manifest ID" leaves the marketplace search placeholder in every pack.** `en` now reads `Search apps by name or app ID…`, and the eight packs that translated the jargon literally follow. zh already read 「按名称或标识搜索应用…」 and is unchanged.
2379
+
2380
+ The new keys are in all ten packs.
2381
+ - e327c89: fix(console): a sign-up refused by the server's audience gate reads in the session's language (objectui#10998)
2382
+
2383
+ When an environment admits new accounts by invitation only (the server's default)
2384
+ or only from allowlisted email domains, the server refuses `/sign-up/email` with
2385
+ `403 SELF_REGISTRATION_CLOSED` or `403 EMAIL_DOMAIN_NOT_ALLOWED`. The console's
2386
+ register page mapped only the user-exists codes to pack text, so either refusal
2387
+ showed the server's English message in every language, and the invitation-only
2388
+ refusal also named the internal setting behind it.
2389
+
2390
+ - `@object-ui/console`: `RegisterPage` maps both codes to localized text. A code it
2391
+ does not map still shows the server's own message.
2392
+ - `@object-ui/i18n`: all ten locale packs carry the two new keys,
2393
+ `auth.register.errors.selfRegistrationClosed` and
2394
+ `auth.register.errors.emailDomainNotAllowed`. No existing value changes.
2395
+ - 2eaf5be: `WizardForm`'s own chrome now reads the locale packs (objectui#10999). The
2396
+ default Cancel, Back, Next, Submitting, Create and Update labels, the
2397
+ "Step x of y" counter, the step indicator's label for a step that declares no
2398
+ `label`, the indicator's accessible name, and the notice on a step with no
2399
+ fields were English literals, so a wizard in a zh session showed `Cancel`,
2400
+ `Step 1 of 3` and `Next` beside a Chinese UI. They now resolve through the
2401
+ wizard's existing `createSafeTranslation` hook, the way the rest of
2402
+ plugin-form's chrome does.
2403
+
2404
+ An authored `cancelText`, `prevText`, `nextText` or `submitText` still renders
2405
+ exactly as authored, in every locale: only the defaults are localized, and the
2406
+ four keys stay plain strings.
2407
+
2408
+ Five of the strings reuse keys the packs already carried: `common.cancel`,
2409
+ `common.next`, `form.create`, `form.update`, and `form.stepOf`, a key that
2410
+ `pnpm check:i18n-dead-keys` listed as read by nothing until now. Five are new
2411
+ under `wizard.` in all ten packs: `back`, `submitting`, `stepFallback`,
2412
+ `progressLabel` and `emptyStep`. The wizard gets its own `back` rather than
2413
+ `common.back` because zh words a wizard's step back ("previous step", the term
2414
+ `grid.import.back` and `grid.bulk.back` already use) differently from a page's
2415
+ back ("return").
2416
+
2417
+ The zh value of `form.stepOf` gains the spaces around its numbers that the zh
2418
+ pack's other position counters, such as `table.pageInfo` and
2419
+ `detail.recordOf`, already carry.
2420
+
2421
+ **One rendered English string changes**: the final button's in-flight label was
2422
+ typed with three ASCII full stops and is now `Submitting…` with the typographic
2423
+ ellipsis, because it is a pack value now and `ellipsis-glyph-3878.test.ts`
2424
+ holds every pack value to U+2026. Every other English default renders the same
2425
+ text as before.
2426
+ - bf7ab35: A record form whose fields are all locked because the user may not create (or
2427
+ edit) records of its object now says so (objectui#11000). The ADR-0092 D4 lock
2428
+ disables every field when the object's affordance for the form's mode is
2429
+ closed: the object's `managedBy` bucket keeps it closed, or the server's
2430
+ effective API operation set for the user lacks `create` (or `update` on an edit
2431
+ form). Until now nothing on the form said why, and a wizard's Next stayed
2432
+ enabled.
2433
+
2434
+ - Every form layout that draws the lock (the default form, `drawer`, `modal`,
2435
+ `tabbed`, `split` and `wizard`) renders one notice above the fields, such as
2436
+ "You don't have permission to create Project records. The fields are
2437
+ read-only." It names the object by its label, translated when the app's
2438
+ locale bundle translates it, and names `create` on a create form or `edit` on
2439
+ an edit form. It is announced as a status (`role="status"`).
2440
+ - The notice reads the same verdict the lock does, so it appears exactly when
2441
+ every field is locked for that reason. A field locked on its own (one the
2442
+ user may read but not edit, or one declared `readonly`) shows no notice.
2443
+ - A wizard does not walk a user who cannot submit through its steps: while the
2444
+ lock holds, Next and the final submit button are disabled, and the step
2445
+ indicator does not jump forward even with `allowSkip`. Cancel and Back stay
2446
+ usable, and every step is still shown. A user whose affordance is open sees
2447
+ no change.
2448
+ - Two keys are new in the `form` namespace of all ten packs:
2449
+ `noPermissionToCreate` and `noPermissionToEdit`, each with an `{{object}}`
2450
+ placeholder.
2451
+
2452
+ The lock itself is unchanged, and so is the Save button of the non-wizard
2453
+ layouts.
2454
+ - 51c2949: The form family's own feedback chrome now reads the locale packs (objectui#11039). The default success toast, the thank-you heading, the loading line, the load-failure heading and the default submit and cancel labels were English literals, so a zh session saw `Created`, `Thanks!`, `Loading form...` and `Error loading form` beside a Chinese UI. They now resolve through the i18n catalogue, the way `WizardForm`'s footer has since objectui#10999.
2455
+
2456
+ **What moved.**
2457
+
2458
+ - `@object-ui/plugin-form`, across the presentations `object-form` routes to:
2459
+ - The success toast when no `successMessage` is authored: `form.created` after a create, `form.saved` after an edit (the default arm and `WizardForm`), and `MasterDetailForm`'s built-in save toast, whose "… saved" after an authored `title` is `form.savedNamed`.
2460
+ - The note that rides that toast when a declared `navigateOnSuccess` was refused: `form.navigateRefused`. The submitter reads it, as the toast's description.
2461
+ - The thank-you heading when a `thank-you` submit behaviour declares no `title` (the default arm and `WizardForm`): `publicForm.thankYouTitle`.
2462
+ - The loading line of the default arm, `TabbedForm`, `SplitForm`, `WizardForm` and `DrawerForm`: `publicForm.loading`.
2463
+ - The heading of the load-failure panel in those five and `ModalForm`: `form.errorLoading`.
2464
+ - The default submit label, `form.create` or `form.update`, in the default arm, `TabbedForm`, `SplitForm`, `DrawerForm` and `ModalForm`; the default Cancel of `DrawerForm` and `ModalForm`, `common.cancel`; and `MasterDetailForm`'s default Save, Create and Cancel, `common.save`, `form.create` and `common.cancel`.
2465
+ - `@object-ui/console`: the form page (`/f/:slug` and `/forms/:name`) reads its loading line from `common.loading`, its success toast from `form.submitted`, and its default thank-you heading and message from `publicForm.thankYouTitle` and `publicForm.thankYouMessage`, through the `I18nProvider` that `main.tsx` mounts above every console route.
2466
+ - `@object-ui/i18n`: six keys are new under `form.` in all ten packs: `created`, `saved`, `savedNamed` (`{{title}}`), `submitted`, `errorLoading` and `navigateRefused`. The three `publicForm.` keys were already in all ten packs with nothing reading them (`pnpm check:i18n-dead-keys` listed them as confirmed dead), and say the same thing. The zh value of `publicForm.thankYouTitle` ends with a full-width exclamation mark instead of an ASCII one.
2467
+
2468
+ **Authored values still win.** An authored `successMessage`, `submitText` or `cancelText`, a plain string or a per-locale map, and a `thank-you` behaviour's own `title` and `message`, render as authored in every locale. Only the defaults are localized. No schema key is added, renamed or retyped.
2469
+
2470
+ **Three English strings change their rendered text**, because each is now the value of a key whose English differs from the old literal:
2471
+
2472
+ - the loading line, `Loading form...` with three ASCII full stops, is now `Loading form…` with the typographic ellipsis;
2473
+ - the thank-you heading, `Thanks!`, is now `Thank you!`, in the default arm, `WizardForm` and the form page;
2474
+ - the form page's default thank-you message, `Your submission has been received.`, is now `Your submission has been received successfully.`
2475
+
2476
+ Every other English default renders the same text as before. With no i18n provider mounted, plugin-form's forms still render English from their defaults tables, which `pnpm check:i18n-keys` holds byte-identical to the `en` pack.
2477
+ - 8732846: The console's undo confirmation toast reads the session's language (objectui#11056).
2478
+
2479
+ - **The toast after Undo.** Pressing the Undo button on an undoable success toast used to raise a hard-coded English "Change undone", even where the button itself was already translated (objectui#10969). Both console undo handlers, in `useConsoleActionRuntime` and in `RecordDetailView`, now read the new `actions.undone` key through the translator each already holds. Under zh-CN the toast reads 「已撤销更改」.
2480
+ - **English is unchanged.** The `en` value is `Change undone`, the same text as before.
2481
+ - **The new key is in all ten packs.** de, es, fr, ja, ko, pt and ru word it as their own `navigationSync.undone` without the word for navigation; ar does the same and gives the noun its article. zh reads 「已撤销更改」, with the 撤销 of `actions.undo` and the 更改 of the pack's 「保存更改」.
2482
+
2483
+ `AppContent`'s own Ctrl+Z handler, which toasts `Undo: ` or `Redo: ` followed by the operation's description, is not changed here.
2484
+ - 54997ff: fix(console,plugin-form,i18n): the public form page and the master-detail form chrome speak the user's language
2485
+
2486
+ objectui#11039 moved the form family's feedback chrome onto the locale packs and
2487
+ left two halves behind, both still English inside a Chinese UI.
2488
+
2489
+ The console form page (`/f/:slug`, `/forms/:name`) rendered its action chrome from
2490
+ literals: the submit button's `Submit`, its in-flight `Submitting…` and
2491
+ `Uploading…`, the `Redirecting…` line of a pending redirect, and the frame of the
2492
+ `Required: …` refusal. They now read `publicForm.submit`, `publicForm.submitting`,
2493
+ `fields.file.uploading` (the key every other form's in-flight Save label already
2494
+ reads), and two new keys, `publicForm.redirectPending` and
2495
+ `publicForm.requiredFields`. The refusal is one key with a `{{fields}}` hole and
2496
+ its labels are joined with the pack's own list separator, so the colon, its
2497
+ spacing and the word order belong to the locale.
2498
+
2499
+ `plugin-form` had the same literals in the master-detail form: `Loading columns…`,
2500
+ the `Subtotal` / `Tax (N%)` / `Total` stack, the row editor's `Line item — row N`
2501
+ title with its `Apply` and `Close`, the in-form collection's `Add`, and the Save
2502
+ button's `Saving…`; in `ModalForm`, the sr-only description of a master-detail
2503
+ dialog; and in `ObjectForm`, the hint under a field the caller may read but not
2504
+ write. They now read the pack (new keys under `form.masterDetail` and
2505
+ `form.deniedDescription`, plus the existing `detail.add`, `detail.saving` and
2506
+ `common.close`), in all ten packs.
2507
+
2508
+ Only defaults move. An authored value still wins everywhere it did: a collection
2509
+ `title` and `addLabel`, a dialog `description`, a field `description`, an authored
2510
+ field label inside the refusal. English output is byte-identical to the literals.
2511
+ - 1d6a23d: The console's global Undo and Redo toasts read the session's language (objectui#11080).
2512
+
2513
+ - **The Ctrl+Z / Ctrl+Shift+Z toasts.** `AppContent`'s `useGlobalUndo` handlers used to raise `Undo: ` or `Redo: ` in English before the operation's description, under every language. They now read two new keys, `actions.undoneOperation` and `actions.redoneOperation`, through the translator `AppContent` already holds. The operation's description is interpolated unchanged, so the pack owns the words and their order. Under zh-CN the toasts read 「撤销:…」 and 「重做:…」.
2514
+ - **English is unchanged.** The `en` values are `Undo: {{description}}` and `Redo: {{description}}`, the same text as before.
2515
+ - **The new keys are in all ten packs.** Each pack words them with its own `actions.undo` and its own word for Redo, followed by its usual colon: full-width in zh, and a space before the colon in fr.
2516
+
2517
+ The description of an operation whose action declared no `label` is still built in English by its producer. That half of objectui#11080 is not changed here.
2518
+ - cff8641: The dashboard's refresh button now speaks the session language (objectui#11097). `DashboardRenderer` and `DashboardGridLayout` hard-coded "Refresh All", "Refreshing…" and the accessible name "Refresh dashboard", so a zh-CN session read English on the button that every console dashboard shows since the console began wiring `onRefresh`. Both components now read the pack keys `dashboard.refreshAll`, `dashboard.refreshDashboard` and the existing `dashboard.refreshing`. The English wording is unchanged.
2519
+
2520
+ `@object-ui/i18n`: new keys `dashboard.refreshAll` and `dashboard.refreshDashboard` in every built-in locale pack.
2521
+ - 385ebc5: fix(plugin-form,fields,i18n): the record page's line-items panel and the line-items grid speak the user's language
2522
+
2523
+ Under a Chinese session the record page's `record:line_items` panel still
2524
+ rendered some of its chrome in English. Its default title read `Line Items` and
2525
+ its button read `Save` / `Saving…`. The line-items grid (`GridField`) read
2526
+ `Add line` on its Add button, `No items yet — click “Add” to begin.` in list
2527
+ mode, and `No items` when read-only.
2528
+
2529
+ They now read the locale packs. The button reuses `common.save` and
2530
+ `detail.saving`, and the empty text names `detail.add`. Four new keys are added
2531
+ to all ten packs: `form.lineItems.title`, `fields.grid.addLine`,
2532
+ `fields.grid.noItems` and `fields.grid.noItemsAddHint`.
2533
+
2534
+ Only the defaults move. An authored `title` or `add_label` still wins, and an
2535
+ authored `add_label` is also the label the empty text names. English output is
2536
+ byte-identical to the literals these replace, with or without an i18n provider.
2537
+ - a8c5509: fix(plugin-form,fields,i18n): the line-items panel, the grid field and the master-detail heading finish speaking the user's language
2538
+
2539
+ Under a Chinese session the rest of the line-items chrome still rendered in
2540
+ English. The record page's `record:line_items` panel read `Loading…`, `Save the
2541
+ record first to add line items.`, `This record’s line items have not been
2542
+ loaded.`, the `Failed to load line items` / `Failed to save line items`
2543
+ fallbacks and its no-`childObject` hint. The line-items grid (`GridField`) read
2544
+ its footer `Total`, its column chooser's `Columns` / `Optional columns`, the
2545
+ computed cell's `Computed` tooltip and its row actions. A master-detail
2546
+ collection with no `title` was headed `Line Items`.
2547
+
2548
+ They now read the locale packs. Reused keys: `common.loading`, `table.columns`,
2549
+ `form.masterDetail.total` (the footer), `view.dragToReorder` (the drag handle)
2550
+ and `form.lineItems.title` (the master-detail heading). New keys, in all ten
2551
+ packs: `form.lineItems.saveRecordFirst`, `form.lineItems.notLoaded`,
2552
+ `form.lineItems.loadFailed`, `form.lineItems.saveFailed`,
2553
+ `form.lineItems.noChildObject`, `fields.grid.optionalColumns`,
2554
+ `fields.grid.computed`, `fields.grid.openRow`, `fields.grid.duplicateRow` and
2555
+ `fields.grid.removeRow`.
2556
+
2557
+ Each row action now has one key, read by both its `aria-label` and its `title`.
2558
+ The English kept for each is the accessible name it already had:
2559
+
2560
+ - open the row in the full form: `Open row` (the tooltip was `Open full form`);
2561
+ - duplicate the row: `Duplicate row` (the tooltip was `Duplicate line`);
2562
+ - remove the row: `Remove row` (no tooltip, as before);
2563
+ - the drag handle: `Drag to reorder`.
2564
+
2565
+ Only the defaults move. An authored collection `title` still wins, and so does
2566
+ a failure message the server sent. Apart from the two tooltips above, English
2567
+ output is byte-identical to the literals these replace, with or without an i18n
2568
+ provider.
2569
+ - c2a8d23: fix(fields,plugin-form,i18n): the line-items grid's required-cell text and the master-detail form's config hints read the locale packs
2570
+
2571
+ Under a Chinese session two parts of the line-items family still rendered in
2572
+ English. The line-items grid (`GridField`) named a required, empty cell as the
2573
+ column label followed by ` is required`. That text is the plain cell's tooltip
2574
+ and the `error` the lookup and file cells take. A master-detail collection that
2575
+ could not be resolved showed one of three English configuration hints: no
2576
+ `childObject`, a child schema that failed to load, or no lookup or
2577
+ master_detail field linking the child to the parent.
2578
+
2579
+ They now read the locale packs. The grid cells reuse `validation.required`
2580
+ (`{{field}} is required`), with the column label in `{{field}}`: the sentence
2581
+ the form renderer already shows for a required field. The three hints read
2582
+ new keys in all ten packs: `form.masterDetail.noChildObject`,
2583
+ `form.masterDetail.schemaUnavailable` and
2584
+ `form.masterDetail.noRelationshipField`. Property names (`childObject`,
2585
+ `relationshipField`) and object names stay code elements and are never
2586
+ translated.
2587
+
2588
+ English output is byte-identical to the literals these replace, with or
2589
+ without an i18n provider.
2590
+ - 1263e40: fix(app-shell,plugin-detail): the record feed says "no permission" when its read is refused, instead of showing an empty list
2591
+
2592
+ This widens two published surfaces: `@object-ui/plugin-detail` gains the export `isRefusedFeedRead`, and `@object-ui/react`'s `DiscussionContextValue` gains the optional members `activityDenied` and `commentsDenied` (as do `RecordActivityTimelineProps` and `RecordChatterPanelProps`).
2593
+
2594
+ When the server refused a record's activity read (401, 403, or a permission
2595
+ envelope), the record page's discussion panel and the `record:activity` block
2596
+ rendered the empty state: "No comments yet" or "No activity recorded". A member
2597
+ who was not allowed to see the activity could not tell that apart from a record
2598
+ with no history. Every failed feed read ended in a catch that treated it as an
2599
+ empty answer.
2600
+
2601
+ Each feed read now keeps its verdict. A refused read shows a localized
2602
+ no-permission state in place of the empty state: a lock and "You don't have
2603
+ permission to view activity on this record." When the other read did answer, its
2604
+ rows stay on screen and the notice above them names the half that was withheld.
2605
+ A refused `sys_comment` read shows "You don't have permission to view comments on
2606
+ this record." in the same way.
2607
+
2608
+ - **What counts as refused** is one verdict, `isRefusedFeedRead`, exported from
2609
+ `@object-ui/plugin-detail`. Both surfaces use it. It is the `forbidden` and
2610
+ `unauthorized` kinds of the existing `classifyLoadError` read classifier, and
2611
+ it reads no status of its own.
2612
+ - **Unchanged:** a read that answers with zero rows, a 404 (`sys_activity` on a
2613
+ deployment without the audit plugin), and any other failure such as a 500 keep
2614
+ the empty state. The panel has no error state of its own.
2615
+ - **Not retried:** a refusal is recorded and shown; the read is not re-issued.
2616
+ - `DiscussionContext` (`@object-ui/react`) gains two optional members,
2617
+ `activityDenied` and `commentsDenied`, which the host that owns the fetch
2618
+ sets. `RecordActivityTimeline` and `RecordChatterPanel` take the same two
2619
+ optional props.
2620
+ - Two new `detail.*` strings, `activityAccessDenied` and `commentsAccessDenied`,
2621
+ are translated in all ten locale packs.
2622
+ - 9419df1: fix(app-shell): the save warning has a sentence of its own for a formula field the server ignored
2623
+
2624
+ objectstack `b2805465` gave the write path a fourth strip reason, `computed`: a
2625
+ value the caller sends for a `formula` field is stripped on every write path,
2626
+ because the server computes that field and has nowhere to store it. The save
2627
+ warning now words that reason as what it is — "Calculated by the server from a
2628
+ formula, so the value sent did not take effect: …" — through a new
2629
+ `detail.writeStrippedComputed` key in all ten locale packs, and never as a
2630
+ read-only lock, which would send the user after a permission problem that does
2631
+ not exist.
2632
+
2633
+ The reason table stays exhaustive over the spec's union, and one spelling now
2634
+ compiles against both the published `@objectstack/spec` 17.5.0 (which does not
2635
+ name the arm) and objectstack `main` (which does), which is what the
2636
+ `Spec Main Shape Gate` needs to go green again.
2637
+
2638
+ When the sentence reaches a user: the adapter judges a reported reason against
2639
+ the spec release this bundle is built with, and 17.5.0 does not declare
2640
+ `computed`, so until that release carries it a formula strip from a newer server
2641
+ is still reported as "Not applied by the server: …" — truthful, but without the
2642
+ cause. The new sentence is used from the spec release that declares the arm on.
2643
+ - 55d18c6: fix(i18n): every count plural family carries every plural form its language uses (objectui#11432)
2644
+
2645
+ The 14 i18next count plural families (`console.ai.usage.resetsWeeklyHours`,
2646
+ `detail.fileCount`, `perm.facet.objects` and the rest) held `_one`, sometimes
2647
+ `_other`, and a base key. i18next asks `Intl.PluralRules` for the one suffix a number
2648
+ needs, and where a pack lacked that slot the base key answered with its single
2649
+ string. So `ru` read "Сброс через 3 часов" on the AI usage indicator where Russian
2650
+ needs "часа", and `ar` served 0, 2, 3-10 and 11-99 from one "singular(plural)"
2651
+ marker.
2652
+
2653
+ Every pack now spells out every CLDR category its language selects: `_other` where
2654
+ the base stood in for it, `ru` `_few`/`_many`, `ar` `_zero`/`_two`/`_few`/`_many`,
2655
+ and `fr`/`es`/`pt` `_many` (exact millions, "1000000 de fichiers"). Existing slots
2656
+ whose form was wrong for their own category are corrected: `ar` `_other` (100-102…)
2657
+ now holds the singular, `ru` `_other` (fractions) the genitive singular, and the four
2658
+ `ru` `perm.facet.*` `_one` forms agree with the numeral like the new slots. English
2659
+ and the `de`/`zh`/`ja`/`ko` strings a user sees do not change.
2660
+
2661
+ `all-locales-key-parity.test.ts` now computes each family's required slots from
2662
+ `Intl.PluralRules` per pack, so a new family or a new pack that misses a category
2663
+ fails at PR time, and the parity rule admits a key `en` lacks only when it is such a
2664
+ slot.
2665
+ - c4ab6d0: The ten locale packs drop `appDesigner.fieldDesigner.validationRules` ("Validation Rules") and `appDesigner.fieldDesigner.addRule` ("Add Rule") (objectui#11434).
2666
+
2667
+ Both labelled an editor for `DesignerFieldDefinition.validationRules` that was never built: no component asked for either key, and `@object-ui/types` retires that member on both faces in this release. A host that looked either key up itself now gets the key back instead of a label; author its own label if it needs one.
2668
+ - 6158e4c: objectui now resolves `@objectstack/*` 17.6.0 (objectui#11438). One declared range moves: `@object-ui/types` raises its `@objectstack/spec` floor to `^17.6.0` in objectui#11227's own changeset, because its published types read `EmptyState` / `EmptyStateSchema`, which 17.5.0 does not export. Every other `@objectstack/*` range already admits 17.6.0 and stays as it was, and `check:spec-floors` names no other floor that has to rise. Everything below is a contract move 17.6.0 made, and it already reaches any consumer that resolves `@objectstack/spec ^17.6`.
2669
+
2670
+ - `@object-ui/components`: the `page:header` registration no longer publishes a `breadcrumb` input. 17.6.0 retires the key (objectstack#20758) and refuses it by name; the renderer has ignored it since objectui#11166.
2671
+ - `@object-ui/i18n`: a served translation document is recognised as a spec payload when it carries only the `picklists` group, which 17.6.0's `GetTranslationsResponseSchema` adds. Such a bundle used to be returned untransformed, so nothing read it. The group is now namespaced under `app` like every other group.
2672
+ - `@object-ui/console`: the bundle inlines the 17.6.0 packages, so its client-side validation answers as a 17.6.0 server does. The first screen is smaller: 17.6.0's spec root no longer carries the migration chain, so the eager closure is 250,096 gzipped bytes lighter (measured against a `main` build), and the bundle budget comes down by that amount.
2673
+ - 4a1adb7: Count labels read the right noun form in every language at every count: each label a word must agree with is now an i18next count family in all ten packs (objectui#11445).
2674
+
2675
+ **What was wrong.** objectui#11432 gave every count family every CLDR category its language selects. Two other spellings of the same defect sat outside that rule. Fifteen labels were code-selected pairs (`detail.attachmentCount` / `detail.attachmentCountPlural`, `common.itemCount` / `common.itemCountOne`, and thirteen more): the component chose between two keys on `=== 1`, which gives a language two forms, so Russian read `3 вложений` and Arabic `2 مرفقات`. Fifty-three labels were one string at every count, so Russian read `Согласовать 3 запросов?`, Arabic `منذ 5 دقيقة`, Spanish `1 seleccionados` and English `3 row modified`.
2676
+
2677
+ **What changes.** Each of those 68 keys is a family: `en` `_one` / `_other`, `ru` `_one` / `_few` / `_many` / `_other`, `ar` all six categories, and each other pack the categories its language selects. The base key keeps its value and answers a call made without a count.
2678
+
2679
+ **Removed keys.** The fifteen code-selected siblings leave all ten packs: `lookup.recordCountOne`, `common.itemCountOne`, `list.recordCountOne`, `detail.reactionCountOne`, `detail.attachmentCountPlural`, `detail.replyCountPlural`, `console.objectView.recordCountOne`, `search.resultsCountPlural`, `search.itemsAvailableOne`, `ai.formAssist.suggestionCountOne`, `ai.formAssist.appliedCountOne`, `collaboration.commentCountOne`, `collaboration.reactionCountOne`, `collaboration.presentUserCountOne` and `collaboration.moreUserCountOne`. A host that looked one up itself now gets the key back instead of a label: call the family key with `{ count }` instead (`t('detail.replyCount', { count: n })`), and i18next picks the form.
2680
+
2681
+ **Provider-less fallback.** `createSafeTranslation` now reads a count family's `_one` / `_other` row for a numeric `count` before the base row, in the order i18next reads the `en` pack, so a host with no `I18nProvider` keeps rendering "1 reply" / "3 replies". A string `count` still reads the base row, as it does in i18next.
2682
+
2683
+ **Supersedes, in this release.** The notes for objectui#9664, objectui#10024, objectui#10242, objectui#10425 and objectui#10636 describe the `…One` / `…Plural` switch these families replace; each now carries a dated note saying so. Their `ru` / `ar` count labels (`Записей: N`, `عدد السجلات: N`) stay only on the base key, which answers a call made without a count.
2684
+
2685
+ **Pinned.** `count-families-11445.test.ts` fails on any `…Plural` key in a pack and on any `{{count}}` value outside a family unless its count-neutral list names the key with the reason no word agrees with the number.
2686
+ - d0097af: The console's Create View dialog now creates chart views the platform accepts (objectui#11576).
2687
+
2688
+ The dialog wrote a chart view as `chart: { chartType, xAxisField, yAxisFields }`, the inline
2689
+ axes `@objectstack/spec` retired under ADR-0021. The spec's list chart block refuses those two
2690
+ keys by name and requires `dataset` and `values`, so the platform's view write door refused
2691
+ every chart view created from the console, from both "Save as view" and the view tab bar's
2692
+ add button. The dialog had already closed, so the user saw nothing happen.
2693
+
2694
+ The chart type now binds a semantic-layer dataset instead:
2695
+
2696
+ - the dialog offers the ADR-0021 datasets whose base `object` is the view's own object, and
2697
+ only those;
2698
+ - it then offers the chosen dataset's measures (`values`) and, when the dataset declares any,
2699
+ its dimensions (`dimensions`, optional);
2700
+ - an object that exposes no dataset shows the chart type as unavailable, with its own reason.
2701
+
2702
+ The payload is `chart: { chartType, dataset, values, dimensions? }`, with no second spelling.
2703
+ The dataset catalog reader the dashboard and report editors use now also carries each dataset's
2704
+ base `object`.
2705
+
2706
+ In `@object-ui/i18n`, the four `console.objectView` axis labels (`xAxisField`, `xAxisFieldHelp`,
2707
+ `yAxisField`, `yAxisFieldHelp`) leave all ten packs, and eight keys label the dataset, measure
2708
+ and dimension picks (`dataset`, `datasetHelp`, `chartMeasure`, `chartMeasureHelp`,
2709
+ `chartDimension`, `chartDimensionHelp`, `viewTypeUnavailableDataset`, `noDatasetMeasure`).
2710
+ - 432882b: The marketplace consent panel no longer promises confinement the runtime does not provide.
2711
+
2712
+ `PluginDisclosure` introduced a code-bearing package's structured permission set with "On install, this package will be granted:". A list of grants on a security panel is read as a confinement promise — the complement assumed denied — and on this platform it is not. The consented set is persisted (`sys_package_installation.granted_permissions`), re-confirmed on a widening upgrade, and registered on the runtime's `PluginPermissionEnforcer` at load; it is queried by nothing, because `SecurePluginContext` has zero production construction sites and the fs/network gates have no caller at all (measured on objectstack `9bd4344e4`; recorded upstream in objectstack `aaacf1d5c`).
2713
+
2714
+ `marketplace.disclosure.grantsIntro` now states a REQUEST — "This package requests:" — and a new `marketplace.disclosure.notEnforced` line beside the list says the set is recorded at install, re-confirmed if a later version asks for more, and not yet a runtime restriction. Both land in all ten locale packs; the `ja` value stays predicate-final so that pack's halfwidth-colon rule still decides it.
2715
+
2716
+ The trust-tier badge is deliberately untouched: it is the trust-tier half of the same panel, which objectstack `a9ee98992` settled separately.
2717
+ - b06e374: The Spanish pack renders `Done` as `Listo` at every one of the four sites that say it
2718
+ (objectui#3880, triage adjudication 2026-08-09). `grid.bulk.done` — the footer button that
2719
+ dismisses the bulk-action result dialog — read `Hecho` while `common.done`, `view.done` and
2720
+ `form.fullscreen.done` all read `Listo`, so the same English word rendered two ways in
2721
+ Spanish across dialogs a user meets in one session.
2722
+
2723
+ Adjudicated a typo rather than a deliberate contextual split, on three checks. All four
2724
+ keys hold the byte-identical `en` value `Done`, and all four call sites are the same
2725
+ control: a dialog-footer button whose click finishes or dismisses the surface
2726
+ (`BulkActionDialog` `onClose(result)`, `ManageViewsDialog` `onOpenChange(false)`,
2727
+ `fullscreen-editor` `commitFullscreen`, `InviteMemberDialog`'s invitation-created footer).
2728
+ The nine other packs each render all four identically (de `Fertig`, fr `Terminé`, pt
2729
+ `Concluído`, ru `Готово`, ja `完了`, ko `완료`, zh `完成`, ar `تم`), so no other translation
2730
+ pass had found a context worth splitting on. And the neighbouring `Hecho`/`Deshecho`
2731
+ pairing that could have justified it does not hold: `grid.bulk.undo` is the verb `Deshacer`,
2732
+ and `Deshecho: ` is `undonePrefix`, a result-line status rather than a button.
2733
+
2734
+ `Hecho` moved to the 3:1 majority `Listo`, which is the value objectui#3546 slice seven had
2735
+ already chosen for `common.done`. `packages/i18n/src/__tests__/residue-namespaces-3546.test.tsx`
2736
+ pinned the old outlier as a recorded example of deliberate divergence; that pin now asserts
2737
+ the four as one value instead, and its note keeps the history plus the `Pending`/zh row,
2738
+ which remains a genuine deliberate split.
2739
+
2740
+ No `en` value changes, so no other pack is asked to follow. This is the value half of
2741
+ objectui#3880 only — the card's 281/164 shared-string census stays on the card as
2742
+ documentation, and is explicitly not a gate: 164 of those groups diverge legitimately.
2743
+ - 978507b: **Behaviour change:** an action whose own declared `visible` gate hides it is no
2744
+ longer run by `autoTrigger`, and the refusal is reported instead of swallowed
2745
+ (objectui#4191).
2746
+
2747
+ Before, `action:button` registered its auto-trigger effect above its `visible`
2748
+ early return, so an action carrying both `autoTrigger: true` and a `visible`
2749
+ that evaluated false rendered nothing and executed anyway; `action:menu` did the
2750
+ same for overflow actions, deliberately, to stay in step. The declared nav deep
2751
+ link made that reachable from a URL: `ObjectView` armed the requested action
2752
+ after checking only its placement, so any list-page link naming an action its
2753
+ author had hidden there ran it.
2754
+
2755
+ The author's verdict now outranks the transport flag:
2756
+
2757
+ - `action:button` and `action:menu` refuse the auto-trigger of an action their
2758
+ own `visible` gate hides. Both go through the one shared hook, so which
2759
+ renderer received the action (decided by `action:bar`'s `maxVisible`, and so by
2760
+ the viewport) cannot change the outcome. The refusal shows a warning toast
2761
+ naming the action, from the new `actions.notAvailableHere` key (all ten locale
2762
+ packs), and writes a console diagnostic in development builds. An action whose
2763
+ predicate becomes true later in the same mount still runs, once.
2764
+ - The deep-link preparation step on the object list evaluates the same
2765
+ predicate before it marks the action `autoTrigger`. A hidden action is not
2766
+ armed and the deep-link parameter stays in the URL, so the one-shot intent is
2767
+ not spent on nothing, and the same notice is shown.
2768
+
2769
+ This was never an authorization boundary: confirm dialogs, parameter
2770
+ collection, entitlement checks and server-side permissions apply on every
2771
+ execute path, before and after this change. What the gate now protects is the
2772
+ author's rule about where an action may be offered.
2773
+ - 594704f: The `console.objectView.*` config-panel vocabulary is retired — 116 keys removed from each
2774
+ of the ten packs, 1160 translated strings that nothing read (objectui#4730, maintainer
2775
+ ruling 2026-08-19).
2776
+
2777
+ The namespace held 209 keys per pack. 116 of them labelled a view-configuration settings
2778
+ panel that does not exist: appearance and density toggles, accessibility attributes,
2779
+ conditional-formatting rules, row-action and inline-edit switches, quick-filter builders,
2780
+ an advanced-settings tier. `packages/app-shell/src/views/ViewConfigPanel.tsx` — the panel
2781
+ they were written for — was migrated off the legacy `buildViewConfigSchema` engine onto
2782
+ `ViewVariantInspector`, a spec-driven inspector whose field labels come from
2783
+ `@objectstack/spec` metadata rather than from this namespace. The panel was replaced; the
2784
+ keys were not cleaned up with it.
2785
+
2786
+ Removed under objectui#4658's three-legged evidence standard, re-measured on this branch's
2787
+ merge base rather than inherited from the card: zero `t()`/`tt()` call sites, zero textual
2788
+ occurrence of the dotted key anywhere in the repo outside the packs that define it, and a
2789
+ consumer spot-check confirming no i18n wiring. The 93 live keys stay — the create-view
2790
+ dialog fields, the view-type catalogue, `new`/`save`/`cancel`, the object-not-found copy,
2791
+ plus the 38 keys whose spelling still appears somewhere the AST pass cannot see, which are
2792
+ out of scope here.
2793
+
2794
+ Four of the retired keys name `ListViewSchema` properties that are still active —
2795
+ `rowActions`, `inlineEdit`, `hiddenFields`, `filterableFields`. They are retired anyway, by
2796
+ the ruling's own words: a live schema property is not a consumer of a locale string; only a
2797
+ labelled UI control is. If such a panel is ever specified, its keys are re-authored
2798
+ alongside it.
2799
+
2800
+ `packages/i18n/src/__tests__/objectView-config-keys-retired-4730.test.ts` pins the removal
2801
+ by name. Every i18n gate in this repo runs call site → key, so none of them can see a dead
2802
+ key come back: the parity gate is fully satisfied by 116 dead keys present in all ten packs,
2803
+ and the reverse sweep that found them (`scripts/check-i18n-dead-keys.mjs`) is report-only by
2804
+ design. The pin is the only thing that would notice.
2805
+ - 41b7ce3: **View configuration is explicitly org-wide, and its write path is now gated (objectstack#7494's
2806
+ ruling, maintainer 2026-08-12).** The `sort` / `hiddenFields` / `columnState` / `rowHeight` that a
2807
+ list toolbar persists were never per-user: they are one shared row on the view, so an ordinary user
2808
+ dragging a column or cycling density was re-styling that view for the entire organization. Nothing
2809
+ in the console said so, and nothing stopped it. A per-user scope stays parked (objectstack#7611,
2810
+ v18) and is deliberately not built here — which is precisely why the write has to be gated rather
2811
+ than narrowed: there is no second, private store for it to fall back to.
2812
+
2813
+ `ObjectStackAdapter.updateViewConfig` now refuses when the session's **reported** ADR-0066 capability
2814
+ set does not contain `manage_metadata`, throwing the new `ViewConfigPermissionDeniedError`
2815
+ (`VIEW_CONFIG_PERMISSION_DENIED`, with `isViewConfigPermissionDeniedError` and the
2816
+ `VIEW_CONFIG_CAPABILITY` constant alongside it). The gate is the **first** statement in the method —
2817
+ before `connect()`, before the payload is assembled — so a refused call puts nothing on the wire.
2818
+ It is on the write rather than on the toolbar button on purpose: withholding the affordance would
2819
+ leave the method still accepting the call from anything else holding the adapter, whereas a gate on
2820
+ the write is inherited by every caller, present and future.
2821
+
2822
+ `manage_metadata` is not a newly minted name. It is the capability this repo already treats as
2823
+ metadata-authoring authority — `HomePage`'s `AUTHORING_CAPABILITY`, the one the server itself
2824
+ refuses metadata writes without — and the gated write goes through `client.meta.saveItem`, the very
2825
+ same ADR-0005 metadata door, so this applies the authority the server is already applying instead of
2826
+ inventing a parallel one.
2827
+
2828
+ **Unknown fails open, by doctrine.** A capability set that was never reported (a backend predating
2829
+ ADR-0066, or no permission provider mounted) is not a denial: the server enforces regardless, so a
2830
+ client-side refusal on missing data cannot protect anything and can only break a permitted user. A
2831
+ *reported* empty grant gates strictly. Hosts push the session's capabilities in with the new
2832
+ `setSystemCapabilities`; `ObjectView` wires it from `usePermissions()`.
2833
+
2834
+ The refusal is also **said out loud**. `ObjectView`'s persist path previously swallowed every failure
2835
+ into `console.error`, which for a debounced toggle whose UI has already moved would have left the
2836
+ operator looking at a density they did not get; a denied write now raises a toast. And the "View
2837
+ settings" popover — where density and field visibility are actually changed — now states the scope
2838
+ before the operator acts: *"Grouping, color, density, and visible fields. Applies to everyone who
2839
+ uses this view."*, translated in all ten packs.
2840
+ - 95f8704: `record:path` now announces each stage's state, not just its label (WCAG 2.2 SC 1.4.1)
2841
+
2842
+ The lifecycle path distinguished travelled, upcoming and lost-terminal stages with
2843
+ colour plus a `✓`/`✗` glyph, and both glyphs are `aria-hidden` decoration.
2844
+ `aria-current="step"` marked the current stage and nothing else, so a screen-reader
2845
+ user heard a run of identically-announced items — and a rejected stage announced
2846
+ exactly like an ordinary stage the record had not reached yet.
2847
+
2848
+ Each stage now carries an accessible name composing its (already picklist-localized)
2849
+ label with its state, from five new `detail.pathStage*` keys translated in all ten
2850
+ locale packs. The glyphs stay decorative and the readout's `role="listitem"` /
2851
+ `aria-current` semantics are unchanged.
2852
+
2853
+ The name is composed into `aria-label` rather than visually-hidden text because
2854
+ `listitem` takes its name from the author only: text placed inside a stage computes
2855
+ to an empty accessible name, so the visually-hidden shape would have looked right in
2856
+ the markup and delivered nothing to the accessibility tree.
2857
+ - 8d3a529: `record:path` finishes localizing and de-colouring its accessible names — the two residues
2858
+ objectui#5916 named and deliberately left behind (objectui#5956, objectui#5957).
2859
+
2860
+ **The list's own label was English on a localized surface, and the other one named nothing.**
2861
+ Both the desktop and the mobile `role="list"` row did
2862
+ `aria-label={schema.aria?.label || 'Record path'}`, so a zh/ja/ar session heard `Record path`
2863
+ for the list while every stage inside it announced in the session locale — one control
2864
+ speaking two languages at once. The fallback is now `detail.pathLabel`, translated in all ten
2865
+ packs; the `schema.aria.label` author override still wins ahead of it.
2866
+
2867
+ The lost-terminal alt group was a different defect wearing the same clothes: its
2868
+ `aria-label="Alternative terminal stages"` sat on a bare `div`, which has the `generic` role,
2869
+ and browsers expose no accessible name on a generic element. That string reached nobody —
2870
+ inert, not merely untranslated — so translating it would have shipped copy to ten packs that
2871
+ no user can hear. It is removed rather than given a role that takes a name, on three
2872
+ measurements: nothing is lost (it was never announced), it would be redundant (every stage
2873
+ inside already announces `closed lost` in the session locale after objectui#5916, in the one
2874
+ place `role="list"` can carry it), and it would fork the two rows (the mobile row renders one
2875
+ flat list with no alt group, so a named group would make one control expose two structures by
2876
+ viewport).
2877
+
2878
+ **An unreached goal terminus was distinguished by hue alone.** `railClass` paints it
2879
+ `bg-emerald-500/30` where a plain upcoming stage gets `bg-muted` — the renderer's own note
2880
+ calls this "a faint emerald so the goal is legible" — while both announced the identical
2881
+ `{{stage}}, upcoming`. Two stages ahead of the record painted differently and read the same:
2882
+ the WCAG 2.2 SC 1.4.1 class objectui#5916 closed, on the one distinction it left behind, and
2883
+ reachable without authors opting in because `classify()` finds `won` through the `WON_TOKENS`
2884
+ heuristic as well as an explicit `terminal: 'won'`. New key `detail.pathStageWonUpcoming`
2885
+ (`{{stage}}, goal stage, not reached`), translated in all ten packs.
2886
+
2887
+ Scoped to the UNREACHED goal, which is a measurement of the stylesheet rather than a
2888
+ preference: a reached goal terminus paints `bg-primary` when current and `bg-emerald-500` when
2889
+ completed, byte-identical to any other current or completed stage. Naming it apart would hand
2890
+ a screen reader a distinction the screen does not make — the mirror image of the defect — so
2891
+ it is one new key, not a pair, and a test pins that decision so it cannot drift into a fourth
2892
+ state unnoticed.
2893
+
2894
+ Both new keys also land in `DETAIL_DEFAULT_TRANSLATIONS`, which
2895
+ `defaults-maps-mirror-en-pack` compares against the `en` pack key by key, so neither can fork
2896
+ between a provider-mounted console and a provider-less embed. No existing `en` value changes,
2897
+ so no pack is asked to follow an edit.
2898
+ - 5ac2e2c: The merged `Loading…` group now reads one way per language (objectui#5972).
2899
+
2900
+ objectui#3878 converged every pack on the typographic ellipsis, and in doing so **merged**
2901
+ the ASCII `Loading...` group into the U+2026 `Loading…` group. Nobody re-measured the
2902
+ wording afterwards. Re-derived on today's tree by flattening all ten packs and comparing
2903
+ exact values, the group is 10 keys — `lookup.loading`, `common.loading`,
2904
+ `fields.recipient.loading`, `grid.import.historyLoading`, `grid.bulk.loading`,
2905
+ `detail.loading`, `report.loading`, `dashboard.loading`, `auth.device.loading`,
2906
+ `approvalsInbox.loadingMore` — and `de` rendered them four ways, `ko` two and `ar` two,
2907
+ while en/zh/ja/fr/es/pt/ru were already unanimous.
2908
+
2909
+ Three packs move, translation copy only — no key is added or removed, no `en` value
2910
+ changes, and every value keeps its U+2026:
2911
+
2912
+ - **de** → `Wird geladen…` on `detail.loading`, `report.loading` (were `Laden…`) and
2913
+ `approvalsInbox.loadingMore` (was `Lädt…`). The passive is both the group majority and
2914
+ the pack's dominant register for in-flight states generally.
2915
+ - **ko** → `로딩 중…` on `fields.recipient.loading`, `grid.bulk.loading`,
2916
+ `grid.import.historyLoading` and `approvalsInbox.loadingMore` (were `불러오는 중…`).
2917
+ Majority, and it matches the pack's own pattern: `불러오는 중` is what `ko` uses when the
2918
+ string names the thing being loaded, the bare form is `로딩 중`.
2919
+ - **ar** → `جارٍ التحميل…` on `common.loading` and `detail.loading` (were `جاري التحميل…`).
2920
+ This one is an orthography normalization rather than a wording choice: `جارٍ` is the
2921
+ indefinite منقوص participle with tanwīn on the rāʾ, `جاري` the yāʾ-retaining form.
2922
+
2923
+ `de` `auth.device.loading` deliberately stays `Lade…`. It is the one member whose outlier
2924
+ spelling is coherent with its own screen: `DeviceAuthPage` renders that namespace's three
2925
+ in-flight states together and `de` writes all three in the same first-person voice
2926
+ (`Genehmige…`, `Ablehne…`, `Lade…`), the other two being outside this group. Converging it
2927
+ alone would manufacture a fresh same-screen inconsistency, so it is reported as a fork and
2928
+ pinned as a named exemption instead.
2929
+
2930
+ `packages/i18n/src/__tests__/ellipsis-glyph-3878.test.ts` gains the per-language
2931
+ uniformity pin beside the glyph rule that created the group. The pin derives the group
2932
+ from `en` and asserts its membership and per-language value counts **before** asserting
2933
+ uniformity, so it cannot pass by matching nothing.
2934
+ - b08b7eb: All ten locale packs now define `home.recentApps.itemType.report` and
2935
+ `home.recentApps.itemType.metadata` (objectui#6023). `RecentItem['type']` is a six-member
2936
+ union and `useTrackRouteAsRecent` writes both of these at runtime — `metadata` on any
2937
+ `/metadata/<type>/<name>` route visit, `report` on any `/report/<name>` visit — but the
2938
+ packs defined only four of the six, so the Recently Accessed and Starred cards labelled
2939
+ those two items from the call sites' inline `defaultValue` instead of from the pack.
2940
+
2941
+ Ten packs missing the same member is full parity, so no pack-vs-pack gate could see it,
2942
+ and the prefix rule only ever asked whether `home.recentApps.itemType` resolved, which it
2943
+ did. What made it quiet rather than loud is the `defaultValue`: English readers saw a
2944
+ plausible `Report` / `Metadata` (lowercase in the rail) rather than a raw key, and the
2945
+ other nine locales saw those English words — objectui#3517's mechanism for hiding a
2946
+ missing key for months.
2947
+
2948
+ The nine translations were taken from each pack's own existing rendering of the same word
2949
+ rather than composed: the singular `Report` that `appDesigner.navReport`,
2950
+ `appDesigner.navTypeReport` and `search.badgeReport` already carry (ar `تقرير`, de
2951
+ `Bericht`, es `Informe`, fr `Rapport`, ja `レポート`, ko `보고서`, pt `Relatório`, ru
2952
+ `Отчёт`, zh `报表`), and the `Metadata` that `layout.metadata.label` already carries (ar
2953
+ `البيانات الوصفية`, de `Metadaten`, es `Metadatos`, fr `Métadonnées`, ja `メタデータ`, ko
2954
+ `메타데이터`, pt `Metadados`, ru `Метаданные`, zh `元数据`). No new vocabulary was invented
2955
+ for any locale.
2956
+
2957
+ The two matching entries in `scripts/i18n-call-site-key-baseline.json`'s `missingMembers`
2958
+ are cleared, since that list is a ratchet: a baselined entry whose defect is gone fails the
2959
+ build too. The union itself is untouched — narrowing `RecentItem['type']` is the other
2960
+ resolution the gate accepts, and it would have been a lie about data both call sites
2961
+ demonstrably write.
2962
+ - 5fa06c4: The zh pack's `console.breadcrumb.reports` renders 报表, the noun the rest of the pack
2963
+ already uses for the report feature (objectui#6166).
2964
+
2965
+ **The authority for this edit is the maintainer ruling of 2026-08-25, not an occurrence
2966
+ count.** The card was filed explicitly as a native-speaker call and explicitly forbade
2967
+ resolving it by normalising to the majority: 报表 (a tabular/data report) and 报告 (a
2968
+ written/narrative report) are not interchangeable, so a pack that spells one key
2969
+ differently from its siblings is evidence of a majority and never, on its own, evidence of
2970
+ a mistake. The maintainer ruled that no deliberate narrative-report distinction was
2971
+ intended here and that the breadcrumb names the same report feature the rest of the pack
2972
+ calls 报表.
2973
+
2974
+ The render context corroborates the ruling and closes the confidence gap triage recorded
2975
+ when it declined to decide this itself. `console.breadcrumb.reports` labels the
2976
+ `routeType === 'report'` **list** route in app-shell's `AppHeader` — a structural sibling
2977
+ of the `dashboards`, `pages` and `system` segments beside it — and drilling through it
2978
+ appends a metadata report definition, the same feature named by
2979
+ `console.commandPalette.reports`, `console.nav.navReport`, `search.typeReports`,
2980
+ `search.badgeReport` and `search.reportNotFound`. Nothing narrative renders beneath it.
2981
+
2982
+ A comment at the key records that this was **ruled** rather than counted, and states the
2983
+ 报表/报告 distinction it was ruled against, so the next reader measuring pack consistency
2984
+ neither re-files it nor quietly restores 报告 after reading the render context and
2985
+ disagreeing. That comment is half of the deliverable; the value change alone would leave
2986
+ the decision unrecorded, which is the failure mode the card was most concerned about.
2987
+
2988
+ **Scope: one key, one pack.** This is not a licence to normalise vocabulary across the ten
2989
+ packs — the card names that hazard explicitly and says it would need its own ruling, and
2990
+ this ruling grants nothing beyond the single key it names. No `en` value changes, so no
2991
+ other pack is asked to follow, and no other pack was touched.
2992
+ - 5d3a2d1: The capability picker localizes `manage_sharing` (objectui#6285). Before this, "Manage
2993
+ Sharing" was the one platform capability in `sys_permission_set`'s picker that rendered in
2994
+ English in every locale, beside seven siblings that translated — a user-visible missing
2995
+ translation, in all ten packs at once.
2996
+
2997
+ The cause was an unchecked copy. `CURATED_CAPABILITY_LABELS` in
2998
+ `CapabilityMultiSelectField.tsx` listed seven capability names under a doc comment claiming
2999
+ it mirrored `@objectstack/spec/security`'s `PLATFORM_CAPABILITIES`; the spec grew an eighth
3000
+ member and the list did not follow, so `manage_sharing` fell through to the English label
3001
+ the `sys_capability` registry serves. Nothing could catch it: the i18n gate reads that list
3002
+ as this key family's vocabulary and checks the members it names — all seven had keys — and
3003
+ no instrument compared the vocabulary to the array it was named after.
3004
+
3005
+ `capability.label.manage_sharing` is now authored in all ten packs and in the field widgets'
3006
+ provider-less defaults map, the list carries the member, and the prose claim is replaced by
3007
+ a check: `CapabilityMultiSelectField.specParity-6285.test.tsx` imports `PLATFORM_CAPABILITIES`
3008
+ and fails on any difference in either direction, reading the declaration through the i18n
3009
+ gate's own source reader so what it pins is exactly what that gate consumes. `labelFor` also
3010
+ gains a `defaultValue`, so a capability that arrives in a future spec bump before its
3011
+ translation is authored degrades to the registry's English label rather than rendering a raw
3012
+ i18n key at the user.
3013
+ - 00c665e: `appDesigner.fieldDesigner.formula` is retired — one row removed from each of the ten
3014
+ locale packs plus the designer defaults map, 11 lines, zero readers (objectui#6310).
3015
+
3016
+ objectui#6043 retired the Field Designer's formula-expression textarea, which was the
3017
+ key's only call site (`FieldDesigner.tsx`, the `{ name: 'formula', label:
3018
+ t('appDesigner.fieldDesigner.formula') }` field descriptor). The value outlived it in
3019
+ eleven places: `DESIGNER_DEFAULT_TRANSLATIONS` in
3020
+ `packages/plugin-designer/src/hooks/useDesignerTranslation.ts`, and the `appDesigner >
3021
+ fieldDesigner > formula` leaf of `packages/i18n/src/locales/{en,de,es,fr,pt,ru,ja,ko,zh,ar}.ts`.
3022
+
3023
+ Removed under objectui#4658's evidence standard, re-measured on this branch rather than
3024
+ inherited from the card: zero `t()`/`tt()` call sites, no dynamic template head that could
3025
+ reach it (`appDesigner.fieldDesigner.typeCategory.` is the namespace's only one), and its
3026
+ sole textual occurrence anywhere in the repo was the defaults-map row this change removes
3027
+ with it — so the key goes from NEEDS-REVIEW to no footprint at all.
3028
+
3029
+ The map and all ten packs move in one commit, which is what keeps
3030
+ `defaults-maps-mirror-en-pack` green: that gate fails a map row whose key the `en` pack
3031
+ lacks, and `all-locales-key-parity` fails a pack left behind.
3032
+
3033
+ Not touched: `designer.field.formula` (`'Formula (CEL)'`) in
3034
+ `packages/app-shell/src/views/metadata-admin/i18n.ts`, a different and live key belonging
3035
+ to metadata-admin's `ObjectFieldInspector` — the surface that still authors formula
3036
+ expressions.
3037
+
3038
+ `packages/i18n/src/__tests__/appDesigner-fieldDesigner-formula-retired-6310.test.ts` pins the
3039
+ removal by name, following the four prior retirements (objectui#4145, objectui#4392,
3040
+ objectui#4730, objectui#5504). Every i18n gate here runs call site → key, so none of them can
3041
+ see a dead key come BACK into the packs: the reverse sweep that found this one is report-only
3042
+ by design, `all-locales-key-parity` is fully satisfied by ten packs agreeing on a dead key, and
3043
+ `check:i18n-drift` only fires when a value changes. Reverse-verified rather than asserted —
3044
+ reviving the row in all ten packs turns exactly that one case red, naming each pack, while the
3045
+ parity gate and the defaults-map mirror stay green.
3046
+ - fff9645: `ar` spells the منقوص participle one way — `جارٍ`, pack-wide (objectui#6610).
3047
+
3048
+ The pack wrote the active participle of جرى two ways at once: `جارٍ` on 92
3049
+ values and `جاري` on 8. One word, two spellings, differing by a single code
3050
+ point — U+064D (tanwīn kasr, the yāʾ dropped) against U+064A (the yāʾ kept) —
3051
+ and same-screen visible: `LoadingScreen` renders `console.initializing` as the
3052
+ heading over the three `console.loadingSteps.*` and `console.actions.retrying`
3053
+ on the retry button, four values that all said `جاري`, inside a console that had
3054
+ already said `جارٍ` on `dashboard.loading`, `list.loading`,
3055
+ `detail.loadingAttachments` and `console.ai.*`.
3056
+
3057
+ The 92:8 majority is not why the 8 moved. An اسم منقوص declines three ways, so
3058
+ "the 8 are right in their own context" was a live reading and was falsified
3059
+ rather than out-voted: all eight opened their string as a fronted indefinite
3060
+ predicate over a delayed subject — nominative, indefinite, not annexed — and
3061
+ five had a word-for-word twin already spelled `جارٍ`, `grid.refreshing`
3062
+ (`جاري التحديث…`) against `list.refreshing` (`جارٍ التحديث…`) being the exact
3063
+ minimal pair. Meanwhile the three values where the yāʾ is grammatically required
3064
+ (accusative `جاريًا` after لا يزال / ما زال, definite `الجارية`) are correct and
3065
+ are left alone.
3066
+
3067
+ Eight values converged; no key added or removed and no `en` value touched.
3068
+ `ar-participle-orthography-6610.test.ts` makes it a pack-wide invariant rather
3069
+ than an eight-key edit — a ninth value arriving with the yāʾ now fails by key
3070
+ name — and pins the three legitimate yāʾ occurrences so a later sweep cannot
3071
+ flatten a correct distinction into a real grammatical error.
3072
+ - 9c3b7ce: The German pack's `auth.device.*` in-flight states move to the passive register
3073
+ (objectui#6611, triage ruling 2026-08-27, option 2): `approving` `Genehmige…` →
3074
+ `Wird genehmigt…`, `denying` `Ablehne…` → `Wird abgelehnt…`, `loading` `Lade…` →
3075
+ `Wird geladen…`.
3076
+
3077
+ Graded a bug, not a taste call: `Ablehne…` was not a grammatical German form.
3078
+ `ablehnen` is a separable-prefix verb, so the first-person singular is *ich lehne
3079
+ ab* — the pack shipped broken German on `DeviceAuthPage.tsx` to users today.
3080
+ Re-derived the ruling's premise against the tree before touching anything: the
3081
+ `de` pack's dominant in-flight register is measurably the passive — 40 of 61
3082
+ `en`-bare-gerund keys render `Wird …`, against 21 that don't (a Unicode-aware,
3083
+ position-aware scan, positive-controlled on `common.loading` → `Wird geladen…`),
3084
+ close to triage's "roughly 37" and the same conclusion either way. `loading` was
3085
+ the sole member of objectui#5972's merged `Loading…` group carved out by name in
3086
+ `LOADING_GROUP_FORKS` (`packages/i18n/src/__tests__/ellipsis-glyph-3878.test.ts`),
3087
+ because converging it alone while `approving`/`denying` stayed first-person would
3088
+ have manufactured a new same-screen inconsistency. Moving the whole namespace to
3089
+ the passive removes that fork rather than maintaining it, so the exemption row is
3090
+ deleted and the pin now asserts zero forks.
3091
+
3092
+ No key added or removed, no `en` value moves, no other locale touched.
3093
+ - dbd5194: The shell's `sys_activity.type` reading stops calling every unrecognised type an
3094
+ update (objectui#6730).
3095
+
3096
+ `mapActivityRows` in `hooks/sharedUserFeeds.ts` — the feed behind the AppHeader
3097
+ bell's Activity tab, Home's activity card and the exported `ActivityFeed` panel
3098
+ — carried the third hand-written reading of that column in this repo, and it
3099
+ bucketed every value outside `created` / `deleted` / `commented` / `mentioned`
3100
+ as `update`. That is not a missing decision; it is a wrong one stated out loud:
3101
+ a `scheduled` meeting, a `login`, a nightly `system` rollup and an author's
3102
+ `contract_countersigned` all rendered as "somebody updated this record".
3103
+
3104
+ - New `layout/activityItemType.ts` holds the whole reading — the table, the
3105
+ generic bucket, the `"NOW()"` timestamp fallback and the row constructor that
3106
+ applies all three — DOM-free, so what a row becomes is assertable directly.
3107
+ - `ActivityItem['type']` gains a fifth kind, `system`: the generic bucket, with
3108
+ its own icon, label and notification toggle. Following
3109
+ `UNMAPPED_ACTIVITY_FEED_TYPE`'s precedent, an unrecognised value renders
3110
+ through it and is named once on `console.warn` rather than being dropped —
3111
+ `sys_activity.type` is author-extensible (objectstack `88b9d749a`, direction 4), so
3112
+ an unmapped value is real activity nobody has ruled on, not a mistake.
3113
+ - The built-ins that had no honest presentation among the four existing kinds —
3114
+ `system`, `completed`, `scheduled`, `login`, `logout` — now land in that
3115
+ bucket instead of claiming `update`. `assigned` and `shared` stay `update`:
3116
+ both write to the record.
3117
+
3118
+ ⛔ The two readings of this column are deliberately NOT converged.
3119
+ `activityRowToFeedItem` builds a `FeedItem`, and the vocabularies cross:
3120
+ `FeedItem` collapses create/update/delete into one `field_change` and drops
3121
+ `commented` / `mentioned` outright, so routing this surface through it would
3122
+ cost the bell every comment row and every create/delete distinction. What is
3123
+ shared is a pin, not an import — the new suite reads plugin-detail's real table
3124
+ (a devDependency; no runtime edge) and fails when the declared vocabulary grows
3125
+ an entry this side has not read, or when the two readings stop disagreeing in
3126
+ the three measured ways.
3127
+ - b392674: Field widgets say WHY they refused an edit in the reader's language
3128
+ (objectui#6755, maintainer ruling 2026-08-29).
3129
+
3130
+ Three sentences a person has to read to recover from a refusal were string
3131
+ literals in the widgets, inside a package whose locale channel 11 of its 55
3132
+ widgets already use: `ObjectField`'s `Invalid JSON`, and `LocationField`'s
3133
+ format and range refusals (objectui#6716 / #6714). So a zh / ja / ar user who
3134
+ mistyped a coordinate or a JSON blob was told why in English, in a form whose
3135
+ labels, gate hints and validation copy were all translated.
3136
+
3137
+ - All three now read from `useFieldTranslation` / `FIELD_DEFAULTS` under
3138
+ `fields.object.invalidJson`, `fields.location.refusedFormat` and
3139
+ `fields.location.refusedRange`, with entries in all ten locale packs — bound
3140
+ from now on by `check:i18n-drift`.
3141
+ - The `en` values are byte-identical to the literals they replace, so English
3142
+ and provider-less rendering are unchanged, and the refusal pins of
3143
+ objectui#6716 / #6715 and `plugin-form`'s two refusal suites are untouched.
3144
+ - `fields.location.refusedRange` keys the FRAME only: the interpolated
3145
+ `{{detail}}` is `LocationValueSchema`'s own complaint, because the widget must
3146
+ not restate the spec's bounds (a hand-copied range is a second contract).
3147
+ - Not in scope, and recorded rather than folded in: `LocationField`'s third
3148
+ refusal sentence — the residue arm objectui#6715 added after the ruling was
3149
+ written — is still a literal. The card behind `320374d2a` carries it.
3150
+ - 4f3a1e2: fix(plugin-timeline): refuse an unusable gantt date range with a diagnostic that names the offending value
3151
+
3152
+ A gantt whose date range cannot be used now renders a `role="alert"` diagnostic
3153
+ naming the value that made it unusable, instead of crashing or drawing a chart
3154
+ that is confidently wrong. Two input classes, which failed in opposite
3155
+ directions:
3156
+
3157
+ - A date that does not parse threw `RangeError: Invalid time value` mid-render —
3158
+ the same crash site objectui#6750 guarded for the empty list, on a different
3159
+ input. This covers a malformed value (`startDate: 'not-a-date'`), an absent
3160
+ one, and an unparseable `minDate` / `maxDate` pinned on the schema.
3161
+ - An inverted pinned range (`minDate` after `maxDate`) drew a bar at
3162
+ `left: 157.9%; width: -4.3%` under a header row with zero cells, with no
3163
+ error and no diagnostic.
3164
+
3165
+ Valid gantts, the empty-list sentinel from objectui#6750 and the degenerate
3166
+ `minDate === maxDate` axis are unchanged.
3167
+ - 320374d: Key `LocationField`'s THIRD refusal sentence — the residue arm — into the locale
3168
+ packs.
3169
+
3170
+ Typing a half that is only PARTLY a number (`12abc, 34`) is refused by
3171
+ `LocationField` with its own sentence, added by objectui#6715. objectui#6755 had
3172
+ ruled two weeks earlier that a widget's own refusal sentence goes through
3173
+ `useFieldTranslation` + `FIELD_DEFAULTS`, and named three sentences — but it was
3174
+ written on 2026-08-29 14:53 and this arm landed after, so it stayed a hard-coded
3175
+ English literal while its two siblings were keyed.
3176
+
3177
+ **The consequence was worse than one more English string.** All three refusal arms
3178
+ render through the SAME `<p>` and the same `refusalError` state, so after #6755
3179
+ landed that one line spoke the reader's language when the format or range arm fired
3180
+ and English when the residue arm did — objectui#4028's shape ("four Chinese labels
3181
+ around one English one") compressed into a single sentence position, which reads to
3182
+ a user as a bug rather than as a missing translation.
3183
+
3184
+ **Arity is answered explicitly, not defaulted.** Unlike the other two sentences, this
3185
+ one had grammatical number: `verb` was `is not a number` / `are not numbers`, chosen
3186
+ in TypeScript. Handing a pack an English verb form through a `{{hole}}` gives it a
3187
+ fragment it cannot inflect around — Arabic has a DUAL, and two halves is exactly that
3188
+ case. So the verb is not a hole. It lives inside two SIBLING keys picked at the call
3189
+ site, `fields.location.refusedResidue` and `fields.location.refusedResidueOne`,
3190
+ following this repo's own plural convention rather than i18next's `_one`/`_other`
3191
+ suffixes — the same shape `RecordPickerDialog` already uses in this very defaults map
3192
+ (`lookup.recordCount` / `lookup.recordCountOne`), and for the reason `ReactionPicker`
3193
+ states in source: zh/ja/ko have no separate singular form, would legitimately omit a
3194
+ `_one` half, and `all-locales-key-parity` reads that as a missing key. The `ar` pack
3195
+ now uses its dual (`ليسا رقمين`), which the old implementation could not have produced.
3196
+
3197
+ The English conjunction `' and '` and the coordinate NOUNS go the same way: each pack
3198
+ writes its own conjunction inside the two-half value, and `latitude` / `longitude`
3199
+ become `fields.location.latitude` / `fields.location.longitude`, keyed once each and
3200
+ interpolated into both arities so no locale holds two spellings of the same word. The
3201
+ only holes carrying untranslated data are `{{text}}` / `{{otherText}}` — the
3202
+ characters the person actually typed.
3203
+
3204
+ **No behaviour moves.** The English values are byte-identical to the literal they
3205
+ replace in both arities, verified by `check:i18n-drift` (0 en values changed, 4 added)
3206
+ and by objectui#6715's own `LocationField.strictNumeric.test.tsx` and `plugin-form`'s
3207
+ `ObjectForm.locationResidue.test.tsx` passing untouched. Provider-less rendering is
3208
+ unchanged, the refusal itself is unchanged, and the four new keys are bound from here
3209
+ on by `check:i18n-keys` and `all-locales-key-parity` like their three siblings.
3210
+ - 639114c: Carry `manage_org_presentation`, the ninth platform capability (objectui#7122).
3211
+
3212
+ `@objectstack/spec` 17.3.0 declares a ninth member of `PLATFORM_CAPABILITIES`
3213
+ and the capability picker's curated set carried eight, so
3214
+ `CapabilityMultiSelectField` fell back to the `sys_capability` registry's
3215
+ English label for it in every locale — the exact defect objectui#6285 filed
3216
+ when `manage_sharing` did the same thing.
3217
+
3218
+ The label is the spec artifact's own (`Manage Organization Presentation`), read
3219
+ off the installed build rather than invented, and it is authored everywhere the
3220
+ widget's docblock requires of any edit to that list: `useFieldTranslation.ts`
3221
+ and all ten locale packs. Each non-English string is composed from that pack's
3222
+ own established sibling vocabulary (`manage_org_users`,
3223
+ `manage_platform_settings`) rather than machine-translated; a native review pass
3224
+ is welcome on the nine, and nothing about the capability's behaviour depends on
3225
+ the wording.
3226
+
3227
+ The parity pin is unchanged and still fails on ANY difference in either
3228
+ direction, which is what made this visible before it reached a screen.
3229
+ - 1490691: `dashboard.noRows` and `dashboard.noDataAvailable` are retired — two rows removed from
3230
+ each of the ten locale packs, 20 entries, zero readers (objectui#7125).
3231
+
3232
+ objectui#7063 routed the three dashboard empty-state renders (`DatasetWidget`,
3233
+ `ObjectDataTable`, `PivotTable`) through one shared `WidgetEmptyState`, which resolves its
3234
+ own copy from the `dashboard.empty.*` family. The two keys the old per-widget placeholders
3235
+ used outlived their call sites in `packages/i18n/src/locales/{en,de,es,fr,pt,ru,ja,ko,zh,ar}.ts`.
3236
+
3237
+ Removed under objectui#4658's evidence standard, re-measured on this branch rather than
3238
+ inherited from the card: zero `t()`/`tt()` call sites for either fully qualified key, no
3239
+ dynamic `dashboard.` head a substitution could resolve onto them, and every surviving
3240
+ textual occurrence in `packages/` is a comment recording the consolidation. `pnpm
3241
+ check:i18n-keys` stays green across the deletion with the `en` pack at 2,962 keys (2,964
3242
+ before) and every in-scope call-site key still resolving.
3243
+
3244
+ Not touched: `table.noRows` (`'No rows to display'`) and `engine.form.noRows`
3245
+ (`packages/app-shell/src/views/metadata-admin/i18n.ts`, read at `widgets.tsx`) — two
3246
+ different, same-named keys in different namespaces. Nor the comments in
3247
+ `WidgetEmptyState.tsx`, `DatasetWidget.tsx`, `ObjectDataTable.tsx` and `PivotTable.tsx`
3248
+ that record WHY three widgets with three strings became one shared empty state; the packs'
3249
+ own comment keeps that rationale and now names the retirement instead of a row that is
3250
+ gone.
3251
+
3252
+ `packages/i18n/src/__tests__/dashboard-emptyState-keys-retired-7125.test.ts` pins the removal
3253
+ by name, following the five prior retirements (objectui#4145, objectui#4392, objectui#4730,
3254
+ objectui#5504, objectui#6310). Every i18n gate here runs call site → key, so none of them can
3255
+ see a dead key come BACK into the packs: the reverse sweep that found these is report-only by
3256
+ design, `all-locales-key-parity` is fully satisfied by ten packs agreeing on a dead key, and
3257
+ `check:i18n-drift` reported the deletion as `2 removed — those are all-locales-key-parity's`.
3258
+ - d8ec8d6: `ActivityTimeline` speaks the session locale — the other 18 literals
3259
+ (objectui#7149).
3260
+
3261
+ objectui#7142 gave this component its first `t()` call (the empty-state title)
3262
+ and filed the sweep that found the rest. Until now a zh activity tab read
3263
+ `"Activity(0)暂无活动记录"`: one translated string in a component that was
3264
+ otherwise entirely English.
3265
+
3266
+ All 18 now resolve from the ten packs, in three groups:
3267
+
3268
+ - **Relative timestamps and the card title** (`just now`, `{{count}}m/h/d ago`,
3269
+ `Activity`) — these render on *every* activity tab. All five were a pure
3270
+ lookup swap: the `en` pack value was already byte-identical to the literal,
3271
+ and the sibling `RecordActivityTimeline` already used the same keys.
3272
+ - **The `formatFieldChange` sentences** — assembled in code, so they needed new
3273
+ keys *with* interpolation holes rather than a lookup. Same reachability as the
3274
+ timestamps: they render for any entry whose optional `description` is absent.
3275
+ The quotes live inside each pack's value, so every locale punctuates its own
3276
+ way (de `„…“`, zh `“…”`, ja `「…」`, fr/ru `«…»`).
3277
+ - **The six filter chips and the chip group's accessible name** — reachable only
3278
+ through the published export, since no host in this repo passes `filterable`.
3279
+
3280
+ Ten new `detail.*` keys across all ten packs (no inline `defaultValue` —
3281
+ objectui#3517), mirrored byte-for-byte into `DETAIL_DEFAULT_TRANSLATIONS` so a
3282
+ provider-less host still reads English rather than a raw key.
3283
+
3284
+ One deliberate English copy change: the chip group's `aria-label` was
3285
+ `"Activity type filter"` and now resolves `detail.filterActivity`
3286
+ (`"Filter activity"`) — the key `RecordActivityTimeline` already uses for the
3287
+ accessible name of its own activity filter, so one control does not carry two
3288
+ names across two components.
3289
+
3290
+ Also drops the unused `Filter` import (a pre-existing eslint warning).
3291
+ - 866cd1d: `RecordComments` and `PointInTimeRestore` resolve their copy from the locale
3292
+ packs instead of hardcoded English (objectui#7163).
3293
+
3294
+ Both files carried their own `formatTimestamp` whose relative-time branches
3295
+ returned English literals, so a zh/ja/ar session read `5m ago` next to
3296
+ otherwise translated chrome — the defect objectui#7142/#7149 fixed one file
3297
+ over in `ActivityTimeline`.
3298
+
3299
+ - **`RecordComments`** was already wired to the packs (11 `t('detail.…')`
3300
+ references), so this is a pure lookup swap onto `detail.justNow` /
3301
+ `minutesAgo` / `hoursAgo` / `daysAgo` — keys already present in all ten packs,
3302
+ each `en` value byte-identical to the literal it replaces. **No new key, no
3303
+ copy change.**
3304
+ - **`PointInTimeRestore`** used no translation hook at all, so it is swept
3305
+ WHOLE rather than having only its timestamps converted: card title, empty
3306
+ state, field-count line, preview panel, snapshot heading, restore
3307
+ confirmation and all three buttons. Wiring one string into an otherwise
3308
+ untranslated component is what shipped objectui#7142's visibly half-done zh
3309
+ card; that is not repeated here.
3310
+
3311
+ Ten new `detail.*` keys land in all ten packs and in
3312
+ `DETAIL_DEFAULT_TRANSLATIONS`: `revisionHistory`, `noRevisions`,
3313
+ `revisionFieldsChanged`, `revisionFieldsChangedOne`, `revisionPreview`,
3314
+ `revisionSnapshot`, `restoreConfirm`, `restoring`, `confirmRestore`,
3315
+ `restoreToPoint`. `detail.cancel`, `detail.activityEmptyValue` and
3316
+ `detail.emptyValue` are **reused**, not forked.
3317
+
3318
+ The restore confirmation becomes one key with a `{{when}}` hole rather than a
3319
+ sentence assembled around a JSX expression, and the field-count line uses the
3320
+ repo's two-key plural convention selected by a static ternary over two literal
3321
+ keys — never `t(KEYS[n])`, which objectui#7149 measured as invisible to the
3322
+ i18n scanners.
3323
+
3324
+ One deliberate copy change: the snapshot panel's null placeholder was an EN
3325
+ DASH (`–`, U+2013) written inline and now resolves `detail.emptyValue`, which
3326
+ is an EM DASH (`—`, U+2014) in all ten packs — the glyph the rest of the detail
3327
+ package already uses for an empty value.
3328
+ - 20c04b2: A gantt timeline whose rows are malformed now refuses to draw, naming the row,
3329
+ instead of crashing the render (objectui#7164, maintainer ruling A+).
3330
+
3331
+ `TimelineRenderer`'s gantt branch used to read the authored rows twice — once
3332
+ defensively in `findUnusableGanttDate`, once bare in `calculateDateRange` — and
3333
+ every input in the gap threw a `TypeError` mid-render from ordinary JSON:
3334
+ `items: [null]`, a row whose `items` is `5` / `true` / `{}` / an array-like
3335
+ object, or `items` itself not an array. The three readers (the date scan, the
3336
+ range computation and the render loop) now consume ONE verdict from
3337
+ `classifyGanttRows`, and a malformed shape renders the existing `role="alert"`
3338
+ refusal through a new diagnostic key,
3339
+ `timeline.gantt.unusableRange.malformedRow` — "items[0] is null, which is not
3340
+ a row shape" — never the `malformedDate` copy, which named the wrong fault.
3341
+ The key lands in `en` and the nine sibling locale packs.
3342
+
3343
+ `@object-ui/types` (minor — the accept set narrows): `TimelineSchema.items` no
3344
+ longer declares `z.array(z.any())`. Every element must be an object, and a
3345
+ gantt row's own `items`, when present, must be an array, so `validate` refuses
3346
+ `items: [null]` and `items: [{ items: 5 }]` at authoring time — before they
3347
+ reach a renderer. Feed items (`vertical` / `horizontal`) carry no `items` key
3348
+ and parse exactly as before; every in-repo `type: 'timeline'` fixture parses
3349
+ green on both sides of the change. Rows with no bars (`items: []`, a row
3350
+ without `items`) stay the ordinary empty state and still draw.
3351
+ - 01c9023: `AiPendingActionsInbox` speaks the session locale — every string in it, not only its timestamps (objectui#7173).
3352
+
3353
+ The AI HITL approval inbox held its own relative-time helper returning hardcoded
3354
+ English (`'just now'`, `` `${min}m ago` ``), so a zh / ja / ar session read English
3355
+ relative times on every row. It is the fifth spelling of that helper in the repo,
3356
+ and the file had **no translation wiring at all** — the unwired-component shape,
3357
+ not the lookup-swap shape.
3358
+
3359
+ It is therefore swept whole. objectui#7142 wired one string into an otherwise
3360
+ untranslated component and shipped something visibly half-done, and objectui#7149
3361
+ is what finishing that afterwards cost; the triage ruling on this card (2026-09-01)
3362
+ carried that forward as *sweep the file whole or leave it*. Everything the user can
3363
+ read now resolves from the locale packs: the card heading and description, the three
3364
+ tabs, the refresh button, all five status badges, the six column headings, the empty
3365
+ state, the row and drawer buttons, all nine drawer field labels, the outcome banner
3366
+ and the whole reject-reason dialog.
3367
+
3368
+ **No new rows for the four relative-time branches.** `detail.justNow`,
3369
+ `detail.minutesAgo`, `detail.hoursAgo` and `detail.daysAgo` already existed,
3370
+ translated, in all ten packs, and cross-package key borrowing is this repo's settled
3371
+ convention rather than an open question — `ObjectGrid`, `ObjectKanban`, `ObjectTree`,
3372
+ `ListView`, `ObjectView`, `NavigationOverlay`, `RecordAttachmentsPanel`,
3373
+ `RecordDetailView` and `apps/console` all resolve `detail.*` from outside
3374
+ `plugin-detail`. One phrase on one kind of control should not get a second
3375
+ translation that can drift from the first.
3376
+
3377
+ The rest of the sweep needed copy no pack had, so `@object-ui/i18n` gains an
3378
+ `aiApprovals` namespace: 38 keys, translated in all ten packs. It is deliberately
3379
+ separate from `approvalsInbox`, which is the human approval-**process** inbox — a
3380
+ different surface and a different feature, so no rows are shared with it. Four
3381
+ generic verbs are reused rather than forked (`common.refresh`, `common.cancel`,
3382
+ `common.loading`, `common.ok`).
3383
+
3384
+ **⛔ The five relative-time helpers are not unified.** They differ in real behaviour
3385
+ — `Math.round` here against `Math.floor` in `plugin-detail`, thresholds 45s/30d
3386
+ against 60s/7d, different tails — so normalising them is a behaviour change wearing
3387
+ a refactor's clothes and needs its own card. This inbox's arithmetic is untouched,
3388
+ and three rows in the new suite exist only to pin it: 50s renders `1m ago` (a 60s
3389
+ threshold would still say "just now"), 90s renders `2m ago` (`Math.floor` gives
3390
+ `1m ago`), and 20d renders `20d ago` (a 7d threshold would already show a date).
3391
+
3392
+ Two assembled English sentences became single interpolated keys — the outcome banner
3393
+ (`Approve for {{id}}: {{message}}`) and the drawer subtitle
3394
+ (`Tool {{tool}} on {{object}}`). Their word order differs per locale, which fragments
3395
+ around a `<code>` element cannot express, so the two identifiers lose their monospace
3396
+ styling. That is the deliberate cost of making those sentences translatable.
3397
+
3398
+ Evidence: an `en`-only assertion cannot discriminate here, because each key's `en`
3399
+ value is byte-identical to the literal it replaced. The suite asserts in **zh and
3400
+ ar**, and the provider-less path separately, in its own file (`createI18n` installs
3401
+ itself as react-i18next's module-level global, so a provider-less render in a file
3402
+ that has already mounted a provider silently reads that pack instead of the defaults
3403
+ map). No inline `defaultValue` anywhere (objectui#3517).
3404
+
3405
+ Two consequences of the sweep, both landed here rather than left for CI to find:
3406
+
3407
+ `packages/app-shell/src/console/ai/__tests__/ConversationsSidebar.test.tsx` froze its
3408
+ `vi.mock('@object-ui/i18n', ...)` factory to a hand-written object. Its import graph
3409
+ reaches `plugin-chatbot`, which now resolves `createSafeTranslation` at module scope, so
3410
+ the frozen surface made that read `undefined` and the file died during COLLECTION — the
3411
+ objectui#6849 shape, which does not look like a test failure. It now spreads
3412
+ `importOriginal()` and overrides only `useObjectTranslation`. Measured, not guessed: of
3413
+ the 41 frozen `@object-ui/i18n` factories in the repo, running every one of them showed
3414
+ this to be the only file whose graph reaches the package.
3415
+
3416
+ The ten pack blocks are locale DATA, and locale data lands in the console's eager
3417
+ `framework` chunk, so `scripts/check-eager-closure-budget.mjs` raises that chunk's
3418
+ ceiling from 512,000 to 524,000 gzipped bytes and re-pins its baseline onto a fresh
3419
+ measurement (502,405 to 514,863). Attributed by three console builds: the merge parent
3420
+ reads 510,192, this branch with the ten `aiApprovals` blocks cut reads 510,192 again, and
3421
+ this branch reads 514,863 — so the whole 4,671-byte delta is the pack data and nothing
3422
+ else. Headroom is kept at the line's own convention (9,137 bytes, 0.10x the regression
3423
+ the gate must catch) rather than widened; most of the overage was pre-existing drift, with
3424
+ the merge parent already at 510,192 of the 512,000 allowed.
3425
+ - e8c553b: A scatter handed more than one series now refuses instead of drawing a false picture.
3426
+
3427
+ Scatter binds one measure: `series[0].dataKey` is the y axis, and every series was
3428
+ handed the same rows through that one axis. A second series therefore added a
3429
+ colour and a legend entry and nothing else — measured, two series over two rows
3430
+ painted four symbols at two positions, each drawn twice, and the second measure's
3431
+ values appeared nowhere on the plot. The data was valid and the picture was
3432
+ confidently wrong, which no existing refusal could see.
3433
+
3434
+ A `chartType: 'scatter'` with two or more `series` now renders the renderer's
3435
+ refusal shell under `data-chart-error="scatter-multi-series"`, stating that a
3436
+ scatter plots one measure, naming the fix (keep exactly one series) and listing
3437
+ the series keys it was handed. A single-series scatter is unchanged.
3438
+
3439
+ This refusal counts authored `series` only. `compareTo` on scatter is out of
3440
+ its scope: objectui#7402 ruled (b) that scatter joins pie / donut / funnel in
3441
+ excluding `compareTo` — `supportsCompareTo` and the dashboard widget path stop
3442
+ synthesising a comparison series for it, so no `…__comparison` overlay is ever
3443
+ built for a scatter and this guard is never reached by a compare-to document.
3444
+ That exclusion ships as a separate change; until it lands, a `compareTo`
3445
+ document still reaches the renderer as two series and refuses here today.
3446
+
3447
+ No multi-measure projection is built (maintainer ruling, 2026-09-02): nothing
3448
+ in-repo authors a two-series scatter, so that capability waits for a real caller.
3449
+ The refusal copy is `chart.scatterOneMeasure` in all ten locale packs.
3450
+ - 7c3df8f: A non-grid view's fetch now carries a platform row ceiling, and crossing it is
3451
+ never silent (objectui#7210, maintainer ruling a′, 2026-09-02).
3452
+
3453
+ Before this, `ObjectGantt`, `ObjectCalendar`, `ObjectMap` and `ObjectTree` each
3454
+ issued a `find` with **no `$top` at all**, so the request returned the entire
3455
+ filtered result set. At the 186 rows the card was filed from that is invisible;
3456
+ on an object with 100k scheduled rows it is the whole table into the browser,
3457
+ and nothing an author could write — `pagination.pageSize` included — could
3458
+ bound a request that never carried a cap to begin with.
3459
+
3460
+ **What changed.** Those four fetches now ask for `NON_GRID_ROW_CEILING_TOP`
3461
+ rows, draw at most `NON_GRID_ROW_CEILING` of them, and when the result set was
3462
+ larger they render a footnote naming both numbers, verbatim as it renders:
3463
+ *"Showing the first 2000 of 41234 records. Narrow the filter."* Below the
3464
+ ceiling nothing changes: the full set draws and no footnote appears.
3465
+
3466
+ The four view packages take a **minor**, not a patch: a result set above the
3467
+ ceiling is no longer drawn in full, which is a behaviour break whatever the
3468
+ fixed group does to the released version number.
3469
+
3470
+ **The ceiling is a platform constant, not an authorable key** — `2000`, exported
3471
+ from `@object-ui/react` as `NON_GRID_ROW_CEILING`. An authored `limit` or
3472
+ `dataSource: { limit }` still does not reach these queries, by the same ruling;
3473
+ three alternatives were rejected with it (a documentation note only — still the
3474
+ whole table; truncating at `pageSize` — silent, and a complete schedule capped
3475
+ at one page; an authorable `maxRows` — a new permanent key every author sets).
3476
+
3477
+ **Why 2,000.** One constant for all four, so the binding view sets it. Measured
3478
+ in this repo's jsdom lane: gantt, calendar and map hold their DOM flat as rows
3479
+ grow (virtualised task list; four events per day cell; auto-clustering above
3480
+ 100 markers), while `ObjectTree` flattens every expanded node into the document
3481
+ at a linear **5.2 DOM elements per record** with no virtualisation. 2,000 rows
3482
+ is where the worst of the four lands at ~10,400 elements — an order of
3483
+ magnitude above Lighthouse's "excessive DOM size" warning, and still ~10x the
3484
+ real application result set this card came from.
3485
+
3486
+ New exports on `@object-ui/react`: `NON_GRID_ROW_CEILING`,
3487
+ `NON_GRID_ROW_CEILING_TOP`, `applyNonGridRowCeiling`, `NonGridRowCeilingNote`
3488
+ and the type `NonGridCeilingResult`. Two new `common.*` i18n keys carry the
3489
+ footnote copy in all ten packs.
3490
+ - db3896c: `ObjectGantt` refreshes in place when its query really changes, instead of tearing the chart down to the loading placeholder (objectui#7237).
3491
+
3492
+ Once the chart has painted, a change to its sort, filter, permissions, search or bound source re-runs the query with the chart still mounted, so it keeps its scroll position, its collapsed groups and any inline edit in progress. While the query runs, a thin indeterminate bar over the chart marks the rows on screen as the previous answer, and the rows are replaced when the new answer lands. It is the same `RefreshIndicator` that `ObjectGrid`, `ListView` and `ObjectChart` draw over their rows. The initial load still shows the loading placeholder.
3493
+
3494
+ The bar also shows during the chart's other in-place re-reads: the toolbar refresh, the re-read after a drag, a dependency link, a delete or a field edit, and a re-read triggered by the data-invalidation bus. Those already kept the chart mounted, but the only sign of them was a disabled refresh button.
3495
+
3496
+ A changed query that fails is still reported as an error, as it was before. Only a re-read of the same query keeps its last good rows when it fails.
3497
+
3498
+ `@object-ui/i18n`: new key `gantt.aria.refreshing`, the bar's accessible name, in all ten locale packs.
3499
+ - 3399704: Fix `AiUsageIndicator` to recognize the free plan's new `resetKind: 'weekly'` and its
3500
+ `resetsAt` (objectui#7371, consumer of cloud PR #1852's rolling 7-day AI quota window).
3501
+
3502
+ Before this change a `weekly` meter fell through to the component's unrecognized-kind
3503
+ path and rendered no reset line at all — not a crash, but silently wrong information
3504
+ next to a live progress ring. The indicator now shows "Resets in N days" (or "Resets in
3505
+ N hours" once inside the final day, e.g. `console.ai.usage.resetsWeeklyHours`), computed
3506
+ from the endpoint's `resetsAt`, in both languages via `@object-ui/i18n`
3507
+ (`console.ai.usage.resetsWeeklyDays` / `resetsWeeklyHours`, real i18next plural families
3508
+ with a base key so every locale pack resolves correctly, all ten packs translated). D5 is
3509
+ preserved — no token count is ever rendered, only the days/hours until reset.
3510
+
3511
+ Contract-first: `resetsAt` is read verbatim from the endpoint, never re-derived or
3512
+ guessed client-side. A `weekly` meter with `resetsAt: null` (nothing counted yet in the
3513
+ window) and any `resetKind` this build does not recognize both render no reset line —
3514
+ fail-soft, not a crash or stale copy.
3515
+
3516
+ `AiUsageResetKind` (`packages/app-shell/src/hooks/useAiUsage.ts`) gains the `'weekly'`
3517
+ member; `resetsAt` was already `string | null` and needed no shape change.
3518
+ - 2a5bf45: fix(i18n): `createI18n`'s `resources` deep-merges, so overriding one key of a namespace keeps the rest
3519
+
3520
+ A language pack's top-level keys ARE the namespace groups (`common`, `calendar`,
3521
+ `list`, ...), each a nested object, but `createI18n` merged `resources` over the
3522
+ built-in packs one level deep. Supplying a partial group therefore **replaced**
3523
+ it rather than merging into it:
3524
+
3525
+ ```ts
3526
+ createI18n({ defaultLanguage: 'en', resources: { en: { calendar: { today: 'Heute' } } } });
3527
+ ```
3528
+
3529
+ left `calendar.today` set and dropped `month`, `week`, `day`, `allDay`,
3530
+ `newEvent`, `moreEvents` and `unscheduled` from the instance. `t('calendar.allDay')`
3531
+ then returned the bare key, and `calendar.allDay` reached the DOM as literal
3532
+ text — silently, with no error and no warning.
3533
+
3534
+ The merge now recurses, so a partial group override touches only the keys it
3535
+ names. Packs nest up to four levels below the group
3536
+ (`console.ai.empty.build.title`), so it recurses rather than adding one fixed
3537
+ extra level. Two in-repo call sites were affected by this and are repaired by
3538
+ the change: `packages/plugin-gantt/demo` supplied 53 of the 80 `zh.gantt.*` keys
3539
+ and silently lost the other 27 (its own comment says the demo "is never
3540
+ half-translated" — it was), and the `skills/objectui/guides/i18n.md` setup
3541
+ example, the documented way to use this API, dropped 46 of the 48 `common.*`
3542
+ keys in both `en` and `zh`.
3543
+
3544
+ **Arrays are replaced, not concatenated** — stated because it is a decision, not
3545
+ a library default. No built-in pack carries an array value today (every leaf in
3546
+ all ten packs is a string), so nothing observable rides on it; the rule decides
3547
+ what a future array means. An author who writes an array is naming the whole
3548
+ list, so replacement is the only rule that lets them shorten or reorder one and
3549
+ the only one that stays idempotent when the merge runs again. This is
3550
+ deliberately narrower than i18next's own `deepExtend`, which the provider's
3551
+ async `addResourceBundle` path uses: that recurses into arrays index-wise and
3552
+ would leave a longer base array's tail behind, which is the same silent-hybrid
3553
+ shape this fix removes.
3554
+
3555
+ No caller depended on the old replacement semantics: a census of every
3556
+ `resources` literal in the repo (47 parsed sites, plus the dynamic ones resolved
3557
+ by hand) found no site that supplied a partial group in order to clear the rest.
3558
+ - c907a9c: fix(fields): a file or image upload that surfaces no `sys_file` id is refused by name, never submitted as an inline blob
3559
+
3560
+ **BREAKING (`@object-ui/fields`), graded `minor` by this repo's release model** (AGENTS.md: objectui's major follows the `@objectstack` family major, so its own breaking changes ship as `minor` with the breaking semantics stated here, never `major`):
3561
+
3562
+ - **Who breaks.** Two kinds of host. The first calls `fileValueForSubmit` itself. The second mounts an upload adapter whose result carries no id-shaped `meta.fileId`. That includes the object-URL default: `useUpload()` falls back to it when no `UploadProvider` is mounted, and `UploadProvider` uses it when given no `adapter`. It also includes any S3- or Azure-style adapter, `createS3Adapter` and `createAzureBlobAdapter` among them, because neither mints a `sys_file` row. This includes every host on a non-ObjectStack backend (the README's any-backend `DataSource` path) that stored the inline `UploadedFileMetadata` object: no adapter there mints a `sys_file` id.
3563
+ - **What they see now.** `fileValueForSubmit` throws `UploadIncompleteError` (`code` `UPLOAD_INCOMPLETE`, `fileName` naming the pick) where it used to return the inline object, and its return type is `string`. In `FileField`, `FileCell` and `ImageField` the pick is refused with the translated "did not complete" row, and the field is not changed.
3564
+ - **What to do.** Mount an `UploadProvider` whose adapter returns the `sys_file` id in `meta.fileId`. `createObjectStackUploadAdapter`, the ObjectStack presigned flow, does. A custom adapter has to register the file with the platform and return that id. A host that calls `fileValueForSubmit` directly catches `UploadIncompleteError` (or checks `code === 'UPLOAD_INCOMPLETE'`) where it used to accept an object. Such a host returns its own file id in `meta.fileId` (word characters and `-`, 1 to 64 long, as `isFileIdToken` requires) and has its read path return `{ id, name, url }`, because a bare id otherwise renders from ObjectStack's `/api/v1/storage/files/:id`.
3565
+
3566
+ `Clause-②: yes (narrowing)` — `fileValueForSubmit`, a public export of `@object-ui/fields`, narrows its return type to `string` and throws where it used to return an object; `UploadIncompleteError` is a new public export. No field-value accept set widens.
3567
+
3568
+ `fileValueForSubmit` used to fall back to the legacy inline `{ name, original_name, size, mime_type, url }` object when the upload adapter surfaced no `meta.fileId`. That was the file and image widgets' one path to the pre-D3 inline file object: the backend's stored contract has been the bare `sys_file` id since spec `17.0.0`, so the fallback turned a successful-looking upload into a save the engine refused (`invalid_type`) on every deployment that has verified its file-as-reference migration, and into a warned-about legacy value on the rest. ADR-0104's 2026-09-05 addendum fixes the physical column to the bare id as well.
3569
+
3570
+ The fallback is retired. `FileField`, `FileCell` and both upload paths of `ImageField` now submit the id the adapter minted, or refuse the pick with the translated `fields.file.uploadIncomplete` message ("did not complete") in the same error row a transport failure lands in — the field is not changed and no new blob reaches `onChange`. This includes the object-URL default `useUpload()` falls open to when no `UploadProvider` is mounted, and any S3/Azure-style adapter that mints no `sys_file` row. Reading is unchanged: a legacy blob already on a record still renders, and on a `multiple` field it is passed through untouched beside a new id.
3571
+
3572
+ `@object-ui/i18n` gains the `fields.file.uploadIncomplete` key in every locale pack.
3573
+
3574
+ `@object-ui/providers`'s `UploadProvider.tsx` JSDoc for the object-URL default now states it persists nothing and returns no id.
3575
+ - e1c27e4: The README states **ten** built-in locales, derived from the package rather than restated
3576
+ (objectui#7989).
3577
+
3578
+ `README.md` ships in this package's `files`, so the two lines a reader meets first went out
3579
+ in every tarball claiming **11** built-in locales. The package has ten —
3580
+ `BUILT_IN_LANGUAGE_CODES` and the `@object-ui/i18n/locales` map agree on
3581
+ `en zh ja ko de fr es pt ru ar` — and the same document already reasoned on ten six times
3582
+ further down ("the other nine are separate chunks", "all ten codes", "of the ten packs").
3583
+ The "and more" hedge goes with the number: that bullet enumerates all ten languages, so
3584
+ there is no more.
3585
+
3586
+ The correction is the smaller half. A **restated** number is the construct that permitted
3587
+ the error, and correcting one in isolation has already failed to stop the next instance —
3588
+ objectui#3351 fixed the same off-by-one in a changeset file and nobody swept the README.
3589
+ So the count is now read off the document and compared against what the package exports,
3590
+ on two doors that must agree: the payload-free `BUILT_IN_LANGUAGE_CODES` a consumer of the
3591
+ entry gets, and the `builtInLocales` map behind the published `./locales` subpath.
3592
+
3593
+ ⛔ No runtime behaviour changes, and the two sites that were already correct —
3594
+ `src/index.ts`'s `@packageDocumentation` and this manifest's `description` — are untouched.
3595
+ - b1030c7: The record form now tells the user when a `visibleWhen` transition clears a field
3596
+ (objectui#8070, ruling letter A).
3597
+
3598
+ Since `6a449fc49` a field whose own `visibleWhen` / `visibleOn` turns it
3599
+ invisible while it holds a value has that value cleared, so the server does not
3600
+ refuse the row over a column that is no longer on screen. That clear was silent:
3601
+ the field disappeared and nothing said a stored value went with it. The ruling
3602
+ on objectui#8070 ports the naming half of the objectui#6499 ruling to this
3603
+ surface, and the clear itself is unchanged.
3604
+
3605
+ - When a transition clears one or more fields, the form raises one notice that
3606
+ names them by the labels the form draws, joined by the locale's
3607
+ `validation.formInvalidJoiner`, and says they no longer apply given the
3608
+ current values. A field that was already empty is not named (an unchecked
3609
+ two-state control counts as empty), and neither is the first render of a
3610
+ record, which only records the baseline.
3611
+ - The notice is published under the form's outcome-toast id. A later submit
3612
+ refusal replaces it, and the next attempt that passes client validation
3613
+ dismisses it, like the form's other outcome messages.
3614
+ - When a clear hides a second field whose `visibleWhen` reads the field just
3615
+ cleared, the notice names both. Any edit in between starts a new list.
3616
+ - `@object-ui/i18n` adds one key, `form.clearedOnHide`, to all ten packs. The
3617
+ form's built-in fallback table carries its `en` text for a form rendered
3618
+ without an `I18nProvider`.
3619
+ - 6778809: Key the `type="number"` bad-input refusal — the FIFTH sentence of this class,
3620
+ and the only shared one — into the locale packs (objectui#8148).
3621
+
3622
+ objectui#6755 ruled that a widget's OWN refusal sentence goes through
3623
+ `useFieldTranslation` + `FIELD_DEFAULTS`, and `320374d2a` applied that to
3624
+ `LocationField`'s residue arm as the fourth. The four already keyed are each one
3625
+ widget's. This one is not: a single literal in `widgets/numberBadInput.tsx`
3626
+ produced the sentence for `NumberField`, `CurrencyField`, `PercentField` and
3627
+ BOTH of `GeolocationField`'s boxes, through one hook, with five different
3628
+ example values.
3629
+
3630
+ **The measured consequence is on the geolocation surface.**
3631
+ `GeolocationField` sits beside `LocationField`, whose three refusal sentences
3632
+ are all keyed, so on the same form two adjacent coordinate widgets refused bad
3633
+ input in two different languages — a language switch mid-screen, which reads to
3634
+ a user as a bug rather than as a missing translation.
3635
+
3636
+ **The example stays a HOLE, and that is the authoring decision this card had to
3637
+ make.** Five different values reach the one sentence — `1234`, `1234.56`,
3638
+ `12.5`, `30.2741` and `120.1551` — so keying the example per widget would have
3639
+ meant five keys across ten packs, and each pack would then hold a decimal
3640
+ numeral it could legitimately re-punctuate. A pack writing `1234,56` reads as
3641
+ the `latitude, longitude` PAIR the adjacent widget's `fields.location.refusedFormat`
3642
+ asks for. With `{{example}}` filled by the widget in ASCII, no pack spells a
3643
+ digit at all, and all five values still reach the rendered sentence in every
3644
+ language. That property is pinned by name.
3645
+
3646
+ **No behaviour moves.** The English value is byte-identical to the literal it
3647
+ replaces, so English and provider-less rendering are unchanged — verified
3648
+ through the rendered widget for all five boxes, under an `en` provider and with
3649
+ no provider at all. objectui#6780's own announce suite keeps every verdict it
3650
+ had; it now spells its expected English sentence locally, because the helper
3651
+ that composes it takes the widget's `t` and reads
3652
+ `fields.number.badInput` rather than building an English string on its own. The
3653
+ guard still announces without refusing, and the new key is bound from here on by
3654
+ `check:i18n-keys`, `check:i18n-drift` and `all-locales-key-parity` like its four
3655
+ siblings.
3656
+ - 6e9a3d4: AI 搭建入口的空状态不再承诺 v1 拒绝的东西(objectui#8329)。
3657
+
3658
+ `console.ai.empty.build.description` 和 `console.ai.empty.editApp.description`
3659
+ 原本告诉用户「描述一个应用或流程 —— 我会起草对象、界面和自动化」。cloud 的 v1
3660
+ 创作边界(ADR-0112)不含流程、动作、定时,模型会明确拒绝这类请求,所以这是入口
3661
+ 处对用户做的一个会被打回的承诺。它是这个家族里最后一处 —— 起始 chip 的措辞
3662
+ (cloud#1984)和模型收尾的「后续你可以」(cloud#2022)已经修过。
3663
+
3664
+ ⚠️ **十个语言包每一个都有自己翻译好的违规版本**,全部一并修正;组件里两处硬编码
3665
+ `defaultValue` 兜底也同步更新,否则任一语言包丢 key 时旧承诺会静默回归。
3666
+
3667
+ 未改动的是**手工**路径的文案(`home.build.subtitle`、Studio 落地页、导入时运行
3668
+ 已有自动化、打包自动化运维、marketplace 分类)—— Studio 确实能建流程,那些承诺
3669
+ 是真的。判据是「v1 的 AI 创作边界」,不是「automation 这个词」。
3670
+
3671
+ 回滚:ADR-0112 v2 加回流程/动作时,这两句与 chip 文案同一行回滚;测试里的
3672
+ `RETIRED` 映射存了十个语言包的原句。
3673
+ - 309728c: A `user` reference that resolved to nothing is no longer rendered as a person
3674
+ (objectui#8434, routed from cloud#2074).
3675
+
3676
+ `UserCellRenderer`'s first branch printed any primitive as plain truncated text
3677
+ under the comment *"Primitive value: just display the ID/username as text"*. On a
3678
+ `user` field the premise of that comment is wrong: `user` is a lookup specialised
3679
+ to `sys_user`, so a primitive arriving there means precisely that nothing turned
3680
+ the reference into a person. Printing it as displayable text rendered
3681
+ **"resolution failed" as "resolution succeeded"** — and, measured, the cell was
3682
+ **byte-identical** to what a `text` cell prints for the same string. The only
3683
+ difference from a resolved person was the *absence* of the avatar, which is a
3684
+ subtractive signal; a user who has never seen the avatar has no reason to read
3685
+ absence as failure.
3686
+
3687
+ Such a cell now keeps the raw value **visible** and adds a stated affordance
3688
+ beside it: a muted marker glyph plus a sentence ("Unresolved reference: … was
3689
+ not resolved to a user", keyed as `detail.unresolvedReference` in all ten locale
3690
+ packs). The multi-value shape gets the same treatment, so a `user` field is not
3691
+ honest on one input shape and silent on the other.
3692
+
3693
+ **The sentence is deliberately epistemic, not ontological.** This branch has two
3694
+ populations and the renderer cannot tell them apart — it has no resolver at all,
3695
+ unlike `LookupCellRenderer`: an unexpanded `sys_user` id is the *legitimate*
3696
+ stored form (`packages/core/src/utils/expand-fields.ts`: "a `user` column that is
3697
+ NOT requested for expansion comes back as a raw user id", objectui#2032), and a
3698
+ name written into the column is dirty data. A "not found" claim would be false
3699
+ for the first, so the affordance states only what is true of both: this screen
3700
+ did not resolve it.
3701
+
3702
+ **Nothing else moves.** An expanded reference still renders avatar + name; a
3703
+ reference object carrying only an id still draws its avatar; `{}` still prints
3704
+ the coerced text (objectui#8596's boundary); the save-side `reference_not_found`
3705
+ refusal and the edit form are untouched — this change is display-only.
3706
+ - aa08d7e: Route the `repeater` and `file` cell COUNT labels through i18n (objectui#8441).
3707
+
3708
+ The `repeater` cell in the standard renderer table counted its rows with a hardcoded
3709
+ Chinese unit word. Two rules broke on that one literal: AGENTS.md #-1 (all user-facing
3710
+ text MUST be English) and, the half a translation to English would not have fixed, it
3711
+ bypassed i18n entirely — every reader on every locale read it, English ones included,
3712
+ beside the English siblings `[Vector]`, `[Grid]` and `FileCellRenderer` on the same
3713
+ detail page. It now reads `detail.repeaterItemCount` through `useFieldTranslate`, the
3714
+ channel this file already imported and four of its other cells already use.
3715
+
3716
+ `FileCellRenderer` moves with it, to `detail.fileCount`. Its `count === 1 ? 'file' :
3717
+ 'files'` was not a rule violation — it is English — but it was equally unlocalized and
3718
+ plural-safe for English only: `ru` has four plural categories and `ar` six, and a
3719
+ two-branch ternary can spell neither. Two adjacent cells answering one concept two ways
3720
+ is what the single channel closes. **English output is byte-identical**: `2 files`
3721
+ stays `2 files`, and the provider-less fallback is pinned byte-equal to the `en` pack.
3722
+
3723
+ Both keys are REAL i18next plural families — base + `_one` + `_other` in all ten packs,
3724
+ not the two-sibling-key `xxxCountOne` shape. The base key is load-bearing (objectui#3863):
3725
+ i18next asks `Intl.PluralRules` for the one suffix a number needs and, finding no slot,
3726
+ walks `fallbackLng` to `en`, so without it a Russian reader gets English at counts 2-20.
3727
+
3728
+ The `repeater` entry also stops being an inline arrow in `getCellRenderer`'s table and
3729
+ becomes a module-level component. That table is rebuilt on every call and both call
3730
+ sites resolve inside render, so an inline entry is a fresh component type per render:
3731
+ React would remount the cell and tear down react-i18next's language subscription with
3732
+ it. No export was added — the published surface is unchanged.
3733
+ - 7c9145f: Fix the ChatDock AI usage indicator going blank against the single-pool AI usage
3734
+ endpoint (objectui#8524).
3735
+
3736
+ The cloud `GET /api/v1/ai/usage` endpoint now answers `{ pool, breakdown }`: one quota
3737
+ pool, plus `breakdown`, a read-only split of that same pool into app-building (`build`)
3738
+ and data Q&A (`dataChat`). `useAiUsage` only read the retired per-meter
3739
+ `{ meters: { build, dataChat } }` answer, so it returned `null` and `AiUsageIndicator`
3740
+ rendered nothing, with no error.
3741
+
3742
+ - `useAiUsage` reads `{ pool, breakdown? }` and only that shape. A missing `breakdown`,
3743
+ or one whose members are `null`, is a normal reading. Any other answer — the retired
3744
+ `{ meters }` one included — leaves `usage` `null` and now also sets `error`.
3745
+ - The hook's `AiUsageResponse` type is now `{ pool, breakdown? }`, with the new
3746
+ `AiUsageBreakdown` type for the split; `AiMeterUsage` keeps its fields and describes
3747
+ the pool.
3748
+ - `AiUsageIndicator` draws one ring from `pool`. When a `breakdown` member is a number,
3749
+ the popover lists it under the pool's row as a percentage of the pool, as text and
3750
+ never as a second ring. The upgrade / top-up button's test id is now `ai-usage-cta`
3751
+ (it was `ai-usage-cta-build` / `ai-usage-cta-dataChat`).
3752
+ - New locale key `console.ai.usage.breakdownTitle` ("Used so far") in every locale pack.
3753
+ - 676f677: Approvals inbox: a reference that cannot be resolved now renders a neutral
3754
+ "cannot be opened" affordance instead of degrading to the opaque record id
3755
+ (objectui#8631).
3756
+
3757
+ `1267508ea` tombstoned the class the platform FLAGS — `status: 'cancelled'`
3758
+ plus `cancel_reason: 'record_deleted'`. A terminal (`approved` / `rejected`)
3759
+ approval carries no such flag and never will: the upstream cancel path names
3760
+ `status: 'pending'` in its `where`, deliberately, so history is preserved. Those
3761
+ rows kept falling through to `formatIdentity(record_id)` and showing a truncated,
3762
+ meaningless identifier where a business identifier belongs.
3763
+
3764
+ The desktop row, the mobile card and the request drawer now all render the
3765
+ affordance for a reference that the viewer's own readability probe could not
3766
+ resolve AND that carries no snapshot title — the two conditions under which the
3767
+ opaque id was the only thing left on screen. A row whose snapshot kept a business
3768
+ identifier is unchanged: it still shows that identifier with its link suppressed,
3769
+ which is what objectui#5211 ruled. A row the server marked `record_deleted` still
3770
+ gets the tombstone `1267508ea` added, which says something stronger because the server
3771
+ asserted it.
3772
+
3773
+ **The copy names no cause, and that is the point.** The platform's read path
3774
+ fuses "this id names nothing" with "your grants filter it out" on purpose
3775
+ (existence non-disclosure), so nothing on the wire separates them and the console
3776
+ may not invent the separation: telling a viewer a record was deleted would
3777
+ confirm to them that it existed. The affordance therefore states only what is
3778
+ true either way — there is nothing here to open — and is pinned by a test
3779
+ asserting the rendered text carries neither a deletion word nor a permissions
3780
+ word.
3781
+
3782
+ This sentence is console-authored, through this repo's own catalogue in all ten
3783
+ locales, because it has no upstream original: `APPROVAL_CANCEL_REASON_LABELS`
3784
+ declares exactly one option (`record_deleted`) and there is no cause code for
3785
+ "unresolvable" — nor may one be requested, per the card's fence. That is why it
3786
+ does not contradict the neighbouring rule against authoring a second copy of the
3787
+ platform's tombstone sentence.
3788
+
3789
+ Also removed: the full, untruncated `record_id` that the desktop row hung in a
3790
+ `title` tooltip on **every** row, readable ones included. The reference slot now
3791
+ tooltips the identifier it displays.
3792
+ - b253c4e: The import wizard's "Download template" button now downloads the server's import template, an Excel workbook, instead of building a CSV of every field (objectui#9600).
3793
+
3794
+ The old CSV listed every field the wizard was handed, including the system and read-only columns every object carries (created by, owner, and so on). The server strips those columns on import without an error, so a user who filled them in lost that data silently. The server's template (`GET /api/v1/data/:object/export?template=true`, objectstack 17.6.0) lists only the columns this caller can import. It marks required ones with `*`, adds dropdowns for select and boolean columns, and includes an instructions sheet.
3795
+
3796
+ - **`@object-ui/types`: new optional `DataSource.downloadImportTemplate(resource)`**, resolving to the template file as a `Blob`. Additive. When a data source lacks it, no template is offered: there is no client-side fallback.
3797
+ - **`@object-ui/data-objectstack`: `ObjectStackAdapter.downloadImportTemplate(resource)`** sends `template=true` on the export route, on the same request path as `exportDownload`. When the client has a locale set (`getClient().setLocale`), the request carries it as `Accept-Language`, the way the import request does. The template's labels are written in the request's locale, and the import accepts a translated label only in its own request's locale, so the two requests must match.
3798
+ - **`@object-ui/plugin-grid`: `ImportWizard` offers the template** when its data source has `downloadImportTemplate` and the user can create records of the object (`usePermissions().can(object, 'create')`, the server's own gate). A 405 (object not open for import), a 403 (no create permission) and any other failure each show a message on the upload step, and no file is saved. The client-built CSV is deleted, with its helpers (`buildImportTemplateCsv`, `exampleForField`, `firstOptionValue`, `downloadTextFile`). The column mapping step is unchanged.
3799
+ - **`@object-ui/app-shell`**: the identity-import data source (`sys_user`) withholds `downloadImportTemplate`, because the server's template describes the generic import door, not the identity pipeline.
3800
+ - **`@object-ui/i18n`**: `grid.import.downloadTemplateHint` describes the Excel template in all ten packs, and `grid.import.templateDownloadFailed` and `grid.import.templateNotPermitted` are new.
3801
+ - 44152c4: Full-page search now agrees with its own number while browsing: at exactly one
3802
+ searchable item the header reads `1 item available`, not `1 items available`
3803
+ (objectui#9664).
3804
+
3805
+ The header is one ternary with two branches. The query branch already chose its
3806
+ key on `totalCount === 1` — `search.resultsCount` against
3807
+ `search.resultsCountPlural`. The browse branch beside it asked for
3808
+ `search.itemsAvailable` at every count, and that key had neither a plural family
3809
+ nor a sibling to fall to, so `en` shipped the disagreement in the default
3810
+ language; `de`, `es`, `fr` and `pt` read the same way (`1 Elemente verfügbar`,
3811
+ `1 elementos disponibles`, `1 éléments disponibles`, `1 itens disponíveis`).
3812
+
3813
+ `search.itemsAvailableOne` is the singular half, added to all ten packs, and the
3814
+ browse branch now picks between the two on `allItems.length === 1`. That is this
3815
+ repo's two-key plural convention — the one `common.itemCount`/`itemCountOne` and
3816
+ `detail.reactionCount`/`reactionCountOne` already use — and deliberately not an
3817
+ i18next `_one`/`_other` family: full key parity across ten packs caps a family at
3818
+ base plus `_one` plus `_other`, so every CLDR category a pack does not spell out
3819
+ falls through to the base key, which on this key is the plural. Russian meets
3820
+ that at 2 to 4 and Arabic at 2, 3 to 10 and 11 to 99. Choosing the key in the
3821
+ component keeps `Intl.PluralRules` and `fallbackLng` out of the path: both halves
3822
+ exist in every pack, so no count in any language can reach English.
3823
+
3824
+ `zh`, `ja` and `ko` carry the same string in both halves because the counter word
3825
+ holds the number and there is no separate singular form; `ru` does too, because
3826
+ its phrasing states no noun to agree with.
3827
+
3828
+ One translation changed rather than being added: `ar` wrote both numbers into one
3829
+ string as a parenthesised marker. Its key can no longer be reached at one item, so
3830
+ the singular half of that marker was dead weight while the parentheses still
3831
+ rendered at every count the key does serve. It now reads as a singular noun at one
3832
+ item and as a count label at every other count, the shape `ar`'s
3833
+ `common.itemCount`/`itemCountOne` uses (objectui#10425).
3834
+
3835
+ ⚠️ **Dated note, 2026-10-02 — superseded in this release — objectui#11445.**
3836
+ Later in this same release the `search.itemsAvailable` / `search.itemsAvailableOne`
3837
+ switch gave way to a `search.itemsAvailable` count family in all ten packs: the
3838
+ browse branch calls the family key with `{ count }`, each pack spells every CLDR
3839
+ category its language selects (so the base-key fall-through described above no
3840
+ longer arises), and `search.itemsAvailableOne` left every pack. The rest of this entry is kept as the reading of this change.
3841
+ - a78cd37: Dates and numbers across the console and the plugins format in the session's
3842
+ display locale instead of the machine's (objectui#9909).
3843
+
3844
+ Every one of these faces handed `Intl` — directly, or through a formatter called
3845
+ without one — either no locale tag or an explicit `undefined`, which is not "the
3846
+ user's locale": it is the locale of the machine the browser runs on, which is
3847
+ neither of this repository's two locale channels.
3848
+ A German or Spanish session therefore read, beside a translated label, a date or
3849
+ an amount grouped and decimal-marked the machine's way — and an amount with
3850
+ inverted separators does not read as unformatted, it reads as a different
3851
+ number. They now go through `useDisplayLocale()` (tenant regional default, then
3852
+ the active UI language, then `'en'`), and a plain helper takes that tag from its
3853
+ caller. The surfaces:
3854
+
3855
+ - **`@object-ui/fields`** — `GridField`'s numeric and currency cells (list mode
3856
+ and computed columns) and both total cells. The same cell helper's date branch
3857
+ already used the display locale, so a single grid row read two conventions at
3858
+ once.
3859
+ - **`@object-ui/plugin-charts`** — ISO-date x-axis ticks, compact y-axis ticks,
3860
+ the single-value face, spec `format` strings and the tooltip value.
3861
+ - **`@object-ui/app-shell`** — the organization invitations, members and
3862
+ accept-invitation dates; marketplace version dates; the AI conversation row's
3863
+ older-than-a-week date and its tooltip; the build-debug token count; the
3864
+ record approvals timeline; and the metadata-admin audit, history, external
3865
+ datasource snapshot, schema-browser row estimate, flow-run start and job
3866
+ next-fire faces. The audit, history and flow-run panels format these in the
3867
+ DISPLAY locale, not in the `locale` prop they take for their UI strings
3868
+ (`AuditPanel` defaults that prop to `'en-US'`; `HistoryPanel` and
3869
+ `FlowRunsPanel` leave it optional).
3870
+ - **`@object-ui/console`** — the approvals inbox's timestamp tooltips, amounts
3871
+ and payload summary; the audit log's timestamp column; the flow runs table and
3872
+ its run detail.
3873
+ - **`@object-ui/components`** — the export dialog's record counts and the debug
3874
+ panel's event times.
3875
+ - **`@object-ui/plugin-form`** — the analytics submission count, the
3876
+ master-detail subtotal / tax / total stack and the edit-conflict dialog's
3877
+ "their save" time.
3878
+ - **`@object-ui/plugin-chatbot`** — the approvals inbox's past-30-days date and
3879
+ the times stamped on local-mode chat messages (the user's and the
3880
+ auto-response).
3881
+ - **`@object-ui/plugin-dashboard`** — the record-count badge, and the currency
3882
+ and date-format branches of `renderFieldValue`, which was handed the display
3883
+ locale and spent it only on its percent branch.
3884
+ - **`@object-ui/plugin-grid`** — the record-detail panel's inferred currency
3885
+ value and the mobile card's amount line, siblings of date cells already on the
3886
+ display locale.
3887
+ - **`@object-ui/plugin-designer`** — `VersionHistory`'s version times.
3888
+
3889
+ A host that mounts one of the components that newly read the display locale with
3890
+ NO `I18nProvider` now gets react-i18next's once-per-module `NO_I18NEXT_INSTANCE`
3891
+ notice in the console on the first such mount; the faces still render, in the
3892
+ channel's `'en'` last resort, and mounting an `I18nProvider` (as the console
3893
+ does) avoids the notice.
3894
+
3895
+ **Additive API** (nothing that compiled before stops compiling):
3896
+
3897
+ - `@object-ui/types`: `ValidationContext` gains an optional `locale`. At this
3898
+ change, the `@object-ui/core` validation engine prints the `date_min` /
3899
+ `date_max` bound in it; omitted, `Intl` follows the runtime default, as
3900
+ `formatDisplayNumber` already declares for a caller with no locale in hand.
3901
+
3902
+ ⚠️ **Dated note, 2026-09-25 — that engine (`ValidationEngine`) is removed from
3903
+ `@object-ui/core` by a later change; `ValidationContext.locale` stays declared in
3904
+ `@object-ui/types`, and at that change nothing in `@object-ui/core` reads it —
3905
+ objectui#7659.**
3906
+
3907
+ ⚠️ **Dated note, 2026-09-27 — `ValidationContext` itself is removed from
3908
+ `@object-ui/types` by a later change, with the rest of the Phase 3.5
3909
+ validation types it belonged to, so the `locale` member this entry adds no
3910
+ longer exists — objectui#10719.**
3911
+ - `@object-ui/plugin-report`: `exportReport`, `exportAsHTML` and `exportAsPDF`
3912
+ take a trailing optional `locale` for the exported file's "Generated:" time,
3913
+ and `LiveExportOptions` gains an optional `locale` that `exportWithLiveData`
3914
+ forwards. Omitted, the time uses the display channel's own last resort
3915
+ (`'en'`), never the machine's locale. `ReportViewer` passes the session's
3916
+ display locale. The locale is deliberately NOT a `ReportExportConfig` member:
3917
+ that type is authored report metadata, and a display locale belongs to the
3918
+ session.
3919
+
3920
+ **One census, repository-wide.** `plugin-detail`'s machine-locale census pin
3921
+ (objectui#9786) is now a single test, `machineLocaleCensus-9909.test.ts` in
3922
+ `@object-ui/i18n`, covering every workspace package whose manifest depends on
3923
+ `@object-ui/i18n`. It refuses any call site that passes nothing, `undefined`,
3924
+ the `'default'` pseudo-tag (a subtag no locale data answers, so `Intl` resolves
3925
+ it to the machine's locale) or a hard-coded tag, unless the site is declared
3926
+ with its reason — for example the `catch` fallback for a tag `Intl` itself
3927
+ rejected, or an ISO formatter feeding `<input type="date">`. Each declared site
3928
+ is counted exactly, so deleting one without its entry is refused too. The
3929
+ per-package pin it replaces is deleted. The runtime tripwire that observes the
3930
+ argument each locale-taking call (`Intl` constructors, `Date` and `Number`
3931
+ `toLocale*`) actually receives moved into the private `@object-ui/test-support`
3932
+ package, and every surface above is pinned with it: the same data under two
3933
+ declared locales must render differently, NO locale-taking call may receive the
3934
+ machine's locale (nothing, `undefined` or `'default'`), and the surface's own
3935
+ calls must receive the declared tag. A call carrying another declared tag, such
3936
+ as the `'en'` of the session's UI language, is tolerated by design: only the
3937
+ machine's locale is the defect.
3938
+ - bc5870c: fix(plugin-calendar): a record with no date is no longer placed on today
3939
+
3940
+ `ObjectCalendar` mapped a record whose declared `startDateField` carried no
3941
+ value to `new Date()` — the current moment — so it rendered on today's cell as
3942
+ an ordinary event, indistinguishable from a real one. The `isNaN` guard six
3943
+ lines below could not catch it by construction: a no-argument `new Date()` is
3944
+ always valid, so the absent-value case became a well-formed lie *before* the
3945
+ check that would have caught it.
3946
+
3947
+ The fabricating arm is deleted. Such records now leave the grid entirely and
3948
+ appear in a collapsed "Unscheduled (N)" area below the calendar — a visible
3949
+ count and an expandable list, with no invented date and no scheduling UI. The
3950
+ `isNaN` filter keeps its original job for values that are present but
3951
+ unparseable: absent and malformed stay two distinguishable outcomes.
3952
+ `allDay: !endDate` now applies only to records that have a start, so a record
3953
+ with no dates at all is unscheduled rather than silently all-day; a record with
3954
+ a start and no end still renders all-day exactly as before.
3955
+
3956
+ Adds `calendar.unscheduled` to all ten locale packs.
3957
+ - f760064: Localize every accessible name `CalendarView` authors itself.
3958
+
3959
+ The component hard-coded the accessible names of its own landmarks and controls
3960
+ in English, so no locale pack could reach them. A screen-reader user on a
3961
+ translated console heard "Calendar" and "Calendar grid" — the only names those
3962
+ two landmarks have — plus the toolbar buttons and both resize grips, in English,
3963
+ while every sighted string beside them was translated.
3964
+
3965
+ All of them now resolve through the `createSafeTranslation` hook the component
3966
+ already used for its visible copy, and the keys land in all ten locale packs
3967
+ under `calendar.a11y`. Two of the repaired strings were English frames wrapped
3968
+ around already-localized data (the current-date button and the day gridcell's
3969
+ event count); those are now pack-owned sentences whose translations can put the
3970
+ date and the count where each language's grammar wants them, and the gridcell's
3971
+ count is a plural family with a base key so the categories a pack does not
3972
+ enumerate resolve in its own language rather than falling through to English.
3973
+ - 8ea3bee: fix(plugin-chatbot): localize the build-progress panel, which was an English island (objectui#7388)
3974
+
3975
+ Every label the build panel is HANDED was already localized — the host passes
3976
+ `openBuiltAppLabel`, `designBuiltAppLabel`, `previewDraftLabel` and the three
3977
+ connection cues through its own `t()`. Every string the panel OWNED was a
3978
+ literal in the component, so a fully Chinese conversation watched its app get
3979
+ built under `Building your app…`, over `Objects` / `Views` / `Dashboards` /
3980
+ `App` / `Sample data` row headings — one per row, on every build — and a
3981
+ `+N more` overflow counter.
3982
+
3983
+ All of them now resolve through the console's pack as `chatbot.build.*`, added
3984
+ to all ten locales. Behaviour for a known phase is unchanged in English, and
3985
+ the unknown-artifact-type fallback still renders the raw type rather than a
3986
+ raw i18n key.
3987
+ - 3a58149: A cloud-connection bind failure now reads in the user's language whichever clock
3988
+ noticed it (objectui#5054).
3989
+
3990
+ One abandoned device approval could be noticed by either of two clocks, and the
3991
+ Cloud Connection panel had a different answer for each. When the panel's own
3992
+ `expires_in` deadline fired first it rendered `cloudConnection.errors.expired` —
3993
+ translated in all ten packs. When the SERVER noticed first, `/bind/poll` answered
3994
+ HTTP 400 with `message: 'Device authorization failed: expired_token'`; `getJson`
3995
+ threw a bare `Error` carrying only that sentence, and the catch rendered it
3996
+ verbatim. Same user, same failure, two languages, decided by which clock got
3997
+ there first — visible on a zh console as the same abandoned approval reading
3998
+ Chinese or English depending on whether the tab sat open past `expires_in`.
3999
+
4000
+ `getJson` now carries the envelope's `declaredCode` and `code` across its throw,
4001
+ and a single closed map turns the two RFC 8628 outcomes a user can actually cause
4002
+ into console copy: `expired_token` → the existing `cloudConnection.errors.expired`,
4003
+ `access_denied` → a new `cloudConnection.errors.accessDenied` added to all ten
4004
+ locale packs. `declaredCode` is read first, because ADR-0112 keeps the upstream
4005
+ spelling there — `code` is `DEVICE_CODE_FAILED` for both.
4006
+
4007
+ Every other code is unchanged: `invalid_grant`, and anything upstream invents
4008
+ next, still render the wire `message`, which stays the single source of truth for
4009
+ failures this console has no copy for. No API, export or resolver was widened.
4010
+ - 6ce89da: The 确认修改 (confirm changes) card now carries a UI-owned terminal state after approval (#5695): `detectReplayOutcome` lifts the confirm-replay envelope (`replay_*` tool results) into 应用中 / 已生效 / 已暂存为草稿(含内联发布)/ 未生效(含 publishError 首行), rendered on the original card across the live, hydration/share, and localStorage-cache converters. A failed in-turn publish no longer rehydrates as an ordinary draft card with a live Publish button — the UI-rendered refusal is the layer a model cannot narrate over. New `console.ai.changesApplying/Applied/Drafted/Failed` keys in all ten locale packs.
4011
+ - de570cc: fix(i18n): `useObjectTranslation`'s provider-less `t` now interpolates its inline `defaultValue`
4012
+
4013
+ With no `I18nProvider` mounted, react-i18next hands back its not-ready `t`, which
4014
+ returns `options.defaultValue` **verbatim** — so an inline default written
4015
+ `'Deleted {{count}} rows'` reached the user with the braces intact. 68 inline
4016
+ defaults across 24 files rendered through that path on any host that embeds an
4017
+ ObjectUI component without a provider, which is the configuration
4018
+ `createSafeTranslation` exists for.
4019
+
4020
+ `useObjectTranslation` now runs its not-ready result through the same one
4021
+ interpolator `createSafeTranslation`'s `fallbackT` uses, so both provider-less
4022
+ renderers fill exactly the `{{name}}` spelling the copy is already gated to. The
4023
+ ready path is untouched: with a provider, i18next's own `t` is returned by
4024
+ reference and nothing is interpolated twice. Pre-interpolated template-literal
4025
+ defaults (`` `Deleted ${n} rows` ``) stay correct — they have no holes left to
4026
+ fill — so no call site changes.
4027
+ - 1e66879: `console.ai.pendingDrafts` — the standing unpublished-changes bar's five strings —
4028
+ now exists in all ten locale packs. It previously existed only in `en` and `zh`, so
4029
+ `ar`, `ru`, `pt`, `es`, `fr`, `de`, `ko` and `ja` rendered the English defaults and
4030
+ `all-locales-key-parity` failed on `main` (objectui#5705).
4031
+
4032
+ The feature landed `en`-only in objectui#5696; the follow-up in objectui#5697 was
4033
+ titled for the locale packs but reached only `zh`, so eight packs × five keys stayed
4034
+ missing and the parity assertion — which carries no allowlist — was red on `main` and
4035
+ on every PR whose diff touched source. Source-free diffs skip the shard that runs it,
4036
+ which is why the breakage survived several merges.
4037
+
4038
+ Each pack keeps its own conventions rather than `en`'s: the eight all quote with `"`,
4039
+ `ru` puts the number last (`…: {{count}}`) as it already does for the sibling
4040
+ `home.pendingDrafts` counts, and `ja` uses the full-width `:` before `{{detail}}`
4041
+ because that value is a runtime message rather than a single token — both choices
4042
+ carry an in-pack note. Terminology is taken from each pack's existing publish-bar
4043
+ vocabulary (`home.pendingDrafts`, `console.ai.seedWarn`) so the two banners read
4044
+ alike.
4045
+
4046
+ Both interpolations survive verbatim in every pack — `{{count}}` in `count` and
4047
+ `{{detail}}` in `publishedWithFindings` — asserted mechanically against the evaluated
4048
+ packs, not by eye. The unrelated `home.pendingDrafts` block (`message` / `cta`) is a
4049
+ different node and is untouched.
4050
+ - c5200f0: Follow-up to #5696: the pending-drafts bar's strings live at `console.ai.pendingDrafts.*` with en+zh locale entries — the i18n call-site key gate and ratchet flagged the original root-level keys that existed nowhere.
4051
+ - fd8dace: Studio surfaces the runtime authoring gate's advisory findings after a **publish**, not only after a save
4052
+
4053
+ objectui#4133 / PR #4236 wired the gate's advisories to the save door and recorded, honestly, what that left unsurfaced: Studio's designer stages every edit as a `mode: 'draft'` save, drafts are never gated (the framework returns at its D1 early-return before a single rule runs), and the publish step that *is* gated returned no `advisories` field at all. So on the flow most tenants actually use, the author was told nothing at either door — for two different reasons, only one of which was objectui's.
4054
+
4055
+ The second reason has expired. `PublishMetaItemResponseSchema` now declares the same optional, omitted-when-empty `advisories` key that `SaveMetaItemResponseSchema` has carried since #4717, and `publishMetaItem` populates it. Measured against the installed `@objectstack/spec` (17.2.0) rather than inferred from the version number: the key survives a `safeParse`, a half-shaped finding is rejected, and a clean publish omits the key entirely. That reading is now a test rather than a note, so a spec drift fails CI instead of silently re-muting the door.
4056
+
4057
+ ⚠️ **Dated note, 2026-09-29 — the save response's advisories key came from an objectstack card — objectui#11016.** The
4058
+ paragraph above dates the save response's `advisories` key by a bare number,
4059
+ which in this repository resolves to an unrelated objectui item. It is
4060
+ objectstack-ai/objectstack#4717, the card that put the runtime authoring gate's
4061
+ advisory findings on the response. The text above is kept as the reading of this change.
4062
+
4063
+ `MetadataClient.publish` and `MetadataClient.publishDraft` — the two methods over the single-item publish route `POST /meta/:type/:name/publish` — now report through the **same** sink, the same event and the same renderer the save door already used. No new UI shape: same warning tier, same 10s duration, same per-finding `rule` + `message` + `hint` formatting, findings still rendered verbatim as server prose. The wiring lands in the data layer rather than at the call sites, so `ResourceEditPage`'s Publish button and the runtime `RuntimeDraftBar` promotion (ObjectView / ReportView / DashboardView) are covered by one change, as are future ones.
4064
+
4065
+ One thing had to differ, and it is the frame's verb. Save and Publish are two different buttons in this product, so a toast that says "Saved" after a Publish tells the author their change is still a draft — the opposite of what happened. `MetadataSaveAdvisoryEvent` therefore gains a required `door: 'save' | 'publish'` and the renderer picks `console.publishAdvisoryTitle` (added to all ten locale packs) accordingly. `door` exists because `mode` cannot answer this: a direct active save and a draft promotion both report `mode: 'publish'`, since both land the body in the active overlay. It is required rather than optional so a future third door cannot be wired without saying which one it is, and the renderer branches on it through an exhaustive switch with a `never` check, so adding a third member is a compile error rather than a silently wrong verb.
4066
+
4067
+ **BREAKING for event constructors — `MetadataSaveAdvisoryEvent.door` is required.** Reading the event is unaffected: a listener that ignores `door` behaves exactly as before, and every other member is unchanged. Constructing one is a compile break — a door-less event literal that type-checked before now fails with TS2741, `Property 'door' is missing`. Measured on the emitted `dist/index.d.ts` of `@object-ui/data-objectstack` on both sides: that single required member is the entire non-comment delta of the package's published surface. **Migration:** add `door: 'save'` or `door: 'publish'` to the literal, whichever write it models — `'save'` for `PUT /meta/:type/:name`, `'publish'` for `POST /meta/:type/:name/publish`. Scored `minor` rather than `major` per the repo's version policy: objectui's major is pinned to `@objectstack`'s so that "same major means compatible" holds across the two repos, so objectui's own breaking changes ship as `minor` with the break named here (`scripts/check-changeset-no-major.mjs`). Every publishable package sits in one `fixed` group, so this entry carries the group.
4068
+
4069
+ Unchanged, deliberately: the **batch** door. "Publish whole app" (`POST /packages/:id/publish-drafts`) discarded per-draft advisories server-side when this change was written — objectstack#9343, open and unruled at the time — and nothing here compensated for that from the client side. A test pinned the absence, so a later traversal of a batch-shaped `published[]` could not be added without turning it red.
4070
+
4071
+ *Corrected before release (`ce986aafc`): both present-tense claims in the paragraph above went false after it was written — objectstack#9343 landed, the batch response now carries per-draft advisories, and `ce986aafc` routes that door through the same seam and flips the absence pin to a presence pin. The paragraph is kept in the past tense as the record of what this change did and did not do; this note is what the CHANGELOG publishes instead of a sentence that was true only while it sat here.*
4072
+ - 38a9568: `useObjectLabel` now keeps a stable identity when no i18next instance is bound,
4073
+ so the memoization it advertises holds on the no-provider path too
4074
+ (objectui#5564).
4075
+
4076
+ react-i18next's `useTranslation` builds its return value out of a fresh `{}` on
4077
+ every render when it has nothing to bind to (`const finalI18n = i18n || {}`,
4078
+ which then feeds that hook's own `useMemo` deps), so the `i18n` object arrived
4079
+ with a new identity each render. `useObjectLabel` keyed its memo on `[t, i18n]`,
4080
+ so the memo never held: measured 4 distinct returned objects across 4 renders
4081
+ with no instance, against 1 with one. That is the wrong way round — the memo
4082
+ exists to stop downstream `useMemo`/`useCallback` deps from being re-keyed in
4083
+ heavy consumers, and `useSafeFieldLabel`'s docstring names the no-provider case
4084
+ as the one it exists to serve.
4085
+
4086
+ Both memo dependencies are now pinned to module-level constants while no
4087
+ instance is bound. The substitution is unobservable rather than merely
4088
+ convenient: every `t()` call in the module sits inside a
4089
+ `for (… of getAppNamespaces())` loop, and `getAppNamespaces()` returns `[]`
4090
+ under exactly the same "is there a usable instance" predicate — so while the
4091
+ substitution is in effect, the closures cannot read either value. When an
4092
+ instance appears the dependencies become the live values again, so a provider
4093
+ mounting after first render recomputes the object exactly once and resolves
4094
+ real translations from then on.
4095
+
4096
+ No API change: no new exports, no signature changes, and the returned surface is
4097
+ identical on both paths. Direct `useObjectLabel()` consumers are fixed alongside
4098
+ `useSafeFieldLabel()` ones, including `ListView.filterFields` — the consumer the
4099
+ memo's own docstring names.
4100
+ - b2437a7: `setLocalized`'s published docblock states the single-locale write rule that is
4101
+ actually in force, instead of deferring the multi-locale-authoring question to a
4102
+ closed card (objectui#5591).
4103
+
4104
+ The docblock read "is not a multi-locale authoring UI (objectui#4163)". objectui#4163
4105
+ closed as completed on 2026-08-15 with that product question still unanswered, so the
4106
+ parenthetical pointed at nothing — and it read as though the question had been settled
4107
+ somewhere a reader could go and check. This is the failure mode objectui#5428
4108
+ demonstrated is not harmless: there, a dangling deferral of exactly this shape let an
4109
+ expired justification sit unread for a release cycle at two surfaces.
4110
+
4111
+ The remedy is objectui#5428's, not a re-pointing at a successor card: state the rule in
4112
+ force (`setLocalized` reaches only the entry for the locale the author is in), keep the
4113
+ open product question open **in place**, and record why there is deliberately no tracker
4114
+ reference — so the next reader cannot restore one. Re-pointing is how the class
4115
+ regenerates, because the next card closes too. The same wording form already landed in
4116
+ `plugin-designer`'s `writeWidgetTitle` and `DashboardWidgetInspector`.
4117
+
4118
+ Prose only. No behaviour, no signature, no test changes — `setLocalized`'s pairing with
4119
+ `pickLocalized` is unchanged and still pinned by `src/__tests__/setLocalized.test.ts`.
4120
+
4121
+ Declared as a `patch` for `@object-ui/i18n` alone because the emit was measured per
4122
+ package rather than assumed, and the two packages this change touches differ:
4123
+
4124
+ - `@object-ui/i18n` — the docblock sits on the **exported** `setLocalized`, so it reaches
4125
+ the published artifacts. Rebuilt with `tsconfig.tsbuildinfo` cleared first (the build is
4126
+ `composite`, which otherwise skips emit), and compared by SHA-256 rather than byte count:
4127
+ `dist/pickLocalized.d.ts` `1e2170ad…` -> `124a1c07…` and `dist/pickLocalized.js`
4128
+ `06eb88bd…` -> `568cb703…`. A consumer reads this text on hover and in the API docs, so
4129
+ it publishes something.
4130
+ - `@object-ui/plugin-dashboard` — the two comments changed there are a `//` banner between
4131
+ declarations and a test docblock, neither attached to an exported declaration.
4132
+ `dist/WidgetConfigPanel.d.ts` is **byte-identical** across the rebuild
4133
+ (`93252e8cdf5a6faa…` both sides). The only artifact that moved is
4134
+ `dist/WidgetConfigPanel.d.ts.map`, whose mappings shift because lines were added above
4135
+ the declarations; no declaration text changed. Nothing user-visible publishes from that
4136
+ package, so it is not named here.
4137
+ - eddc1dd: The Studio copilot tells the agent WHAT the user is discussing (cloud#1610 send half): `ChatPane` accepts a `surfaceContext` and sends it as `context.surface` on every turn (the transport reads the body per send, so it stays fresh); the Studio copilot derives it from the URL alone — the `:tab` pillar segment plus the `?surface=type:name` deep-link the pillars already mirror, so the artifact carries its type discriminator (page/object/dashboard/report). A display chip above the composer (「正在讨论:…」, new `console.ai.discussing` key in all ten packs) makes the sent context visible instead of invisible grounding.
4138
+ - Updated dependencies [97abedc]
4139
+ - Updated dependencies [ad694ac]
4140
+ - Updated dependencies [c131d9e]
4141
+ - Updated dependencies [7b395d8]
4142
+ - Updated dependencies [8cedb0d]
4143
+ - Updated dependencies [6cc910b]
4144
+ - Updated dependencies [061f5e8]
4145
+ - Updated dependencies [4ab4f1b]
4146
+ - Updated dependencies [961ceaa]
4147
+ - Updated dependencies [544aca2]
4148
+ - Updated dependencies [86982ac]
4149
+ - Updated dependencies [ff14e29]
4150
+ - Updated dependencies [64563a9]
4151
+ - Updated dependencies [8acc51b]
4152
+ - Updated dependencies [3261e64]
4153
+ - Updated dependencies [6c2f3c5]
4154
+ - Updated dependencies [9a5f998]
4155
+ - Updated dependencies [4758b33]
4156
+ - Updated dependencies [baac95a]
4157
+ - Updated dependencies [29b45f6]
4158
+ - Updated dependencies [39b8d51]
4159
+ - Updated dependencies [17b323e]
4160
+ - Updated dependencies [33e58d8]
4161
+ - Updated dependencies [256b4c9]
4162
+ - Updated dependencies [30b11ad]
4163
+ - Updated dependencies [3d6badf]
4164
+ - Updated dependencies [990a2d6]
4165
+ - Updated dependencies [6650259]
4166
+ - Updated dependencies [9e6619f]
4167
+ - Updated dependencies [b2683a2]
4168
+ - Updated dependencies [328abeb]
4169
+ - Updated dependencies [e227156]
4170
+ - Updated dependencies [6cd8f66]
4171
+ - Updated dependencies [63ab761]
4172
+ - Updated dependencies [ae582b7]
4173
+ - Updated dependencies [f61dab1]
4174
+ - Updated dependencies [81f8498]
4175
+ - Updated dependencies [a782fa7]
4176
+ - Updated dependencies [76e9df0]
4177
+ - Updated dependencies [6158e4c]
4178
+ - Updated dependencies [bf43afa]
4179
+ - Updated dependencies [858eafb]
4180
+ - Updated dependencies [58da8ae]
4181
+ - Updated dependencies [138ad45]
4182
+ - Updated dependencies [5638529]
4183
+ - Updated dependencies [c476be0]
4184
+ - Updated dependencies [063119f]
4185
+ - Updated dependencies [f9c8c4e]
4186
+ - Updated dependencies [83e3f83]
4187
+ - Updated dependencies [4abc0aa]
4188
+ - Updated dependencies [f560ded]
4189
+ - Updated dependencies [aea682a]
4190
+ - Updated dependencies [64dae8e]
4191
+ - Updated dependencies [9801765]
4192
+ - Updated dependencies [9cebfca]
4193
+ - Updated dependencies [053fdc8]
4194
+ - Updated dependencies [ae476b8]
4195
+ - Updated dependencies [490d9a9]
4196
+ - Updated dependencies [2c3cd1b]
4197
+ - Updated dependencies [546ddf7]
4198
+ - Updated dependencies [44d075b]
4199
+ - Updated dependencies [a26b9e4]
4200
+ - Updated dependencies [06b82b8]
4201
+ - Updated dependencies [8f1d995]
4202
+ - Updated dependencies [3c2b6f7]
4203
+ - Updated dependencies [5961030]
4204
+ - Updated dependencies [299102e]
4205
+ - Updated dependencies [831be72]
4206
+ - Updated dependencies [d0889e2]
4207
+ - Updated dependencies [4d5f9b4]
4208
+ - Updated dependencies [7977ff9]
4209
+ - Updated dependencies [3beef6d]
4210
+ - Updated dependencies [2acd8e1]
4211
+ - Updated dependencies [045d20b]
4212
+ - Updated dependencies [a2d2515]
4213
+ - Updated dependencies [3619792]
4214
+ - Updated dependencies [9e37d9b]
4215
+ - Updated dependencies [48c19bd]
4216
+ - Updated dependencies [a6d8b8d]
4217
+ - Updated dependencies [e75f4c9]
4218
+ - Updated dependencies [19f1639]
4219
+ - Updated dependencies [47547d0]
4220
+ - Updated dependencies [cfc9b6d]
4221
+ - Updated dependencies [81a2eb1]
4222
+ - Updated dependencies [00d2fa6]
4223
+ - Updated dependencies [c6198c2]
4224
+ - Updated dependencies [721d1e0]
4225
+ - Updated dependencies [1237ae4]
4226
+ - Updated dependencies [feac439]
4227
+ - Updated dependencies [e62c44e]
4228
+ - Updated dependencies [b041b9c]
4229
+ - Updated dependencies [ce2aaef]
4230
+ - Updated dependencies [1e215c4]
4231
+ - Updated dependencies [da6e191]
4232
+ - Updated dependencies [aef97e5]
4233
+ - Updated dependencies [52cac38]
4234
+ - Updated dependencies [93fea2e]
4235
+ - Updated dependencies [335abea]
4236
+ - Updated dependencies [1bd79c8]
4237
+ - Updated dependencies [af9e957]
4238
+ - Updated dependencies [0ea7054]
4239
+ - Updated dependencies [9a853f2]
4240
+ - Updated dependencies [fc32921]
4241
+ - Updated dependencies [8f9d87a]
4242
+ - Updated dependencies [846cec0]
4243
+ - Updated dependencies [91facae]
4244
+ - Updated dependencies [b38014e]
4245
+ - Updated dependencies [317dbce]
4246
+ - Updated dependencies [f76f436]
4247
+ - Updated dependencies [ce45a03]
4248
+ - Updated dependencies [abc1b18]
4249
+ - Updated dependencies [fb3a101]
4250
+ - Updated dependencies [f391ede]
4251
+ - Updated dependencies [f5cfbbd]
4252
+ - Updated dependencies [f5cfbbd]
4253
+ - Updated dependencies [64c3cdd]
4254
+ - Updated dependencies [4d65991]
4255
+ - Updated dependencies [0a174f3]
4256
+ - Updated dependencies [d1865d2]
4257
+ - Updated dependencies [561abef]
4258
+ - Updated dependencies [abf710d]
4259
+ - Updated dependencies [c372b29]
4260
+ - Updated dependencies [84defab]
4261
+ - Updated dependencies [681d3f1]
4262
+ - Updated dependencies [969d4f2]
4263
+ - Updated dependencies [f3bc481]
4264
+ - Updated dependencies [b79aac2]
4265
+ - Updated dependencies [19a0b0e]
4266
+ - Updated dependencies [f8e3e9a]
4267
+ - Updated dependencies [3b6bc69]
4268
+ - Updated dependencies [804831c]
4269
+ - Updated dependencies [da45e6b]
4270
+ - Updated dependencies [835f0f3]
4271
+ - Updated dependencies [ed35b44]
4272
+ - Updated dependencies [729e851]
4273
+ - Updated dependencies [20b507a]
4274
+ - Updated dependencies [72d6587]
4275
+ - Updated dependencies [ba0b61a]
4276
+ - Updated dependencies [ab856ed]
4277
+ - Updated dependencies [43c0d17]
4278
+ - Updated dependencies [f0f4d6c]
4279
+ - Updated dependencies [4a7ef0d]
4280
+ - Updated dependencies [276d174]
4281
+ - Updated dependencies [2982ed9]
4282
+ - Updated dependencies [05a49f2]
4283
+ - Updated dependencies [a78cd37]
4284
+ - Updated dependencies [5eabe86]
4285
+ - Updated dependencies [ff5ef1c]
4286
+ - Updated dependencies [81c0bc4]
4287
+ - Updated dependencies [60500cb]
4288
+ - Updated dependencies [6791717]
4289
+ - Updated dependencies [d7573b3]
4290
+ - Updated dependencies [bf3edfe]
4291
+ - Updated dependencies [5aed9e4]
4292
+ - Updated dependencies [83c77dc]
4293
+ - Updated dependencies [e719ebd]
4294
+ - Updated dependencies [516583b]
4295
+ - Updated dependencies [fa429cf]
4296
+ - Updated dependencies [8e74b27]
4297
+ - Updated dependencies [8ebd57f]
4298
+ - Updated dependencies [617707a]
4299
+ - Updated dependencies [7138bc1]
4300
+ - Updated dependencies [cef27e2]
4301
+ - Updated dependencies [06973aa]
4302
+ - Updated dependencies [50798f3]
4303
+ - Updated dependencies [105f3c5]
4304
+ - Updated dependencies [42df928]
4305
+ - Updated dependencies [af3861f]
4306
+ - Updated dependencies [1f4e029]
4307
+ - Updated dependencies [c00bf28]
4308
+ - Updated dependencies [f2158ec]
4309
+ - Updated dependencies [759606e]
4310
+ - Updated dependencies [a51fa0c]
4311
+ - Updated dependencies [6c6cee7]
4312
+ - Updated dependencies [83fe6e7]
4313
+ - Updated dependencies [d1ab06f]
4314
+ - Updated dependencies [91783c4]
4315
+ - Updated dependencies [ca39427]
4316
+ - Updated dependencies [2d36552]
4317
+ - Updated dependencies [ed71d9e]
4318
+ - Updated dependencies [7776fc2]
4319
+ - Updated dependencies [e76634c]
4320
+ - Updated dependencies [92814db]
4321
+ - @object-ui/core@17.7.0
4322
+
3
4323
  ## 17.6.0
4
4324
 
5
4325
  ### Minor Changes