@object-ui/data-objectstack 17.3.0 → 17.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/CHANGELOG.md +888 -0
  2. package/README.md +3 -11
  3. package/dist/index.cjs +1511 -1115
  4. package/dist/index.d.cts +1222 -824
  5. package/dist/index.d.ts +1222 -824
  6. package/dist/index.js +1503 -1111
  7. package/package.json +5 -6
  8. package/src/aggregate-capability.test.ts +0 -177
  9. package/src/bulkDelete.test.ts +0 -75
  10. package/src/bulkUpdate.test.ts +0 -93
  11. package/src/cache/MetadataCache.test.ts +0 -477
  12. package/src/cache/MetadataCache.ts +0 -289
  13. package/src/cloud.ts +0 -109
  14. package/src/connection.test.ts +0 -188
  15. package/src/contracts.ts +0 -115
  16. package/src/errors.test.ts +0 -426
  17. package/src/errors.ts +0 -293
  18. package/src/exportDownload.test.ts +0 -141
  19. package/src/filter-entry-translation.test.ts +0 -360
  20. package/src/filter-operator-ast-parity.test.ts +0 -71
  21. package/src/getObjectSchema.test.ts +0 -145
  22. package/src/importJob.test.ts +0 -71
  23. package/src/index.ts +0 -3658
  24. package/src/integration.ts +0 -200
  25. package/src/listViews.test.ts +0 -175
  26. package/src/metadata-client.test.ts +0 -303
  27. package/src/metadata-client.ts +0 -849
  28. package/src/occ.test.ts +0 -101
  29. package/src/onMutation.test.ts +0 -562
  30. package/src/onWriteWarning.test.ts +0 -273
  31. package/src/orderby-serialization.test.ts +0 -115
  32. package/src/queryDataset.test.ts +0 -132
  33. package/src/searchAll.test.ts +0 -120
  34. package/src/security.ts +0 -243
  35. package/src/spec-symbol-batch6.test.ts +0 -306
  36. package/src/studio.ts +0 -152
  37. package/src/userState.test.ts +0 -315
  38. package/src/userState.ts +0 -238
  39. package/src/v3-compat.test.ts +0 -240
  40. package/src/validation-error.test.ts +0 -94
package/CHANGELOG.md CHANGED
@@ -1,5 +1,881 @@
1
1
  # @object-ui/data-objectstack
2
2
 
3
+ ## 17.6.0
4
+
5
+ ### Minor Changes
6
+
7
+ - a8411ad: data-objectstack: retire the four remaining `v3.0.0 Deep Integration` modules — `IntegrationManager`, `SecurityManager`, the studio canvas helpers (`createDefaultCanvasConfig` / `snapToGrid` / `calculateAutoLayout`), and the contract helpers (`validatePluginContract` / `generateContractManifest`) — for having zero code consumers outside this package
8
+
9
+ `src/index.ts`'s `// v3.0.0 Deep Integration modules` banner introduced five
10
+ modules. objectui#4152 / PR #4239 already retired the first,
11
+ `CloudOperations`, for fabricating a plausible success against a client
12
+ namespace that does not exist. This closes out the other four:
13
+ `contracts.ts`, `integration.ts`, `security.ts`, `studio.ts`.
14
+
15
+ **Not a repeat of #4152's urgency limb.** None of these four fabricated
16
+ anything — `SecurityManager.generateCSPHeader()` really composes a header,
17
+ `snapToGrid` really snaps, `validatePluginContract` really validates. What
18
+ they shared with `CloudOperations` was the other limb: published surface of
19
+ `@object-ui/data-objectstack` with a measured **zero** code consumers outside
20
+ this package, across `packages/`, `apps/` and `examples/` (`.ts`/`.tsx`,
21
+ excluding `node_modules`). The two apparent hits on re-measurement were
22
+ homonyms, not consumers — `packages/plugin-designer/src/PageDesigner.tsx`
23
+ declares its own local `snapToGrid` callback with no import from this
24
+ package, and the `SecurityManager` hits outside this file are prose in
25
+ `CHANGELOG.md`. Under the startup-focus principle a declared capability with
26
+ no producer, no consumer and no business pull is retired, not kept on the
27
+ chance it becomes useful.
28
+
29
+ **Breaking, in FROM → TO form.** The following are no longer exported from
30
+ `@object-ui/data-objectstack`:
31
+
32
+ - `IntegrationManager` and its types (`IntegrationConfig`, `IntegrationTrigger`,
33
+ `IntegrationProvider`, `SlackIntegrationConfig`, `EmailIntegrationConfig`,
34
+ `WebhookIntegrationConfig`)
35
+ - `SecurityManager` and its types (`SecurityManagerPolicy`, `CSPConfig`,
36
+ `AuditLogConfig`, `AuditEventType`, `DataMaskingConfig`, `DataMaskingRule`,
37
+ `AuditLogEntry`)
38
+ - `createDefaultCanvasConfig`, `snapToGrid`, `calculateAutoLayout` and their
39
+ types (`StudioCanvasConfig`, `StudioPropertyEditor`,
40
+ `StudioThemeBuilderConfig`, `StudioColorPalette`, `StudioTypographyPreset`,
41
+ `StudioShadowPreset`)
42
+ - `validatePluginContract`, `generateContractManifest` and their types
43
+ (`PluginContract`, `PluginExport`, `PluginAPIContract`,
44
+ `ContractValidationResult`, `ContractValidationError`)
45
+
46
+ It is a `minor` under this repo's version policy (objectui's own breaking
47
+ changes never declare `major`). Nothing broke that was working: the only
48
+ in-repo construction sites were this package's own `v3-compat.test.ts` (which
49
+ exercised the modules directly) and `spec-symbol-batch6.test.ts` (which only
50
+ guarded `SecurityManagerPolicy`'s name against colliding with the spec's
51
+ unrelated `SecurityPolicy` — that guard is removed along with its subject).
52
+
53
+ **No compile-compat stub was left**, for the same reason #4152 left none: with
54
+ no consumer to keep compiling, a stub would be a second phantom surface
55
+ guarding the first.
56
+
57
+ **The banner and the compat-test title stop claiming a v3.** `index.ts`'s
58
+ `// v3.0.0 Deep Integration modules` banner had nothing left under it once
59
+ these four went, so it is removed rather than retitled.
60
+ `v3-compat.test.ts` — titled "v3.0.0 compatibility tests for @objectstack
61
+ dependencies" against a resolved `@objectstack` family of `17.0.0-rc.6` even
62
+ before this change — is not an empty shell (one block, `PaginatedResult API`,
63
+ never depended on any of the five retired modules), so it stays and is
64
+ retitled instead of deleted.
65
+
66
+ A negative pin
67
+ (`src/v3-deep-integration-retired-4241.pin.test.ts`) replaces the retired
68
+ `v3-compat.test.ts` cases and fails if any of the thirty retired names
69
+ returns — reading both the runtime export list (which catches the seven
70
+ class/function exports) and `index.ts`'s source text (the only instrument
71
+ that can catch a returning `export type`).
72
+
73
+ ### Patch Changes
74
+
75
+ - 1ef236e: `@object-ui/data-objectstack` stops publishing its `src/` tree
76
+
77
+ The manifest's `files` array listed `src` alongside `dist`, so every published tarball carried all 43 source files — 38 of them `*.test.ts`. It had been that way since the package's first commit (`780a1b993`), never added for a consumer, and objectui#4006 recorded the same shape without acting on it: its scope was the `*.test.d.ts` half that the build program emitted into `dist`, and its own triage note graded this half as tarball weight rather than a break.
78
+
79
+ Nothing in the published surface reached those files, which is why no consumer changes in either direction. Measured on a cleanly rebuilt `dist`, all four ways in are closed: the `exports` map has one entry (`.`) and every condition under it targets `dist`; `main` / `module` / `types` are `./dist/index.js`, `./dist/index.js`, `./dist/index.d.ts`; the repo and the docs teach only the root specifier, and no `@object-ui/data-objectstack/src/...` deep import exists anywhere (the `src` paths in sibling `vite.config.ts` / `vitest.config.mts` files are workspace aliases resolved through `path.resolve()` against the source tree, which no `files` array shapes); and the tarball holds no sourcemap that could point back at `src`, since `tsup.config.ts` sets `sourcemap: false` and its bundled `dts` writes no `.d.ts.map` — the built `dist` contains four files, zero `.map` among them, and zero occurrences of `sourceMappingURL` or `../src/`.
80
+
81
+ `npm pack --dry-run` across the change, on the same `dist`:
82
+
83
+ | | before | after |
84
+ | --- | --- | --- |
85
+ | entries | 51 | 8 |
86
+ | unpacked | 1356830 B | 719876 B |
87
+ | tarball | 393379 B | 222157 B |
88
+
89
+ 43 files leave, none arrives, and every surviving entry is byte-identical apart from the edited `package.json`: `dist/index.{js,cjs,d.ts,d.cts}`, `README.md`, `CHANGELOG.md`, `LICENSE`, `package.json`. The 43 are the 38 tests plus the five modules they cover (`index.ts`, `errors.ts`, `metadata-client.ts`, `userState.ts`, `cache/MetadataCache.ts`), whose published form remains the bundled `dist/index.js`.
90
+ - cf4f8a6: `MetadataClient.layered()` now reads the three-layer view from its declared path,
91
+ `GET /meta/:type/:name/layers`, instead of flagging the ordinary item read.
92
+
93
+ The consumer half of objectstack#5882 (ruled B by the maintainer; the server half
94
+ landed in objectstack#6596 and shipped in `@objectstack/spec@17.0.0`). The layered
95
+ projection — packaged baseline vs tenant overlay vs merged effective, which is
96
+ what the Studio metadata editor's comparison tabs render — used to be reached by
97
+ hanging a query flag on `GET /meta/:type/:name`. One route therefore answered two
98
+ unrelated representations chosen by a query parameter, while `packages/spec`
99
+ declared only the unflagged one: anything generating a client from the route
100
+ table produced a parser that was simply wrong for the flagged call. The
101
+ projection now has a path of its own and a response schema of its own
102
+ (`GetMetaItemLayeredResponseSchema`).
103
+
104
+ Same body, same envelope, so nothing in the editor changes shape: `code`,
105
+ `overlay`, `overlayScope`, `effective`, the load-time `_diagnostics` and the full
106
+ ADR-0010 protection envelope all still arrive on one round trip, and `?package=`
107
+ (ADR-0048) is still threaded — the two entry points are served by ONE handler
108
+ upstream precisely so the deprecation window's promise holds. The retired
109
+ spelling still answers during that window, marked with RFC 9745 `Deprecation` and
110
+ an RFC 8288 `Link: rel="successor-version"` pointing here, so this migration is
111
+ safe against a lagging backend for as long as the window stays open, and it is
112
+ what lets the maintainer close it.
113
+
114
+ One behaviour delta rides along, and it is the server's design rather than a
115
+ choice made here: the retired flag FELL THROUGH to the plain item read when the
116
+ backend's protocol implementation had no layered support, answering the
117
+ `{ type, name, item }` envelope. A dedicated path refuses to answer a different
118
+ resource under this one's declared shape and returns 501 `NOT_IMPLEMENTED`, which
119
+ surfaces as a failed read instead of a comparison view whose `code` and `overlay`
120
+ are silently blank.
121
+
122
+ The request is built in this package rather than delegated to
123
+ `@objectstack/client` because the SDK expresses no layered read in either
124
+ spelling — the framework's REST route ledger records the route as `server-only`,
125
+ "consumed by objectui over plain HTTP", and whether the SDK should express it is
126
+ an open upstream product call. The new path expectation is derived from the
127
+ installed `@objectstack/spec` route table, and a ratchet keeps any shipped source
128
+ file or skills guide from reaching the projection by query flag again.
129
+ - 3d053bb: The Studio's overlay-layer badge stops printing the producer's raw scope value
130
+
131
+ objectui#4982. `MetadataLayered.overlayScope` was typed `string | null` under a comment naming its
132
+ vocabulary as `organization | environment | package` — three spellings the producer has never
133
+ emitted. The real vocabulary lives in `@objectstack/spec`'s `GetMetaItemLayeredResponseSchema`
134
+ (`z.enum(['org', 'env']).nullable()`), and the framework's two assignment sites write `'org'` /
135
+ `'env'`. Because the declared type was `string`, no compiler anywhere had an opinion, so the wrong
136
+ comment was the only description of the field a reader had.
137
+
138
+ `overlayScope` is now the spec union, derived by indexing the published response type rather than
139
+ restated locally (a restatement is the fork `check:spec-symbol-derivation` rejects); the alias ships
140
+ as `MetadataOverlayScope`.
141
+
142
+ User-visible half: `LayeredDiff`'s overlay badge rendered that value straight to screen while the
143
+ sibling artifact / none / merged badges all went through `translateConsoleValue`, and
144
+ `CONSOLE_VALUE_ZH.layer` had no entry for either value the field can hold. One badge therefore had
145
+ two languages depending on the data — a zh-CN admin opening any overlaid metadata item read `org` /
146
+ `env`, while an un-overlaid one read 「已设」. The badge now translates like its three siblings, with
147
+ 「组织」/「环境」 added to the layer table. That table's overlay-scope half is keyed by the spec union,
148
+ so a scope the spec adds later fails `type-check` until it has a label instead of quietly reaching a
149
+ badge in English. `translateConsoleValue` remains zh-only for every group, as before — extending it
150
+ to the other locale packs is a separate decision and not part of this change.
151
+ - d871f8e: A view personalization overlay no longer freezes the view it was laid over. `ObjectView`'s
152
+ `persistViewPatch` sends `{ ...baseViewDef, ...patch }`, so a row written by a mere column
153
+ drag or sort change stored the view's whole body — its effective `filter`, `columns`,
154
+ `label`, `type`, `isDefault` — as of that moment, and the display merge
155
+ (`{ ...source, ...override }`) then let that snapshot outrank the source view indefinitely:
156
+ an admin edited a view's filter and everyone who had ever resized a column silently kept the
157
+ old filter, with nothing reporting it.
158
+
159
+ An overlay now contributes only the keys it owns — `rowHeight`, `sort`, `hiddenFields`,
160
+ `columnState`, `inlineEdit` (`VIEW_OVERLAY_OWNED_KEYS`, new export from
161
+ `@object-ui/data-objectstack` alongside `narrowPersonalizationOverlay`) — so a later change
162
+ to the source view reaches every user, including those whose stored row still carries the
163
+ old snapshot: rows written before this change stop shadowing the source on the next read,
164
+ with no migration to run and nothing rewritten at rest. A genuine saved view's own body is
165
+ untouched — it is classified by the same predicate `listViews()` already excludes overlay
166
+ rows by, so a row cannot be an overlay for one reader and a saved view for the other
167
+ (objectui#5233, ruled on objectstack#7494).
168
+ - a0b9e91: A system (code-defined) view's personalization overlay row no longer masquerades as a user-created saved view.
169
+
170
+ Toggling density / sort / hidden columns / column widths / inline-edit on a code-defined view persists a row under the same `type='view'` metadata namespace a genuinely saved view lives in, keyed by the same id (`ObjectStackAdapter.updateViewConfig`). `listViews()` previously returned that row indistinguishably from a real saved view, so `ObjectView`'s `isSystem = !saved` check flipped to `false` and the tab gained Rename / Delete / Set-default / Pin against a view that lives in code — `handleDeleteView` would even call `dataSource.deleteView` on it.
171
+
172
+ Two layers now keep the two kinds of rows apart:
173
+
174
+ - **Write side**: `updateViewConfig` — the only production writer of personalization overlays — stamps an explicit `_isOverride: true` discriminant on every row it saves, UNLESS the write targets an already-saved view's own row (see below).
175
+ - **Read side**: `listViews()` excludes any row carrying that marker, and (for rows already persisted before this fix shipped) a best-effort legacy shape: a flat body with a `viewKind` the platform can only have server-side-backfilled from a registry (code-defined) baseline — a genuine runtime-created saved view never has one.
176
+
177
+ `listViewOverrides()` (the reader `ObjectView` uses to merge these settings back into the live view for display) is unchanged — it is supposed to keep seeing overlay rows.
178
+
179
+ The overlay this stores is **org-wide shared view settings**, not a per-user preference (a true per-user scope is a parked platform-side v18 direction) — comments describing it as "personal" have been corrected to say so.
180
+
181
+ **Follow-up fix (same card, post-review):** `updateViewConfig`'s ONE call site (`ObjectView`'s toolbar-driven toggle) fires for a toggle on EITHER a system view OR an already-saved view — a saved view whose own toolbar the user toggles writes to that same view's own row. Stamping the overlay marker unconditionally there would flag the user's own saved view as an overlay and make `listViews()` exclude it on the very next read, i.e. the saved view would vanish from the switcher the moment its density was adjusted. `updateViewConfig` gains an optional `opts.isSavedView` parameter (also added to the `DataSource` interface in `@object-ui/types`); `ObjectView` passes it from the same `isSavedViewId` classification its readonly gate and mutating handlers already use, and the marker is withheld when it's true.
182
+ - Updated dependencies [88085e3]
183
+ - Updated dependencies [2533ec5]
184
+ - Updated dependencies [bbe8b86]
185
+ - Updated dependencies [8477be5]
186
+ - Updated dependencies [279fb13]
187
+ - Updated dependencies [ad07b65]
188
+ - Updated dependencies [41f498b]
189
+ - Updated dependencies [e1d4251]
190
+ - Updated dependencies [1184192]
191
+ - Updated dependencies [a2a9747]
192
+ - Updated dependencies [ac600e5]
193
+ - Updated dependencies [c1ef923]
194
+ - Updated dependencies [af5e292]
195
+ - Updated dependencies [7f96b10]
196
+ - Updated dependencies [167ec42]
197
+ - Updated dependencies [f1d4748]
198
+ - Updated dependencies [b1119ec]
199
+ - Updated dependencies [9f23d2b]
200
+ - Updated dependencies [578e025]
201
+ - Updated dependencies [af025ee]
202
+ - Updated dependencies [598c89a]
203
+ - Updated dependencies [b8b9af4]
204
+ - Updated dependencies [31676be]
205
+ - Updated dependencies [9ce096f]
206
+ - Updated dependencies [e05db88]
207
+ - Updated dependencies [5ffcc14]
208
+ - Updated dependencies [d971e51]
209
+ - Updated dependencies [97abb24]
210
+ - Updated dependencies [deb157a]
211
+ - Updated dependencies [d2ce342]
212
+ - Updated dependencies [9695da7]
213
+ - Updated dependencies [58b8346]
214
+ - Updated dependencies [dfc6975]
215
+ - Updated dependencies [3cf4de0]
216
+ - Updated dependencies [c9dc811]
217
+ - Updated dependencies [a0b9e91]
218
+ - Updated dependencies [99bd015]
219
+ - @object-ui/types@17.6.0
220
+ - @object-ui/core@17.6.0
221
+
222
+ ## 17.5.0
223
+
224
+ ### Minor Changes
225
+
226
+ - 932cbcd: An app you are not allowed to open now says so, instead of reporting that it may still be publishing
227
+
228
+ `GET /api/v1/meta/apps` is filtered per session server-side (`filterAppForUser`), so an app withheld by its `requiredPermissions` and an app that does not exist were byte-identical to the console: both simply absent from the list. With one fact and two conditions, `AppContent` rendered its only copy for an absent app — "This app is not available yet — it may still be publishing. Try again in a moment." — over a permanent authorization decision, under a Retry button that could never succeed.
229
+
230
+ That is not a cosmetic complaint. On a downstream acceptance round one role hit this screen while another opened the same app fine, and because the copy names a transient deployment state the finding was filed as a suspected platform defect and carried through two test batches before a clean-baseline investigation found the account was missing a permission-set binding. The gate had been working exactly as designed; the message is what sent everyone to the wrong place.
231
+
232
+ The maintainer ruling (2026-08-12) took the contract half first. objectstack#8013 made the BY-NAME route answer an explicit denial — `403` with the ADR-0112 catalog code `PERMISSION_DENIED` in the declared `{ success: false, error: { code, message } }` envelope — for an app that exists and whose `requiredPermissions` the session lacks, while the LIST route stays filtered exactly as before, with no `authorized: false` flag, so the enumeration surface is not widened past what a direct by-name probe already implies. Absence keeps answering `404 RESOURCE_NOT_FOUND`, and so do the two neighbouring refusals the same ruling deliberately left alone: an unpublished app (ADR-0045 §3 keeps it externally unobservable) and an app gated by an absent optional service (ADR-0057 D10 — nothing was denied to the caller).
233
+
234
+ This is the console half. When a requested app is missing from the list and the existing post-publish readiness re-check still cannot find it, the console asks the by-name route which of the two it is, through a new `ObjectStackAdapter.probeAppAccess(name)`. On the measured code it renders a plain authorization message with a way back to the launcher; on anything else — an absent app, an unreachable server, a host that injected a DataSource without the probe — today's publishing copy renders byte for byte, retry button included.
235
+
236
+ Two properties of that seam are load-bearing rather than incidental. It branches on the ADR-0112 **code**, never the status (objectui#4408): the two answers under test are both errors one status apart, and a status-reading implementation passes the happy path while going blind exactly where the defect lives. And only `denied` moves the copy: this bug exists because the console asserted a state it had not measured, so a probe that fails, times out or cannot be issued must leave the screen alone rather than guess in the other direction.
237
+
238
+ `probeAppAccess` is deliberately separate from `getApp` rather than a flag on it: `getApp` degrades every failure to `null` — the very conflation being undone — and memoises in the adapter's metadata cache, where a verdict about the CALLER would outlive the session it described. New public API on the adapter (`probeAppAccess`, `isAppPermissionDeniedError`, `APP_PERMISSION_DENIED_CODE`, `AppAccessVerdict`), purely additive; nothing existing changed shape. Three new `empty.*` keys ship in all ten locale packs.
239
+
240
+ - 537a0d1: `deleteView` removes every home the view has — deleting a draft-only saved view no longer silently no-ops
241
+
242
+ A view has two possible homes: the pending per-item **draft** (`DELETE /api/v1/meta/view/:name?state=draft`) and the **published** overlay (`DELETE /api/v1/meta/view/:name`). `deleteView` addressed only the second, unqualified. Deleting a view that existed only as a draft therefore fired the delete at the published overlay, the server answered `200 {"success":true,"reset":false,"message":"No view '…' found — nothing to delete."}`, the draft survived untouched, and the tab was still there after a reload — while the receipt reported `{ deleted: false }` and nothing surfaced the refusal to the user.
243
+
244
+ That is not a corner case. ADR-0034's `persistRuntimeMetadata` (app-shell) stages **every** runtime edit as a draft, and a view created from the `+` tab lives ONLY as a draft until an explicit Publish — so both "a view you just made" and "a published view you have since edited" are routinely draft-carrying.
245
+
246
+ **Why this is not the mechanical mirror of #4139.** `updateView` probes the draft first and writes back to whichever home the read resolved; that is right for an update in all cases. Copying it here would have been wrong in one: on a published+draft pair a draft-first-_only_ delete discards the draft and leaves the published row still serving the view. That is not Delete view, it is **Discard draft** — a deliberately different operation that already exists (`discardRuntimeDraft`, documented as "the published overlay is untouched"). The asymmetry has a clean statement: for an update, one home is the right home; for a delete, "remove this view" is satisfied only when _no home is left serving it_.
247
+
248
+ So both homes are now deleted, **draft first**. The order is load-bearing on the failure path: a fault between the two calls leaves the published overlay intact, so the view is still served and the delete is cleanly retryable. The reverse order would strand a draft-only view — precisely the bug above.
249
+
250
+ **Two blind calls, no probe.** Measured against the framework's `deleteMetaItem`: a missing home is reported as a **200** carrying `reset:false` (`"No pending draft for view/x."` / `"No view 'x' found — nothing to delete."`), never a 404. There is nothing for a probe to protect against, and `updateView`'s probe exists for a different reason — its read must resolve the row the merge writes back to — which has no counterpart for a delete.
251
+
252
+ **One transport, one error contract.** Both halves now go through `MetadataClient.reset()`, the transport that can express the `?state=` qualifier and the one `updateView`'s draft half already uses. The published half previously went through `client.meta.deleteItem`; measured, that issues the byte-identical request (this adapter configures no environment scoping), so routing it here changes no addressing and collapses two error shapes into one `MetadataError`.
253
+
254
+ The receipt is widened **additively**: `{ deleted }` gains optional `draft` and `published` outcomes (`removed`, plus the server's `reset` / `message`). `deleted` is true only when no home is left serving the view _and_ at least one actually held a row — a view that existed in neither home still answers `false`, unchanged. A failure of the published half after the draft was discarded now throws (matching `updateView`'s convention of surfacing a fault rather than degrading) carrying the partial state on the error's `outcome`: "draft gone, overlay left" is exactly what the old `{ deleted: boolean }` could not express, and it is never rounded up to `true`.
255
+
256
+ Cache invalidation moves into a `finally`, so `invalidateViewKeys` fires exactly once per call on **every** outcome including the throw. After a half-failure the draft row really is gone, and objectui#4363's asymmetry decides it: an unnecessary invalidation costs one refetch, a missed one costs the cache's full 5-minute TTL of stale overrides.
257
+
258
+ Minor rather than patch: this moves published behavior for existing callers and adds two exported types, the same grading objectui#4271's `get()` unwrap and objectui#4495's `find()` resolve→reject took. The `.d.ts` diff is additive only — `deleteView`'s return widens from an inline `{ deleted: boolean }` to the new `DeleteViewResult`, which still carries `deleted: boolean` — so no consumer needs a code edit to keep compiling. A repo-wide census found one call site (app-shell's `ObjectView` delete handler), which awaits the call and does not read the receipt.
259
+
260
+ - bec3e14: The `DataSource` contract carries `deleteView`'s per-home outcomes (#4564)
261
+
262
+ #4479 / PR #4562 widened the ObjectStack adapter's `deleteView` to return
263
+ `DeleteViewResult { deleted, draft?, published? }`, so a caller could finally tell a
264
+ partial delete ("draft gone, published overlay left") from a complete one. The shared
265
+ interface did not follow: `DataSource.deleteView?` still declared the narrow
266
+ `Promise<{ deleted: boolean }>`.
267
+
268
+ Nothing failed to compile, and that is exactly what made the gap invisible — a wider
269
+ return is assignable to a narrower declaration, so the adapter satisfied the interface
270
+ while every consumer reaching it **through** `DataSource` was handed a type with the
271
+ per-home outcomes already discarded. The one real call site today (app-shell's
272
+ `ObjectView` delete handler) awaits the call and reads nothing off the receipt, so the
273
+ loss was latent rather than broken.
274
+
275
+ `DeleteViewResult` and `ViewHomeDeleteOutcome` now live in `@object-ui/types`, beside
276
+ the `DataSource` interface that returns them, and `deleteView?`'s declared return is
277
+ `Promise<DeleteViewResult>`. The direction was forced: the dependency runs
278
+ `@object-ui/data-objectstack` to `@object-ui/types` and never the other way, so the
279
+ shapes could not be imported downward — moving them was the alternative to re-declaring
280
+ a structural twin in `types`, which the one-resolver rule rejects because a copy is
281
+ mutually assignable with the original for exactly as long as it takes to drift.
282
+
283
+ `@object-ui/data-objectstack` re-exports both names unchanged, so every importer PR
284
+ #4562 left pointing at it keeps compiling — and now resolves to the same declaration the
285
+ shared contract speaks rather than a look-alike. A repo-wide census before the move
286
+ found zero importers of either name outside the declaring file itself, PR #4562's own
287
+ suite included, so the re-export is insurance rather than a load-bearing shim.
288
+
289
+ `deleteView` stays **optional** on the interface and keeps both parameters; the growth is
290
+ to the return type only, and `deleted` is untouched, so a consumer reading only `deleted`
291
+ needs no edit.
292
+
293
+ Grading, per this repository's version-alignment convention (the major tracks
294
+ `@objectstack`, never an API-break count):
295
+
296
+ - `@object-ui/types` — **minor**: entry-reachable growth. Two new exported interfaces
297
+ plus a widened method return on `DataSource`, all reachable from the package entry.
298
+ - `@object-ui/data-objectstack` — **minor**, measured rather than assumed. Its emitted
299
+ `dist/index.d.ts` is **not** byte-identical after the swap: the two `interface` blocks
300
+ leave the file and are replaced by a re-export from `@object-ui/types` (121.61 KB to
301
+ 120.25 KB). Both names remain in the public export list, so no importer breaks, but the
302
+ declaration genuinely moved and the emitted types now depend on `@object-ui/types` for
303
+ it — that is a minor, not a patch.
304
+
305
+ - 479cc7b: `MetadataClient.get()` returns the item body its docblock always promised — the field half of the permission matrix is alive again
306
+
307
+ `GET /api/v1/meta/:type/:name` answers the spec-declared envelope `{ type, name, item, …protection fields }` — one shape, for published and draft reads alike, since objectstack#5563 collapsed the read to it. `get()` handed that envelope straight back to callers while its own docblock declared it returned "the unwrapped item content". Every consumer reading `obj.fields` therefore read `undefined`.
308
+
309
+ The visible cost was the entire field-level half of the permission matrix: expanding any object in `/_console/apps/:app/metadata/permission/:set` reported "No fields registered for this object." with zero checkboxes, for every object, while the network showed that object's 21 fields arriving 200 OK. Reproduced against two objects on fresh loads, and proven not to be the read-only gate — a run with the editor fully writable (864 enabled checkboxes) still showed an empty field sub-table, which is exactly what a read resolving `undefined` predicts.
310
+
311
+ That was one symptom of nine. A census of every `get()` call site found **zero** deliberate readers of the envelope and nine consumers reading the body directly, all of them broken the same way: the RLS CEL editor's field lint and autocomplete resolved an empty field set; the dataset inspectors and the preview field/catalog hooks came back empty; the report drill-down's fallback path read `def.object` off the envelope, found nothing and silently returned; the record-page seed synthesized a default layout from an envelope instead of an object; and the Field Designer read `raw.fields` for display and then wrote `{ ...raw, fields }` back — saving the envelope over the object body. None of it was caught, because the test doubles across the repo were written against the docblock: they answered a bare `{ fields }` body, so the suite exercised the documented contract while production ran the other one.
312
+
313
+ The fix is at the producer, not the nine consumers. `get()` now unwraps the envelope once, at the client boundary — so every one of those call sites is repaired without being touched. Detection is by the PRESENCE of the three keys `GetMetaItemResponseSchema` declares (`type: string`, `name: string`, an `item` slot), never guessed from payload contents: a metadata document carrying its own `type` and `name` (a view is `{ name, type: 'grid', … }`) has no `item` and is left whole, and a document with an `item` property of its own but no envelope identity is likewise untouched. Key count is deliberately not part of the test, since a real envelope also spreads the ADR-0008 protection carriers. Anything that is not the envelope — an older server answering the bare document — passes through byte-for-byte, and 404 still reads as `null`.
314
+
315
+ `getDraft()` is unchanged and keeps returning the envelope, which its docblock declares and roughly eleven call sites depend on by reading `.item`. That asymmetry is now real rather than aspirational: the two methods share one private transport, and differ only in whether they unwrap. `unwrapDraftBody` (app-shell) and `unwrapViewDraft` (this package) remain the shared helpers for taking a draft body out, and both were already tolerant of either shape, so the two seams that reach a draft through `get()` keep their exact semantics — including reading an empty draft as "nothing pending".
316
+
317
+ Minor rather than patch: this moves published behavior for existing callers, the same grading `find()`'s resolve-to-reject change took. No signature changed — the `.d.ts` diff is documentation plus one private member — so nothing needs a code edit to keep compiling; a caller that had written its own `.item` compensator against the old behavior would need to drop it, and none exists in this repo.
318
+
319
+ - 2776b11: data-objectstack: retire the phantom `CloudOperations` surface — the class, its three `Cloud*` types, and the module that claimed to integrate a cloud namespace no client has ever shipped
320
+
321
+ `src/cloud.ts` exported a `CloudOperations` class with four methods, all
322
+ re-exported from the package entry, so this was published surface of
323
+ `@object-ui/data-objectstack`. Every method optional-chained into
324
+ `client.cloud?.…`, and no released `@objectstack/client` has ever exported a
325
+ `cloud` namespace. Re-measured at `17.0.0-rc.6` before deleting: the module's
326
+ export list is `ObjectStackClient`, `ScopedProjectClient`, `RealtimeAPI`,
327
+ `QueryBuilder`, `FilterBuilder`, `createQuery`, `createFilter`, and a
328
+ constructed client's `.cloud` is `undefined`. The nearest real namespaces on the
329
+ instance — `projects` (which owns `/api/v1/cloud/environments`) and `packages`
330
+ (which owns marketplace installs) — are not what these methods reached for.
331
+
332
+ So every call resolved `undefined` and fell through to a literal:
333
+
334
+ | method | what it returned, always |
335
+ | :-------------------- | :------------------------------------------------------------ |
336
+ | `deploy` | `{ deploymentId: 'deploy-' + Date.now(), status: 'pending' }` |
337
+ | `getDeploymentStatus` | `{ status: 'unknown' }` |
338
+ | `searchMarketplace` | `[]` |
339
+ | `installPlugin` | `{ success: false }` |
340
+
341
+ The maintainer's 2026-08-11 ruling removed it rather than repairing it, and named
342
+ the reason: `deploy()` did not degrade to an error, it **manufactured a
343
+ plausible success**. A caller got a well-formed `deploymentId` for an operation
344
+ that never left the process and then polled it forever against
345
+ `{ status: 'unknown' }`. That is the most dangerous shape for an AI consumer,
346
+ which builds downstream logic on the fake id instead of getting suspicious.
347
+ Under the startup-focus principle a declared capability with no producer, no
348
+ consumer and no business pull is retired, not stubbed.
349
+
350
+ **Breaking, in FROM → TO form.** `CloudOperations`, `CloudDeploymentConfig`,
351
+ `CloudHostingConfig` and `CloudMarketplaceEntry` are no longer exported from
352
+ `@object-ui/data-objectstack`. It is a `minor` under this repo's version policy
353
+ (objectui's own breaking changes never declare `major`). Nothing broke that was
354
+ working: the only in-repo construction site was a test, and every method's
355
+ observable behaviour was a fabricated constant.
356
+
357
+ **No compile-compat stub was left.** The ruling allows one — throwing loud
358
+ `NotImplemented` — only where a compile need is demonstrated. Measured across the
359
+ whole repository, the sole importers were the package's own `index.ts`,
360
+ `v3-compat.test.ts` (three cases asserting the fallback had the right _keys_,
361
+ which is how the emptiness stayed green) and objectui#3720's vocabulary pin. No
362
+ app, no other package, no doc. With no consumer to keep compiling, a stub would
363
+ be a second phantom surface guarding the first.
364
+
365
+ The false module header went with it — it read `Cloud namespace integration for
366
+ @objectstack/spec v3.0.0 / Replaces the legacy Hub namespace`, against a resolved
367
+ spec of `17.0.0-rc.6` and schemas this package never consumed.
368
+
369
+ **objectui#3720's pin retires with its subject.** `cloud-environment-vocabulary.pin.test.ts`
370
+ pinned the doc comment on `CloudDeploymentConfig.environment` — the deliberate
371
+ three-member deploy-target vocabulary and the `staging`-is-not-a-discovery-member
372
+ trap. Every fact it held was a claim _about_ that comment, and its spec-side
373
+ assertions existed only to keep those claims honest; with the type deleted they
374
+ would pin `@objectstack/spec`'s enums on behalf of no local reader — the same
375
+ phantom shape this change closes. #3720's conclusion is unaffected and now moot:
376
+ it found no producer-side deploy-target type to converge onto because the
377
+ producer did not exist, and this change removes the consumer that was waiting for
378
+ it. Its pending empty changeset (`cloud-deploy-environment-vocabulary-3720.md`,
379
+ never released) is removed too, since it announced a deliberate vocabulary on a
380
+ type this same release deletes.
381
+
382
+ A negative pin (`src/cloud-surface-retired-4152.pin.test.ts`) replaces the
383
+ retired cases and fails if any of the four names returns — reading both the
384
+ runtime export list (which catches the class) and `index.ts`'s source text
385
+ (which is the only instrument that can catch a returning `export type`).
386
+
387
+ - 2e3b0c0: fix(list): an `OBJECT_API_DISABLED` list request renders an honest cannot-work state instead of the empty state
388
+
389
+ A list pointed at an object whose `enable` block withholds the API rendered its ordinary
390
+ empty state, so _"this page cannot work, and never could"_ reached the user as _"you have no
391
+ records"_ (objectui#4408). The reported instance — `Setup › Advanced › Signing Keys`, whose
392
+ `sys_jwks` declares `enable.apiEnabled: false` — could not load for any persona and said so
393
+ to nobody. That is also why the upstream defect objectstack#7544 survived review for its
394
+ whole life: a merely unpopulated page invites nobody to click through.
395
+
396
+ The masking had two halves, in two packages, and neither package could see the other:
397
+
398
+ - **`@object-ui/data-objectstack`** (minor — see the grading note below) — `find()` degraded
399
+ **every** 404 into `{ data: [], total: 0 }` and memoised the resource, so the denial arrived
400
+ at the surface as a successful empty result, indistinguishable from a genuinely empty
401
+ object. The two `enable`-block denials are now let through instead: `OBJECT_API_DISABLED`
402
+ (404) and `OBJECT_API_METHOD_NOT_ALLOWED` (405). The memo skips them too — absorbing one
403
+ would have pinned the object to "empty" for the rest of the session.
404
+ - **`@object-ui/plugin-list`** — the load-error panel gained an `api-disabled` kind. The 405
405
+ half was never swallowed, so it already reached this panel, but classified as `network`:
406
+ _"check your connection and try again"_ for a condition no retry can change. It now says
407
+ the object is not exposed through the API, that this is a setting on the object rather than
408
+ a permission, and it offers **no Retry** button, because every retry re-fetches the
409
+ identical refusal.
410
+
411
+ Both denials are pure functions of the object's metadata — no user, no permission, no
412
+ context — so neither is transient or per-user, which is exactly the case where a silent empty
413
+ state is most misleading. Discrimination is on the ADR-0112 `code`, never the status: a
414
+ missing collection, a missing record and a disabled object are all 404.
415
+
416
+ **A genuinely empty object still renders the ordinary empty state**, and a backend without an
417
+ optional collection still degrades to empty — pinned in both directions, at the adapter, at
418
+ the view, and once end-to-end over a real adapter and a real `ListView`.
419
+
420
+ Also closes a code-propagation gap on the same path: `find()`'s raw `$expand`/`$search`
421
+ branch bypasses `@objectstack/client` and hand-rolled its own error, stamping only `status`.
422
+ It now carries the ADR-0112 envelope (`code` + `httpStatus`), so a denial arriving on the
423
+ branch a list takes whenever it expands a lookup or runs a search is no longer anonymous.
424
+
425
+ New strings: `list.loadErrorApiDisabledTitle` / `list.loadErrorApiDisabledMessage`, in the
426
+ `en` pack and mirrored in the list defaults map.
427
+
428
+ ## Grading note — why `@object-ui/data-objectstack` is **minor** and not patch
429
+
430
+ Two independent reasons, either of which is sufficient under this repo's precedent
431
+ (objectui#4403 / #4177, and #4485's grading of `@object-ui/core`'s `toDomProps` lift):
432
+
433
+ 1. **The emitted `.d.ts` grows two NEW exports.** `isApiAccessDeniedError(error: unknown):
434
+ boolean` and `API_ACCESS_DENIED_CODES` (the readonly tuple
435
+ `['OBJECT_API_DISABLED', 'OBJECT_API_METHOD_NOT_ALLOWED']`) are added to the package's
436
+ public surface. Additive surface growth is minor.
437
+ 2. **Observable behaviour on a published API moves.** `ObjectStackDataSource.find()` now
438
+ **REJECTS** for the two `enable`-block denial codes where it previously **RESOLVED** with
439
+ `{ data: [], total: 0 }`. No signature changed and nothing was removed, but a caller that
440
+ relied on those two codes arriving as a successful empty result now receives a rejected
441
+ promise carrying `code` + `httpStatus`, and must handle it.
442
+
443
+ Deliberately unchanged, and still resolving to an empty result exactly as before: a bare 404
444
+ with no code, `OBJECT_NOT_FOUND` (still memoised) and `RECORD_NOT_FOUND`. The behaviour move
445
+ is scoped to the two denial codes named above and to nothing else.
446
+
447
+ Not major: this follows AGENTS.md's version-alignment rule — objectui's major tracks
448
+ `@objectstack`'s, so this repo's own breaking semantics are declared as minor with the change
449
+ described in the body, which is what this note is.
450
+
451
+ ### Patch Changes
452
+
453
+ - d9d3463: Retire four zero-consumer declared surfaces (dead-surface sweep batch 3, #4328). Each was
454
+ measured as declared-but-never-read at the branch point, and each is removed rather than
455
+ left as an authoring surface whose values nothing acts on.
456
+
457
+ Breaking for anyone who typed against the removed declarations, marked `minor` per this
458
+ repository's version-alignment convention (the major tracks `@objectstack`, never an
459
+ API-break count):
460
+
461
+ - `@object-ui/core` no longer exports `mergeViewsIntoObjects`. It was a second copy left
462
+ behind by the move of that step to the provider layer, and it had drifted: it ignored a
463
+ view container's default `list` and keyed views by the authored bare key instead of the
464
+ composer's `<object>.<key>` identity. The live implementation — `MetadataProvider`'s, in
465
+ `@object-ui/app-shell` — is unchanged and remains the only one. (#3775)
466
+ - `@object-ui/types`' `RoleDefinition` no longer declares `permissions`. A role's grants
467
+ live in `ObjectPermissionConfig.roles`, keyed by object; that is the only home any
468
+ consumer reads (`resolveRoles` walks `inherits` and matches on `name`). The removed
469
+ field was _required_, so five fixtures across three packages had been declaring an empty
470
+ array for a value nothing would ever look at. Role-attached grants are now a compile
471
+ error rather than silently ignored data. (#4288)
472
+ - `@object-ui/react`'s `RecordContextValue` no longer declares `loading` / `error`. Both
473
+ had zero producers and zero consumers — no host passed them, no `record:*` renderer read
474
+ them — and only the provider's memo dependency list still named them. Record-level
475
+ loading and error state stays where it is actually expressed: each renderer's own data
476
+ source. (#3773)
477
+
478
+ No behaviour change, no request-count change:
479
+
480
+ - `@object-ui/data-objectstack` drops five `metadataCache.invalidate('views:<object>')`
481
+ calls across `updateViewConfig` / `createView` / `updateView` / `deleteView`. No read
482
+ path has ever populated that key — `listViews` fetches directly, uncached — so all five
483
+ were permanent no-ops. The invalidations of the keys that do have readers
484
+ (`view:<object>:<viewId>` for `getView`, `view-overrides:<object>` for
485
+ `listViewOverrides`) are untouched and now pinned. (#3778)
486
+
487
+ - c0f9a4b: Studio surfaces the runtime authoring gate's advisory findings instead of discarding them client-side
488
+
489
+ The framework's runtime authoring gate produces two kinds of verdict on a metadata write. Errors become a 422 and the author sees them. Advisories ride a **200** — the save succeeded, the row persisted, the version bumped — and until objectstack#7435 the server dropped them into a deduped `console.warn` behind a process-level set. That landing put them on the wire as an optional `advisories[]` on the save response, emitted only when non-empty, and objectui was still throwing them away one layer further out: `MetadataClient.save` parsed the body, returned it as an opaque `T`, and every call site awaited it for its side effect and discarded the value.
490
+
491
+ The measured case the fix is built on: a `nightly_purge` flow whose only defect is a `delete_record` node with `multi: true` and no filter yields `errors = 0 / advisories = 1`. The save returns 200, the flow goes live, and nothing anywhere tells the author it deletes every row. That matters most for exactly the authors Studio serves — a Studio tenant or an MCP/AI author has no `os lint` and no CLI config for `sys_metadata` overlay rows, so this gate is not the weakest of four doors, it is the only one.
492
+
493
+ `MetadataClient` now carries an `onSaveAdvisory` sink, invoked after a save whose response carried a non-empty `advisories[]`, and the console wires it in `useMetadataClient` — the one hook every app-shell write path takes its client from, so a single wiring covers `ResourceEditPage`, `StudioDesignSurface`, `EmbeddedItemEditor`, `DatasourceResourcePage`, `ObjectHooksPanel` and any future call site rather than a toast copied into twenty of them. The finding shape is re-exported from `@objectstack/spec` (`RuntimeAuthoringIssue`) rather than restated, so it cannot fork from the 422 `issues[]` it deliberately shares a declaration with.
494
+
495
+ The affordance is the warning tier and says "Saved" first. A successful save that reads as a failure is the specific defect this surface must not ship, so the toast acknowledges the write, lists `rule` + `message` + `hint` per finding with `where` as secondary context, and renders that text **verbatim** — `message` and `hint` are server prose composed by the gate's rules, not i18n keys. Only the frame around them is translated (`console.saveAdvisoryTitle`, ten packs). The sink is best-effort in both directions: a malformed finding is dropped rather than printed as blanks, and a throwing renderer cannot turn a save the server already committed into an error.
496
+
497
+ **What this does not surface yet, and why.** Studio's designer saves as a **draft** on every edit, and drafts are never gated — the framework returns at its D1 early-return (`if (args.state !== 'active') return null`) before running a single rule, so a draft save produces no findings at all rather than producing some that get withheld. The publish step that promotes a draft to active _does_ run the gate, but the publish route returns no `advisories` field until objectstack#7294 lands. So a draft-then-publish flow renders nothing today, at both of its doors, for two different reasons; the active-mode save door renders findings now. That gap is pinned as a test rather than left for a reader to rediscover.
498
+
499
+ - 605b747: The second metadata client class surfaces the runtime authoring gate's advisories instead of discarding them
500
+
501
+ objectui#4133 (PR #4236) put the gate's advisory findings — the ones that ride a **200**, where the save succeeded and the row persisted — in front of Studio authors, but it covered only one of the two client classes that write through `PUT /api/v1/meta/:type/:name`. The wiring lifts at `useMetadataClient`, which is where every app-shell path takes its `MetadataClient` from. `ObjectStackClient.meta.saveItem` — the SDK client hanging off `ObjectStackAdapter` — is a different class reaching the same door, and every one of its callers awaited the call and discarded the response, so an `advisories[]` the server attached was parsed off the wire and dropped one layer further out.
502
+
503
+ Those callers all write in **active** mode, so this is not the draft case where the gate never runs: the gate does run for them, produces findings, and the author was told nothing. The list is `MetadataService` (five saves behind the Object Manager and Field Designer), `useNavigationSync`, plugin-designer's Create/EditAppPage, and the adapter's own `updateViewConfig` / view / `updateDashboard` paths.
504
+
505
+ `ObjectStackAdapter` now carries an `onSaveAdvisory(listener)` subscription and emits on it after a metadata save whose 200 carried a non-empty `advisories[]`; `AdapterProvider` subscribes once and renders through the same `emitSaveAdvisories` the other client class already uses, so both doors produce one wording on the warning tier that says "Saved" first. The emitter is installed **once at the adapter/client seam** rather than at the call sites: every caller above reaches the save door through the adapter's own long-lived `ObjectStackClient`, so one interception covers all of them, plus any future one, without a toast copied into a dozen places — the same reasoning that put #4133's sink at one factory instead of twenty call sites.
506
+
507
+ It is a sibling of the `onWriteWarning` channel (#3431/#3455) rather than a second payload pushed down it, which is what `MetadataSaveAdvisoryEvent` already said it was modelled on. `WriteWarningEvent` is a closed shape whose required `droppedFields` means "fields the write legally stripped", so carrying advisories on it would either force every existing subscriber to grow a branch or make the event lie about what happened. The seam's shape is reused; its event type is not. `readSaveAdvisories` is shared unchanged between the two clients — one reader, two call sites — which the response envelopes make possible: the spec puts `advisories` at the save body's top level, and the SDK returns that body verbatim (it strips its `{ success, data }` envelope only when a `data` key is present, and this body has none). That measurement is pinned by tests that drive a real SDK client through a fake `fetch` rather than stubbing the method under test.
508
+
509
+ - b42558a: Renaming a freshly-created view now persists — `updateView` reads and writes the same row, instead of reading the published overlay and losing the edit into a rejected partial write
510
+
511
+ ADR-0034 stages every runtime-created view as a per-item **draft**: a view made from the `+` tab lives only in the draft row until an explicit Publish, and the UI reads it back through `?preview=draft`. `updateView` addressed neither half of that. Its read went to the published overlay (`client.meta.getItem`, no draft qualifier), which 404s for a draft-only view; a `catch {}` labelled "treat missing as create-equivalent" then substituted `current = {}`, so the read-merge-write cycle merged onto nothing. What went out was the fragment that merge produces — literally `{label, name, object}`, no `viewKind`, no `config` — which the server rejects as an invalid ViewItem (422). Nothing surfaced to the user, and the draft row still held the old label, so the rename simply did not happen. Create, pin and delete were unaffected: they never take this path.
512
+
513
+ The read now probes the draft row first and, on a hit, merges onto that body and writes it straight back with `mode: 'draft'`. Whichever row the read resolved is the row the write updates, so the two halves agree by construction rather than by coincidence. Probing the draft **before** the published overlay is what makes it correct for a view that has both: writing the published row while a draft is pending would put the edit somewhere the draft shadows, and Publish would later overwrite it with the pre-edit body — losing the change a second time, further from the cause. A draft edit stays a draft, preserving ADR-0037's guarantee that nothing the preview shows goes live until Publish. Renaming a published view with no draft pending is unchanged, published read to published write.
514
+
515
+ The silent catch is gone. A view that resolves in neither home now throws naming the view and the object (creating one is `createView`'s job — no caller of `updateView` relied on the create-equivalent behaviour), and a network, permission or server fault on either read propagates instead of degrading into the partial write that corrupted the row. This turns a class of failure that was previously invisible into an error the existing call sites already catch and surface.
516
+
517
+ Set-default and reorder drive the same read-merge-write cycle with `{isDefault}` / `{sortOrder}` patches, so they were emitting the same partial write and are fixed by the same change.
518
+
519
+ - d2f6e6b: Publishing a view from the console no longer serves a five-minute-stale override map — every writer now routes through one invalidation seam
520
+
521
+ `ObjectStackAdapter` caches two view-shaped reads: `getView` under `view:{object}:{name}` and `listViewOverrides` under `view-overrides:{object}`, with `MetadataCache`'s default 5-minute TTL. objectui#4363 made the adapter's own four write paths drop both. But the console's real create-a-view flow never calls any of them: `ObjectView.handleViewCreate` writes through the ADR-0034 metadata seam (`createRuntimeMetadata` → `metadataClient.save`), and Publish goes `RuntimeDraftBar` → `publishRuntimeMetadata` → `metadataClient.publish`. Two writers into the same `/meta/view/:name` rows; only one of them invalidated anything.
522
+
523
+ Publish is the sharp end. A create lands an invisible per-item draft, and `listViewOverrides` enumerates published rows, so the map is still honest there. Publish promotes the row into exactly the world the map describes — and nothing dropped the key, so the object page kept applying its pre-publish snapshot for the rest of the TTL. It does not self-heal: `loadViewOverrides` treats a resolved map as authoritative and deliberately does not re-probe per view (objectui#3774, correct — re-probing reinstates the 404 flurry the batch read exists to remove), so the per-view `getView` fallback that would have masked a stale map is by design unreachable.
524
+
525
+ The fix is one seam rather than a fifth copy of the key list. `ObjectStackAdapter.invalidateViewKeys(objectName, viewName)` is now the only place that knows which keys a view-row write drops; the adapter's four write paths call it instead of restating the pair, app-shell's ADR-0034 persistence module calls it for `view` saves, creates, publishes and discards, and `MetadataService.saveMetadataItem` calls it when the category is `view` (where it previously named `view:{name}`, which no reader has). Restatement is what this repo keeps paying for — objectui#3778 removed five copies of a key no reader populated, objectui#4363 fixed four copies that named half the live set, and objectui#4373 is the measured proof that a new writer forgets the list by default. A pin suite can only guard writers that exist; a seam makes the next one unable to forget.
526
+
527
+ No cache key, no read path and no public signature changed. The adapter's eight existing invalidation pins pass unchanged, which is the evidence that routing four paths through a seam changed nothing observable; two new structural guards keep the key set from being restated again — one asserting each key template appears exactly twice in the adapter (its reader, and the seam), one asserting no app-shell file spells either.
528
+
529
+ - 85a3082: Every view write path now invalidates the override map — a created, renamed or deleted view is no longer shadowed by a five-minute-stale batch read
530
+
531
+ `ObjectStackAdapter` caches two view-shaped reads: `getView` under `view:{object}:{viewId}`, and `listViewOverrides` under `view-overrides:{object}`. Four write paths touch view rows, and until now exactly one of them — `updateViewConfig` — invalidated the second key. `createView`, `updateView` and `deleteView` invalidated only the per-view key, so the batch override map kept answering from a snapshot taken up to `MetadataCache`'s default 5-minute TTL earlier.
532
+
533
+ That gap does not heal itself. `loadViewOverrides` in app-shell's `ObjectView` treats a resolved map as authoritative and deliberately does not re-probe per view — that is objectui#3774's fix, and it is correct, since re-probing reinstates the 404 flurry the batch read exists to remove. So the per-view `getView` fallback that would have masked a stale map is by design unreachable, and the stale map is served in full. Meanwhile `listViews` is uncached and answers fresh, so the view switcher could list a view whose override body came from a map written minutes earlier: the sharpest shape is the rename/pin path (`updateView`), where a user edits a view, returns to the object, and is served the pre-edit override.
534
+
535
+ All four paths now emit the same ordered pair — the per-view key, then the object's override map. The rule is uniform per method rather than per branch: `updateView`'s draft half invalidates both keys as its published half does, which is deliberate over-invalidation (both readers enumerate published rows, so a draft write stales neither) chosen because an unnecessary invalidation costs one refetch while a missed one costs the full TTL. `createView` names the per-view key too, because `saveItem` is an upsert and an explicit `spec.name` that already exists overwrites a published row a prior `getView` may hold.
536
+
537
+ No signature, no cache key and no read path changed; the only difference is which keys each write drops. The pin suite added by objectui#4328 now asserts the full invalidation key set for all five call sites, with the sweep's two pins kept as untouched controls: `listViews` stays uncached, and no write path names the retired `views:{object}` key.
538
+
539
+ - Updated dependencies [ee66e2e]
540
+ - Updated dependencies [ee26e65]
541
+ - Updated dependencies [5900ac5]
542
+ - Updated dependencies [f650253]
543
+ - Updated dependencies [3d9769a]
544
+ - Updated dependencies [3fc2971]
545
+ - Updated dependencies [aca27fa]
546
+ - Updated dependencies [dde7283]
547
+ - Updated dependencies [92876f0]
548
+ - Updated dependencies [f279deb]
549
+ - Updated dependencies [eb7f586]
550
+ - Updated dependencies [e901131]
551
+ - Updated dependencies [d9d3463]
552
+ - Updated dependencies [2a40f69]
553
+ - Updated dependencies [bec3e14]
554
+ - Updated dependencies [613b167]
555
+ - Updated dependencies [1f9b905]
556
+ - Updated dependencies [abb0f81]
557
+ - Updated dependencies [38ab505]
558
+ - Updated dependencies [7e4f0e5]
559
+ - Updated dependencies [92250d6]
560
+ - Updated dependencies [c1d939f]
561
+ - Updated dependencies [49ae9f4]
562
+ - Updated dependencies [2459a3e]
563
+ - Updated dependencies [d6aa172]
564
+ - Updated dependencies [fe52a04]
565
+ - Updated dependencies [bb68488]
566
+ - Updated dependencies [9461dd3]
567
+ - Updated dependencies [ab04728]
568
+ - @object-ui/core@17.5.0
569
+ - @object-ui/types@17.5.0
570
+
571
+ ## 17.4.0
572
+
573
+ ### Minor Changes
574
+
575
+ - 48132f7: Track the `@objectstack` family at `17.0.0-rc.5` (objectui#3560).
576
+
577
+ The pin moves from `^17.0.0-rc.2` to `^17.0.0-rc.5` across all 37 declarations in
578
+ 30 `package.json` files, and the sibling `@objectstack/*` packages (`client` /
579
+ `formula` / `lint`) move with it — they pin `@objectstack/spec` **exactly**, so
580
+ leaving them behind would keep a second copy of the spec in the tree and have
581
+ `@objectstack/lint` validating against schemas that still accept the keys rc.3–rc.5
582
+ retire. `pnpm-lock.yaml` now resolves one copy of each of the six family packages
583
+ (`spec` / `client` / `core` / `formula` / `lint` / `sdui-parser`), all at rc.5.
584
+
585
+ Bumping the pin and repairing the fallout cannot be split: the pin alone reddens
586
+ CI, and the code alone targets a shape that is not in effect yet.
587
+
588
+ ## A live bug this upgrade fixes
589
+
590
+ **`ObjectStackDataSource.delete()` never emitted its mutation event, and resolved
591
+ `undefined` instead of a boolean.** `@objectstack/client`'s `DeleteDataResult`
592
+ declared a key called `deleted` — a key no schema has ever declared and no server
593
+ path has ever returned on `DELETE /data/:object/:id`. So `result.deleted`
594
+ compiled and read `undefined` at runtime: the guard never fired, a successful
595
+ delete notified no subscriber, and every consumer's cache stayed stale.
596
+ objectstack#5638 corrected the interface to the schema's `success`; following the
597
+ rename is what restores both behaviours. Nothing in this repo had to change shape
598
+ for it — the code was already asking the right question of the wrong key.
599
+
600
+ ## Breaking, in FROM → TO form
601
+
602
+ - **The five `@objectstack/spec/ui` interaction-config modules are gone** —
603
+ touch / dnd / keyboard / animation / offline, 32 defs and 64 exports
604
+ (objectstack#4988, PR objectstack#5321). None of them had an authoring door: no
605
+ metadata document could ever carry one of these blocks, so a stack that parsed
606
+ before the retirement parses byte-for-byte the same after it. `@object-ui/types`
607
+ drops the 32 `export type` re-exports. The vocabulary each one's only real
608
+ consumer needs is now declared by that consumer, which is the remedy the spec's
609
+ own retirement ledger prescribes ("declare that union locally — it is your
610
+ client's policy, not the platform's"):
611
+
612
+ - `@object-ui/react`'s `useOffline` owns `OfflineStrategy`, `ConflictResolution`,
613
+ `PersistStorageType`, `EvictionPolicyType`, `OfflineConfig`,
614
+ `OfflineCacheConfig`, `OfflineSyncConfig`;
615
+ - `@object-ui/core`'s `DndProtocol` / `KeyboardProtocol` own `DndConfig`,
616
+ `DragItem`, `DropZone`, `DragConstraint`, `DragHandle`, `DropEffect`,
617
+ `KeyboardNavigationConfig`, `KeyboardShortcut`, `FocusManagement`,
618
+ `FocusTrapConfig`;
619
+ - `@object-ui/types`' `mobile` module owns `SpecGestureConfig`,
620
+ `SwipeGestureConfig`, `PinchGestureConfig`, `LongPressGestureConfig`,
621
+ `TouchTargetConfig`, `TouchInteraction` (plus a new `SPEC_GESTURE_TYPES`
622
+ runtime tuple), so `@object-ui/mobile`'s import paths are unchanged.
623
+
624
+ Every shape is moved verbatim — same keys, same members, same optionality — so
625
+ no hook or bridge changes behaviour. Consumers importing these names from
626
+ `@object-ui/types` must import them from the owning package instead. Note the
627
+ spec's _surviving_ `ConnectorConflictResolution` (`/integration`, connector sync)
628
+ and `ConflictResolutionStrategy` (`/api`, route merge policy) are **different
629
+ concepts** — do not re-point at them.
630
+
631
+ - **`@object-ui/types` no longer re-exports `NotificationAction` or `EmbedConfig`**
632
+ (objectstack#5015, PR objectstack#5300). Both were published `ui` vocabulary with
633
+ no authoring door; no notification action was ever parsed from metadata and no
634
+ iframe route ever read an embed config. The presentation enums
635
+ (`NotificationType` / `NotificationSeverity` / `NotificationPosition`) and
636
+ `SharingConfig` **survive** and are untouched — public form sharing still gates
637
+ the anonymous endpoints on `allowAnonymous` + `publicLink`.
638
+ `@object-ui/core`'s `SharingProtocol` keeps `resolveEmbedConfig` /
639
+ `generateEmbedCode` against a locally declared `EmbedConfig`, so its surface is
640
+ unchanged.
641
+ - **`ThemeEngine` stops emitting nine retired CSS variable groups**
642
+ (objectstack#5021 option 2, PR objectstack#5289). `theme.animation`,
643
+ `theme.zIndex` and five typography groups (`fontSize` / `fontWeight` /
644
+ `lineHeight` / `letterSpacing`, plus `fontFamily.heading` / `fontFamily.mono`)
645
+ are tombstones the schema now rejects by name, so `--duration-*`, `--timing-*`,
646
+ `--z-*`, `--font-size-*`, `--font-weight-*`, `--line-height-*`,
647
+ `--letter-spacing-*`, `--font-heading` and `--font-mono` had become structurally
648
+ dead code — no author could produce the input that reached them.
649
+ `generateAnimationVars` and `generateZIndexVars` are removed from
650
+ `@object-ui/core`, and `@object-ui/types` drops `Animation` / `ZIndex` /
651
+ `AnimationSchema` / `ZIndexSchema`. **`theme.customVars` is the declared — and
652
+ since #5021 the only — door**: each entry is emitted verbatim as
653
+ `--<key>: <value>`, so a `--z-modal` or a `--duration-fast` goes there now.
654
+ LIVE emission is untouched byte for byte: `colors`, `borderRadius`, `shadows`,
655
+ `typography.fontFamily.base` (→ `--font-sans`) and `customVars`.
656
+ - **`@object-ui/types`' `HttpMethodSchema` now binds the spec's
657
+ `HttpMethodSubsetSchema`, and `HttpMethod` binds `HttpMethodSubset`**
658
+ (objectstack#5832, PR objectstack#5976 — objectui#3499). The spec renamed its
659
+ 5-value UI subset because `schemaNameFromExportKey` strips the `Schema` suffix,
660
+ so the 5-value and 7-value enums both published as `shared/HttpMethod` and the
661
+ later write won — the emitted JSON Schema and reference page described only one
662
+ of them. **The runtime domain is unchanged and this repo's exported names are
663
+ unchanged**; this follows the rename without touching cross-package semantics.
664
+ Deliberately NOT re-pointed at the spec's bare `HttpMethod`: that is the 7-value
665
+ enum, and widening to it would let `method: 'HEAD'` compile and then throw in
666
+ `HttpRequestSchema.parse()`.
667
+ - **`dashboard.widgets[].actionUrl` / `actionType` / `actionIcon` / `aria` are
668
+ refused, not stripped** (objectstack#5010, ADR-0049 enforce-or-remove). A
669
+ dashboard widget has no action button and never had one — every action the
670
+ dashboard dispatches comes from `header.actions[]` — and no renderer ever applied
671
+ the widget `aria`, so it promised accessibility compliance it did not deliver.
672
+ A stale dashboard now gets a named error telling it where the affordance moved,
673
+ instead of silently losing it. Run `os migrate meta --from 16` to rewrite.
674
+
675
+ ### Patch Changes
676
+
677
+ - 3765678: data-objectstack: pass the server's `drillRanges` date-bucket drill scope through `queryDataset` (restores date drill-through)
678
+
679
+ `queryDataset` rebuilds its result by **hand-picking** keys off the REST payload,
680
+ and `drillRanges` was never in the list — so the analytics service's date-range
681
+ drill sidecar (framework#1752) was dropped by the only real adapter in this repo,
682
+ while five consumer call sites were already reading it (`DatasetWidget.tsx:471`
683
+ and `:593`, `DatasetReportRenderer.tsx:316`, `:431`, `:855`).
684
+
685
+ The user-visible effect was not a degraded drill but a missing one. A
686
+ `dateGranularity` dimension groups a **span** of records into one bucket, which
687
+ equality filters cannot express, so `service-analytics` deliberately excludes
688
+ date dimensions from `dimensionFields`/`drillRawRows` and sends a parallel
689
+ half-open `[gte, lt)` range per row instead. For a chart or report grouped **only
690
+ by time** that makes `drillRanges` the _only_ thing that can make
691
+ `canDrill = !!object && (drillDims.length > 0 || !!drillRanges?.length)` true —
692
+ with the key dropped, the entire drill entry point disappeared. A mixed
693
+ date + non-date grouping kept its drill but built a filter with no time bound, so
694
+ clicking June's bar opened every month (a superset).
695
+
696
+ Neither side's tests could see it: the dashboard and report tests mock their own
697
+ data source and feed `drillRanges` in directly, and the adapter's own suite never
698
+ asserted the key. The new adapter-level tests therefore mock the **envelope the
699
+ server actually sends** — bare (`res.json(result)`, no `{ success, data }`
700
+ wrapper), carrying `sql`, and for a date-only grouping carrying `object` +
701
+ `drillRanges` and _no_ `dimensionFields`/`drillRawRows` — then assert the key
702
+ arrives verbatim and row-aligned, that the consumers' own `canDrill` predicate is
703
+ true, and that `buildDatasetDrillFilter` (the shared builder both surfaces call)
704
+ scopes the drilled list to the clicked bucket.
705
+
706
+ The declared entry type is `@object-ui/core`'s `DatasetDrillRange` **by
707
+ reference**, per the objectui#3613/#3752 discipline: it is the single in-repo
708
+ declaration of this shape (what the filter builder accepts and what both
709
+ renderers type their state with), and nothing in `@objectstack/spec` owns it yet,
710
+ so restating `{ field, gte, lt }` locally would create a third dialect of it.
711
+
712
+ `drillRawTotals` (the totals-row companion, framework#3214) is deliberately
713
+ **not** added: it has zero consumers in this repo, so passing it through would
714
+ add a declared-but-unexercised return key with no user-facing effect — it belongs
715
+ in the change that lands a totals-row drill and can test it.
716
+
717
+ - d83f6b3: data-objectstack: type `queryDataset`'s result `fields[]` as the spec's `AnalyticsResult.fields[]` element instead of a hand-written copy
718
+
719
+ The return-value half of the drift objectui#3613 fixed on the parameter side. The
720
+ adapter hand-listed five keys for a result column
721
+ (`name`/`type`/`label`/`format`/`currency`) and, like every restatement, stopped
722
+ at the contract of the day it was written: it never grew **`percentScale`**,
723
+ which `@objectstack/spec@17.0.0-rc.5` carries on
724
+ `AnalyticsResult.fields[]` and documents as mandatory reading for renderers —
725
+ "renderers that receive it must scale by it instead of guessing from the value"
726
+ (objectui#3136).
727
+
728
+ That omission was not cosmetic. `percentScale` is the server's answer to a
729
+ question a `%` format string cannot express (is the stored number a 0–1 fraction,
730
+ or already percentage points?), and objectui#3136 exists because guessing from
731
+ the value's magnitude printed a ratio of exactly `1` as "1.0%". Three in-repo
732
+ consumers read the field through their own local types
733
+ (`DatasetResultField` in `@object-ui/core`), so nothing was red here — but any
734
+ author reading columns through the adapter's **declared** return type got
735
+ `Property 'percentScale' does not exist`, i.e. the declaration actively steered
736
+ them back to the guess the spec bans.
737
+
738
+ `fields` is now the spec type by reference, so there is nothing left to re-sync;
739
+ the change is additive for existing consumers (one more optional key).
740
+ `queryDataset.test.ts` pins structural identity with the spec element, pins
741
+ `percentScale` as the `'fraction' | 'whole'` union rather than a widened
742
+ `string`, keeps a negative pin against the five-key shape, and adds a runtime
743
+ test that reads `percentScale` off a result column **through the declared type**.
744
+
745
+ The rest of the envelope stays locally declared, deliberately. It is the REST
746
+ envelope, not an `AnalyticsResult`: the route adds ADR-0021 D2 drill metadata
747
+ (`object` / `dimensionFields` / `drillRawRows`) on top of the spec result, and
748
+ this method rebuilds its own object from the payload without copying `sql` — so
749
+ declaring the envelope as `AnalyticsResult & { … }` would advertise a key the
750
+ adapter structurally cannot return. A pin records that too.
751
+
752
+ - 5f08c05: data-objectstack: type `queryDataset(selection)` as the spec's `DatasetSelection` instead of a hand-written copy
753
+
754
+ The adapter restated the selection contract inline, field by field, and the copy
755
+ had drifted three ways from the pinned `@objectstack/spec@17.0.0-rc.5`:
756
+
757
+ - **`compareTo.dimension` was required.** It has been optional since
758
+ objectstack#5011, _because the executor resolves it_: exactly one time
759
+ dimension carrying a `dateRange` is the one shifted, and zero or several
760
+ raises a loud error naming the candidates. Requiring it made the compiler
761
+ demand from every typed caller precisely the consumer-side dimension guess
762
+ that change forbids — trading a loud executor error for a silently wrong
763
+ comparison window. No runtime path hit this yet (the dashboard's
764
+ `DatasetWidget` passes `selection` as `unknown`), but a declaration is a live
765
+ instruction to anyone calling this client from TypeScript.
766
+ - **`timeDimensions` was widened to `unknown[]`**, erasing the very entry shape
767
+ the executor's resolution reads (`{ dimension, granularity?, dateRange? }`),
768
+ and **`runtimeFilter` to `Record<string, unknown>`**, erasing the
769
+ `$and`/`$or`/`$not` vocabulary the server parses.
770
+ - **`dateGranularity` was missing entirely** — the copy had simply stopped at
771
+ whatever the contract looked like the day it was written, so a typed caller
772
+ could not bucket a trend by month at all.
773
+
774
+ The parameter is now the spec type by reference, so there is nothing left to
775
+ re-sync. The fix is the removal of the dialect rather than a correction to it:
776
+ restating a contract owned elsewhere creates a second de-facto dialect of it, and
777
+ drift is then only a matter of time (AGENTS.md #0/#0.1). `queryDataset.test.ts`
778
+ pins structural identity with `DatasetSelection` plus each of the three drifts
779
+ individually, checked by this package's `tsc --noEmit`; a runtime test pins that
780
+ a dimension-less `compareTo` reaches the server untouched, so the adapter can
781
+ never start guessing on the executor's behalf.
782
+
783
+ The response type is deliberately left alone — it is the REST envelope
784
+ (`object` / `dimensionFields` / `drillRawRows`), not a restatement of
785
+ `AnalyticsResult`.
786
+
787
+ - 41d6022: The console no longer reads `/meta/*` before it knows whether it has a session, and a failed request now says which request failed
788
+
789
+ Opening a logged-out console painted ~30 red `HTTP request failed` lines before
790
+ the login form was drawn. Two independent causes, fixed independently
791
+ (objectui#4042).
792
+
793
+ **1. Requests fired before the session was known.** `ConnectedShellInner` now
794
+ withholds the metadata tree until `GET /auth/get-session` resolves, so
795
+ `meta/object` / `meta/view` / `meta/app` are never issued blind. `useAuth()`
796
+ outside an `AuthProvider` reports `isLoading: false`, so an embed with no auth
797
+ provider is unaffected, and every protected route already sat behind an
798
+ `AuthGuard` that resolves auth first — the signed-in data flow is unchanged.
799
+
800
+ The console's landing route (`<Route path="/">`) was the actual entry point for
801
+ the burst: it mounted `ConnectedShell` with no guard above it, so simply opening
802
+ `/_console/` mounted the whole data layer as an anonymous visitor. It is now
803
+ guarded, which also means an unauthenticated visitor reaches `/login` without a
804
+ single doomed request. `examples/console-starter` had the same shape and got the
805
+ same fix.
806
+
807
+ **2. Two requests per type, per mount — not an unauthenticated artefact.**
808
+ Consumers read metadata during the FIRST render (`useActionModal` reads
809
+ `objects`, whose getter kicks `ensureType('object')` and `ensureType('view')`
810
+ from the render phase), before any effect runs. `MetadataProvider`'s preview-mode
811
+ effect then cleared the whole cache on mount, discarding those two entries while
812
+ their requests were in flight; the next render found them `idle` and refetched
813
+ both. The effect now skips its mount run — on mount the cache is empty and there
814
+ was never anything to drop; it only ever meant something on a later
815
+ `previewDrafts` change. That halved `meta/object` and `meta/view` on **every**
816
+ mount, signed in included.
817
+
818
+ A second duplicate only appeared once a read had failed: `entry.promise`
819
+ collapses callers that arrive while a request is in flight, but callers arriving
820
+ just after a failure each started a fresh attempt. A failed type now stays
821
+ un-retried for ~1s, which collapses one mount's burst of callers into a single
822
+ attempt. This is deliberately not the 5-minute `ttlMs` — later callers still
823
+ retry on their own, and `refresh()` / `invalidate()` retry immediately and
824
+ unconditionally, so no explicit recovery path changes.
825
+
826
+ **3. `HTTP request failed` now identifies the request.** `@objectstack/client`
827
+ reports every non-2xx as
828
+ `logger.error("HTTP request failed", undefined, { method, url, status, error })`,
829
+ and the console's logger forwarded that verbatim — so the identifying fields
830
+ lived only in the third argument, and anything that flattens a console record to
831
+ text rendered them `[object Object]` / `Object`. A screenful of failures could
832
+ not tell you a single URL or status. The message string now carries them:
833
+
834
+ ```text
835
+ HTTP request failed: GET /api/v1/meta/object -> 401 [UNAUTHORIZED]
836
+ ```
837
+
838
+ The structured bag is still passed alongside for DevTools to expand — text for
839
+ the flatteners, object for the inspectors, neither at the other's expense. The
840
+ formatter is exported as `formatHttpFailureMessage`, and `createQuietHttpLogger`
841
+ is now exported too so an app wiring its own `ObjectStackClient` gets the same
842
+ identified failures.
843
+
844
+ Nothing is newly silenced. The only demotion remains 404-on-an-optional-
845
+ collection (`sys_presence`, `sys_activity`), which is an expected outcome of a
846
+ request we still mean to make; a 401 that survives the session gate — a
847
+ mid-session expiry, say — stays a visible, fully-identified error. The cure for
848
+ doomed requests is not issuing them, never hiding them once issued.
849
+
850
+ - 7e2b7e9: Fix saved list-view preferences never reading back (density, column widths, sort, hidden columns, inline edit)
851
+
852
+ `listViewOverrides` in the ObjectStack adapter enumerated `GET /api/v1/meta/{objectName}` — putting the object name in the metadata **type** slot — while `updateViewConfig` persists under `type='view'`. The two key spaces are disjoint, so the batch map came back empty for every object and every personalization a user saved on a list view was written to the server but never read back, showing up as "the setting didn't save".
853
+
854
+ The read now enumerates `type='view'` once and narrows to the object client-side, through the same accessor `listViews()` uses over the same rows — the metadata index is name-only, so there is no server-side `?object=` filter to push it into.
855
+
856
+ Second half: the batch read no longer swallows its own failures into an empty map. An empty map is an authoritative "this object has no overrides" and callers may still trust it and skip the per-view reads (the batch optimization is intact), but a transport failure now rejects, so the per-view `getView` fallback it was silently disabling becomes reachable again. `DataSource.listViewOverrides` documents both terms so other adapters implement the same contract.
857
+
858
+ - Updated dependencies [6719877]
859
+ - Updated dependencies [56ff091]
860
+ - Updated dependencies [d229dfa]
861
+ - Updated dependencies [4bc6c23]
862
+ - Updated dependencies [c3b01a7]
863
+ - Updated dependencies [e06810e]
864
+ - Updated dependencies [ab3ad4f]
865
+ - Updated dependencies [c2fd122]
866
+ - Updated dependencies [48132f7]
867
+ - Updated dependencies [1d723e3]
868
+ - Updated dependencies [0109f54]
869
+ - Updated dependencies [7e5bb5d]
870
+ - Updated dependencies [fbc23e0]
871
+ - Updated dependencies [e6fdbdc]
872
+ - Updated dependencies [6bb454a]
873
+ - Updated dependencies [523be48]
874
+ - Updated dependencies [7e2b7e9]
875
+ - Updated dependencies [c1e1e6b]
876
+ - @object-ui/core@17.4.0
877
+ - @object-ui/types@17.4.0
878
+
3
879
  ## 17.3.0
4
880
 
5
881
  ### Minor Changes
@@ -1022,6 +1898,7 @@ ${order}"` is exactly the shape `ObjectGrid` builds from its view metadata's
1022
1898
  objects+fields were editable in Studio; this reworks both surfaces.
1023
1899
 
1024
1900
  **Setup (assign + read-only):**
1901
+
1025
1902
  - The six facets (`object_permissions`, `field_permissions`, `system_permissions`,
1026
1903
  `row_level_security`, `tab_permissions`, `admin_scope`) now render read-only on
1027
1904
  the `sys_permission_set` record page as a compact summary (counts, or capability
@@ -1036,6 +1913,7 @@ ${order}"` is exactly the shape `ObjectGrid` builds from its view metadata's
1036
1913
 
1037
1914
  **Studio (design every facet):** the permission matrix editor gains structured
1038
1915
  editors for the facets that were JSON-only —
1916
+
1039
1917
  - **System Capabilities**: a multi-select over the live `sys_capability` registry
1040
1918
  (scope-grouped, labelled chips).
1041
1919
  - **Row-Level Security**: per-policy rows (object · operation · enabled) with CEL
@@ -1052,6 +1930,7 @@ ${order}"` is exactly the shape `ObjectGrid` builds from its view metadata's
1052
1930
  record the Setup summary reads, so a fresh Studio edit isn’t reflected in Setup’s
1053
1931
  read-only view until the projection refreshes — tracked as a framework follow-up
1054
1932
  (enforcement reads the authoritative metadata).
1933
+
1055
1934
  - @object-ui/types@13.2.0
1056
1935
  - @object-ui/core@13.2.0
1057
1936
 
@@ -1359,6 +2238,7 @@ ${order}"` is exactly the shape `ObjectGrid` builds from its view metadata's
1359
2238
 
1360
2239
  **`@object-ui/plugin-designer`** — two new route-ready pages that
1361
2240
  together close the "Data Model" management loop in the Setup app:
2241
+
1362
2242
  - `MetadataObjectsPage` — lists every object schema (via
1363
2243
  `MetadataClient.list('object')`), renders the existing
1364
2244
  `ObjectManager`, and persists edits/deletes through PUT/DELETE on
@@ -1389,6 +2269,7 @@ ${order}"` is exactly the shape `ObjectGrid` builds from its view metadata's
1389
2269
  the Setup app → _All Metadata Types_.
1390
2270
 
1391
2271
  ### New: `@object-ui/app-shell` views/metadata-admin
2272
+
1392
2273
  - **`MetadataDirectoryPage`** — auto-grouped tile directory by domain, with
1393
2274
  free-text search, domain chips, and a _Writable only_ filter.
1394
2275
  - **`MetadataResourceListPage` / `MetadataResourceEditPage` / `…CreatePage` / `…HistoryPage`** —
@@ -1412,6 +2293,7 @@ ${order}"` is exactly the shape `ObjectGrid` builds from its view metadata's
1412
2293
  `t(key)` helper.
1413
2294
 
1414
2295
  ### New routing variant
2296
+
1415
2297
  - App nav now supports `{ type: 'component', componentRef, params? }` items.
1416
2298
  `AppContent` resolves them through the existing `ComponentRegistry`.
1417
2299
  - Built-in components registered: `metadata:directory`, `metadata:resource`,
@@ -1420,12 +2302,14 @@ ${order}"` is exactly the shape `ObjectGrid` builds from its view metadata's
1420
2302
  / page.
1421
2303
 
1422
2304
  ### Plugin-designer
2305
+
1423
2306
  - Lazy-exported `ObjectManager`, `FieldDesigner`, `ObjectViewConfigurator`,
1424
2307
  `DashboardEditor`, `PageCanvasEditor`, `MetadataObjectsPage`, and
1425
2308
  `MetadataFieldsPage` so the engine can mount them on demand.
1426
2309
 
1427
2310
  The temporary `/dev/meta` route is removed. Setup app navigation flows
1428
2311
  through the new component routes.
2312
+
1429
2313
  - @object-ui/types@6.2.0
1430
2314
  - @object-ui/core@6.2.0
1431
2315
 
@@ -1533,6 +2417,7 @@ ${order}"` is exactly the shape `ObjectGrid` builds from its view metadata's
1533
2417
 
1534
2418
  Extract the bulk-vs-per-row decision into a reusable
1535
2419
  `executeBulkBatch(input, ops)` helper in `@object-ui/core`:
2420
+
1536
2421
  - Single decision tree shared by both update and delete fast paths.
1537
2422
  - Bulk success → no per-row pass.
1538
2423
  - Bulk partial-count → aggregate batch error.
@@ -1576,6 +2461,7 @@ ${order}"` is exactly the shape `ObjectGrid` builds from its view metadata's
1576
2461
  multi-user editing scenarios.
1577
2462
 
1578
2463
  **`@object-ui/data-objectstack`**
2464
+
1579
2465
  - Exports `ConcurrentUpdateError` (carries `currentVersion` and
1580
2466
  `currentRecord`) and `isConcurrentUpdateError()` type guard.
1581
2467
  - `update()` / `delete()` accept `opts.ifMatch` and forward it via the
@@ -1588,6 +2474,7 @@ ${order}"` is exactly the shape `ObjectGrid` builds from its view metadata's
1588
2474
  from conflicts without parsing the wire format.
1589
2475
 
1590
2476
  **`@object-ui/core`**
2477
+
1591
2478
  - `ApiDataSource.update()` and `.delete()` accept `opts.ifMatch` and emit
1592
2479
  the `If-Match` HTTP header.
1593
2480
 
@@ -1630,6 +2517,7 @@ ${order}"` is exactly the shape `ObjectGrid` builds from its view metadata's
1630
2517
  their settings.
1631
2518
 
1632
2519
  The adapter now defaults to:
2520
+
1633
2521
  - `resource`: `sys_user_preference`
1634
2522
  - field shape: `(user_id, key, value)` instead of `(user_id, kind, payload)`
1635
2523
  - option name: **`key`** instead of `kind`