@object-ui/data-objectstack 17.3.0 → 17.5.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,662 @@
1
1
  # @object-ui/data-objectstack
2
2
 
3
+ ## 17.5.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 932cbcd: An app you are not allowed to open now says so, instead of reporting that it may still be publishing
8
+
9
+ `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.
10
+
11
+ 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.
12
+
13
+ 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).
14
+
15
+ 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.
16
+
17
+ 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.
18
+
19
+ `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.
20
+
21
+ - 537a0d1: `deleteView` removes every home the view has — deleting a draft-only saved view no longer silently no-ops
22
+
23
+ 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.
24
+
25
+ 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.
26
+
27
+ **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_.
28
+
29
+ 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.
30
+
31
+ **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.
32
+
33
+ **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`.
34
+
35
+ 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`.
36
+
37
+ 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.
38
+
39
+ 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.
40
+
41
+ - bec3e14: The `DataSource` contract carries `deleteView`'s per-home outcomes (#4564)
42
+
43
+ #4479 / PR #4562 widened the ObjectStack adapter's `deleteView` to return
44
+ `DeleteViewResult { deleted, draft?, published? }`, so a caller could finally tell a
45
+ partial delete ("draft gone, published overlay left") from a complete one. The shared
46
+ interface did not follow: `DataSource.deleteView?` still declared the narrow
47
+ `Promise<{ deleted: boolean }>`.
48
+
49
+ Nothing failed to compile, and that is exactly what made the gap invisible — a wider
50
+ return is assignable to a narrower declaration, so the adapter satisfied the interface
51
+ while every consumer reaching it **through** `DataSource` was handed a type with the
52
+ per-home outcomes already discarded. The one real call site today (app-shell's
53
+ `ObjectView` delete handler) awaits the call and reads nothing off the receipt, so the
54
+ loss was latent rather than broken.
55
+
56
+ `DeleteViewResult` and `ViewHomeDeleteOutcome` now live in `@object-ui/types`, beside
57
+ the `DataSource` interface that returns them, and `deleteView?`'s declared return is
58
+ `Promise<DeleteViewResult>`. The direction was forced: the dependency runs
59
+ `@object-ui/data-objectstack` to `@object-ui/types` and never the other way, so the
60
+ shapes could not be imported downward — moving them was the alternative to re-declaring
61
+ a structural twin in `types`, which the one-resolver rule rejects because a copy is
62
+ mutually assignable with the original for exactly as long as it takes to drift.
63
+
64
+ `@object-ui/data-objectstack` re-exports both names unchanged, so every importer PR
65
+ #4562 left pointing at it keeps compiling — and now resolves to the same declaration the
66
+ shared contract speaks rather than a look-alike. A repo-wide census before the move
67
+ found zero importers of either name outside the declaring file itself, PR #4562's own
68
+ suite included, so the re-export is insurance rather than a load-bearing shim.
69
+
70
+ `deleteView` stays **optional** on the interface and keeps both parameters; the growth is
71
+ to the return type only, and `deleted` is untouched, so a consumer reading only `deleted`
72
+ needs no edit.
73
+
74
+ Grading, per this repository's version-alignment convention (the major tracks
75
+ `@objectstack`, never an API-break count):
76
+
77
+ - `@object-ui/types` — **minor**: entry-reachable growth. Two new exported interfaces
78
+ plus a widened method return on `DataSource`, all reachable from the package entry.
79
+ - `@object-ui/data-objectstack` — **minor**, measured rather than assumed. Its emitted
80
+ `dist/index.d.ts` is **not** byte-identical after the swap: the two `interface` blocks
81
+ leave the file and are replaced by a re-export from `@object-ui/types` (121.61 KB to
82
+ 120.25 KB). Both names remain in the public export list, so no importer breaks, but the
83
+ declaration genuinely moved and the emitted types now depend on `@object-ui/types` for
84
+ it — that is a minor, not a patch.
85
+
86
+ - 479cc7b: `MetadataClient.get()` returns the item body its docblock always promised — the field half of the permission matrix is alive again
87
+
88
+ `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`.
89
+
90
+ 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.
91
+
92
+ 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.
93
+
94
+ 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`.
95
+
96
+ `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".
97
+
98
+ 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.
99
+
100
+ - 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
101
+
102
+ `src/cloud.ts` exported a `CloudOperations` class with four methods, all
103
+ re-exported from the package entry, so this was published surface of
104
+ `@object-ui/data-objectstack`. Every method optional-chained into
105
+ `client.cloud?.…`, and no released `@objectstack/client` has ever exported a
106
+ `cloud` namespace. Re-measured at `17.0.0-rc.6` before deleting: the module's
107
+ export list is `ObjectStackClient`, `ScopedProjectClient`, `RealtimeAPI`,
108
+ `QueryBuilder`, `FilterBuilder`, `createQuery`, `createFilter`, and a
109
+ constructed client's `.cloud` is `undefined`. The nearest real namespaces on the
110
+ instance — `projects` (which owns `/api/v1/cloud/environments`) and `packages`
111
+ (which owns marketplace installs) — are not what these methods reached for.
112
+
113
+ So every call resolved `undefined` and fell through to a literal:
114
+
115
+ | method | what it returned, always |
116
+ | :-------------------- | :------------------------------------------------------------ |
117
+ | `deploy` | `{ deploymentId: 'deploy-' + Date.now(), status: 'pending' }` |
118
+ | `getDeploymentStatus` | `{ status: 'unknown' }` |
119
+ | `searchMarketplace` | `[]` |
120
+ | `installPlugin` | `{ success: false }` |
121
+
122
+ The maintainer's 2026-08-11 ruling removed it rather than repairing it, and named
123
+ the reason: `deploy()` did not degrade to an error, it **manufactured a
124
+ plausible success**. A caller got a well-formed `deploymentId` for an operation
125
+ that never left the process and then polled it forever against
126
+ `{ status: 'unknown' }`. That is the most dangerous shape for an AI consumer,
127
+ which builds downstream logic on the fake id instead of getting suspicious.
128
+ Under the startup-focus principle a declared capability with no producer, no
129
+ consumer and no business pull is retired, not stubbed.
130
+
131
+ **Breaking, in FROM → TO form.** `CloudOperations`, `CloudDeploymentConfig`,
132
+ `CloudHostingConfig` and `CloudMarketplaceEntry` are no longer exported from
133
+ `@object-ui/data-objectstack`. It is a `minor` under this repo's version policy
134
+ (objectui's own breaking changes never declare `major`). Nothing broke that was
135
+ working: the only in-repo construction site was a test, and every method's
136
+ observable behaviour was a fabricated constant.
137
+
138
+ **No compile-compat stub was left.** The ruling allows one — throwing loud
139
+ `NotImplemented` — only where a compile need is demonstrated. Measured across the
140
+ whole repository, the sole importers were the package's own `index.ts`,
141
+ `v3-compat.test.ts` (three cases asserting the fallback had the right _keys_,
142
+ which is how the emptiness stayed green) and objectui#3720's vocabulary pin. No
143
+ app, no other package, no doc. With no consumer to keep compiling, a stub would
144
+ be a second phantom surface guarding the first.
145
+
146
+ The false module header went with it — it read `Cloud namespace integration for
147
+ @objectstack/spec v3.0.0 / Replaces the legacy Hub namespace`, against a resolved
148
+ spec of `17.0.0-rc.6` and schemas this package never consumed.
149
+
150
+ **objectui#3720's pin retires with its subject.** `cloud-environment-vocabulary.pin.test.ts`
151
+ pinned the doc comment on `CloudDeploymentConfig.environment` — the deliberate
152
+ three-member deploy-target vocabulary and the `staging`-is-not-a-discovery-member
153
+ trap. Every fact it held was a claim _about_ that comment, and its spec-side
154
+ assertions existed only to keep those claims honest; with the type deleted they
155
+ would pin `@objectstack/spec`'s enums on behalf of no local reader — the same
156
+ phantom shape this change closes. #3720's conclusion is unaffected and now moot:
157
+ it found no producer-side deploy-target type to converge onto because the
158
+ producer did not exist, and this change removes the consumer that was waiting for
159
+ it. Its pending empty changeset (`cloud-deploy-environment-vocabulary-3720.md`,
160
+ never released) is removed too, since it announced a deliberate vocabulary on a
161
+ type this same release deletes.
162
+
163
+ A negative pin (`src/cloud-surface-retired-4152.pin.test.ts`) replaces the
164
+ retired cases and fails if any of the four names returns — reading both the
165
+ runtime export list (which catches the class) and `index.ts`'s source text
166
+ (which is the only instrument that can catch a returning `export type`).
167
+
168
+ - 2e3b0c0: fix(list): an `OBJECT_API_DISABLED` list request renders an honest cannot-work state instead of the empty state
169
+
170
+ A list pointed at an object whose `enable` block withholds the API rendered its ordinary
171
+ empty state, so _"this page cannot work, and never could"_ reached the user as _"you have no
172
+ records"_ (objectui#4408). The reported instance — `Setup › Advanced › Signing Keys`, whose
173
+ `sys_jwks` declares `enable.apiEnabled: false` — could not load for any persona and said so
174
+ to nobody. That is also why the upstream defect objectstack#7544 survived review for its
175
+ whole life: a merely unpopulated page invites nobody to click through.
176
+
177
+ The masking had two halves, in two packages, and neither package could see the other:
178
+
179
+ - **`@object-ui/data-objectstack`** (minor — see the grading note below) — `find()` degraded
180
+ **every** 404 into `{ data: [], total: 0 }` and memoised the resource, so the denial arrived
181
+ at the surface as a successful empty result, indistinguishable from a genuinely empty
182
+ object. The two `enable`-block denials are now let through instead: `OBJECT_API_DISABLED`
183
+ (404) and `OBJECT_API_METHOD_NOT_ALLOWED` (405). The memo skips them too — absorbing one
184
+ would have pinned the object to "empty" for the rest of the session.
185
+ - **`@object-ui/plugin-list`** — the load-error panel gained an `api-disabled` kind. The 405
186
+ half was never swallowed, so it already reached this panel, but classified as `network`:
187
+ _"check your connection and try again"_ for a condition no retry can change. It now says
188
+ the object is not exposed through the API, that this is a setting on the object rather than
189
+ a permission, and it offers **no Retry** button, because every retry re-fetches the
190
+ identical refusal.
191
+
192
+ Both denials are pure functions of the object's metadata — no user, no permission, no
193
+ context — so neither is transient or per-user, which is exactly the case where a silent empty
194
+ state is most misleading. Discrimination is on the ADR-0112 `code`, never the status: a
195
+ missing collection, a missing record and a disabled object are all 404.
196
+
197
+ **A genuinely empty object still renders the ordinary empty state**, and a backend without an
198
+ optional collection still degrades to empty — pinned in both directions, at the adapter, at
199
+ the view, and once end-to-end over a real adapter and a real `ListView`.
200
+
201
+ Also closes a code-propagation gap on the same path: `find()`'s raw `$expand`/`$search`
202
+ branch bypasses `@objectstack/client` and hand-rolled its own error, stamping only `status`.
203
+ It now carries the ADR-0112 envelope (`code` + `httpStatus`), so a denial arriving on the
204
+ branch a list takes whenever it expands a lookup or runs a search is no longer anonymous.
205
+
206
+ New strings: `list.loadErrorApiDisabledTitle` / `list.loadErrorApiDisabledMessage`, in the
207
+ `en` pack and mirrored in the list defaults map.
208
+
209
+ ## Grading note — why `@object-ui/data-objectstack` is **minor** and not patch
210
+
211
+ Two independent reasons, either of which is sufficient under this repo's precedent
212
+ (objectui#4403 / #4177, and #4485's grading of `@object-ui/core`'s `toDomProps` lift):
213
+
214
+ 1. **The emitted `.d.ts` grows two NEW exports.** `isApiAccessDeniedError(error: unknown):
215
+ boolean` and `API_ACCESS_DENIED_CODES` (the readonly tuple
216
+ `['OBJECT_API_DISABLED', 'OBJECT_API_METHOD_NOT_ALLOWED']`) are added to the package's
217
+ public surface. Additive surface growth is minor.
218
+ 2. **Observable behaviour on a published API moves.** `ObjectStackDataSource.find()` now
219
+ **REJECTS** for the two `enable`-block denial codes where it previously **RESOLVED** with
220
+ `{ data: [], total: 0 }`. No signature changed and nothing was removed, but a caller that
221
+ relied on those two codes arriving as a successful empty result now receives a rejected
222
+ promise carrying `code` + `httpStatus`, and must handle it.
223
+
224
+ Deliberately unchanged, and still resolving to an empty result exactly as before: a bare 404
225
+ with no code, `OBJECT_NOT_FOUND` (still memoised) and `RECORD_NOT_FOUND`. The behaviour move
226
+ is scoped to the two denial codes named above and to nothing else.
227
+
228
+ Not major: this follows AGENTS.md's version-alignment rule — objectui's major tracks
229
+ `@objectstack`'s, so this repo's own breaking semantics are declared as minor with the change
230
+ described in the body, which is what this note is.
231
+
232
+ ### Patch Changes
233
+
234
+ - d9d3463: Retire four zero-consumer declared surfaces (dead-surface sweep batch 3, #4328). Each was
235
+ measured as declared-but-never-read at the branch point, and each is removed rather than
236
+ left as an authoring surface whose values nothing acts on.
237
+
238
+ Breaking for anyone who typed against the removed declarations, marked `minor` per this
239
+ repository's version-alignment convention (the major tracks `@objectstack`, never an
240
+ API-break count):
241
+
242
+ - `@object-ui/core` no longer exports `mergeViewsIntoObjects`. It was a second copy left
243
+ behind by the move of that step to the provider layer, and it had drifted: it ignored a
244
+ view container's default `list` and keyed views by the authored bare key instead of the
245
+ composer's `<object>.<key>` identity. The live implementation — `MetadataProvider`'s, in
246
+ `@object-ui/app-shell` — is unchanged and remains the only one. (#3775)
247
+ - `@object-ui/types`' `RoleDefinition` no longer declares `permissions`. A role's grants
248
+ live in `ObjectPermissionConfig.roles`, keyed by object; that is the only home any
249
+ consumer reads (`resolveRoles` walks `inherits` and matches on `name`). The removed
250
+ field was _required_, so five fixtures across three packages had been declaring an empty
251
+ array for a value nothing would ever look at. Role-attached grants are now a compile
252
+ error rather than silently ignored data. (#4288)
253
+ - `@object-ui/react`'s `RecordContextValue` no longer declares `loading` / `error`. Both
254
+ had zero producers and zero consumers — no host passed them, no `record:*` renderer read
255
+ them — and only the provider's memo dependency list still named them. Record-level
256
+ loading and error state stays where it is actually expressed: each renderer's own data
257
+ source. (#3773)
258
+
259
+ No behaviour change, no request-count change:
260
+
261
+ - `@object-ui/data-objectstack` drops five `metadataCache.invalidate('views:<object>')`
262
+ calls across `updateViewConfig` / `createView` / `updateView` / `deleteView`. No read
263
+ path has ever populated that key — `listViews` fetches directly, uncached — so all five
264
+ were permanent no-ops. The invalidations of the keys that do have readers
265
+ (`view:<object>:<viewId>` for `getView`, `view-overrides:<object>` for
266
+ `listViewOverrides`) are untouched and now pinned. (#3778)
267
+
268
+ - c0f9a4b: Studio surfaces the runtime authoring gate's advisory findings instead of discarding them client-side
269
+
270
+ 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.
271
+
272
+ 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.
273
+
274
+ `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.
275
+
276
+ 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.
277
+
278
+ **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.
279
+
280
+ - 605b747: The second metadata client class surfaces the runtime authoring gate's advisories instead of discarding them
281
+
282
+ 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.
283
+
284
+ 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.
285
+
286
+ `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.
287
+
288
+ 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.
289
+
290
+ - 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
291
+
292
+ 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.
293
+
294
+ 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.
295
+
296
+ 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.
297
+
298
+ 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.
299
+
300
+ - d2f6e6b: Publishing a view from the console no longer serves a five-minute-stale override map — every writer now routes through one invalidation seam
301
+
302
+ `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.
303
+
304
+ 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.
305
+
306
+ 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.
307
+
308
+ 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.
309
+
310
+ - 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
311
+
312
+ `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.
313
+
314
+ 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.
315
+
316
+ 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.
317
+
318
+ 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.
319
+
320
+ - Updated dependencies [ee66e2e]
321
+ - Updated dependencies [ee26e65]
322
+ - Updated dependencies [5900ac5]
323
+ - Updated dependencies [f650253]
324
+ - Updated dependencies [3d9769a]
325
+ - Updated dependencies [3fc2971]
326
+ - Updated dependencies [aca27fa]
327
+ - Updated dependencies [dde7283]
328
+ - Updated dependencies [92876f0]
329
+ - Updated dependencies [f279deb]
330
+ - Updated dependencies [eb7f586]
331
+ - Updated dependencies [e901131]
332
+ - Updated dependencies [d9d3463]
333
+ - Updated dependencies [2a40f69]
334
+ - Updated dependencies [bec3e14]
335
+ - Updated dependencies [613b167]
336
+ - Updated dependencies [1f9b905]
337
+ - Updated dependencies [abb0f81]
338
+ - Updated dependencies [38ab505]
339
+ - Updated dependencies [7e4f0e5]
340
+ - Updated dependencies [92250d6]
341
+ - Updated dependencies [c1d939f]
342
+ - Updated dependencies [49ae9f4]
343
+ - Updated dependencies [2459a3e]
344
+ - Updated dependencies [d6aa172]
345
+ - Updated dependencies [fe52a04]
346
+ - Updated dependencies [bb68488]
347
+ - Updated dependencies [9461dd3]
348
+ - Updated dependencies [ab04728]
349
+ - @object-ui/core@17.5.0
350
+ - @object-ui/types@17.5.0
351
+
352
+ ## 17.4.0
353
+
354
+ ### Minor Changes
355
+
356
+ - 48132f7: Track the `@objectstack` family at `17.0.0-rc.5` (objectui#3560).
357
+
358
+ The pin moves from `^17.0.0-rc.2` to `^17.0.0-rc.5` across all 37 declarations in
359
+ 30 `package.json` files, and the sibling `@objectstack/*` packages (`client` /
360
+ `formula` / `lint`) move with it — they pin `@objectstack/spec` **exactly**, so
361
+ leaving them behind would keep a second copy of the spec in the tree and have
362
+ `@objectstack/lint` validating against schemas that still accept the keys rc.3–rc.5
363
+ retire. `pnpm-lock.yaml` now resolves one copy of each of the six family packages
364
+ (`spec` / `client` / `core` / `formula` / `lint` / `sdui-parser`), all at rc.5.
365
+
366
+ Bumping the pin and repairing the fallout cannot be split: the pin alone reddens
367
+ CI, and the code alone targets a shape that is not in effect yet.
368
+
369
+ ## A live bug this upgrade fixes
370
+
371
+ **`ObjectStackDataSource.delete()` never emitted its mutation event, and resolved
372
+ `undefined` instead of a boolean.** `@objectstack/client`'s `DeleteDataResult`
373
+ declared a key called `deleted` — a key no schema has ever declared and no server
374
+ path has ever returned on `DELETE /data/:object/:id`. So `result.deleted`
375
+ compiled and read `undefined` at runtime: the guard never fired, a successful
376
+ delete notified no subscriber, and every consumer's cache stayed stale.
377
+ objectstack#5638 corrected the interface to the schema's `success`; following the
378
+ rename is what restores both behaviours. Nothing in this repo had to change shape
379
+ for it — the code was already asking the right question of the wrong key.
380
+
381
+ ## Breaking, in FROM → TO form
382
+
383
+ - **The five `@objectstack/spec/ui` interaction-config modules are gone** —
384
+ touch / dnd / keyboard / animation / offline, 32 defs and 64 exports
385
+ (objectstack#4988, PR objectstack#5321). None of them had an authoring door: no
386
+ metadata document could ever carry one of these blocks, so a stack that parsed
387
+ before the retirement parses byte-for-byte the same after it. `@object-ui/types`
388
+ drops the 32 `export type` re-exports. The vocabulary each one's only real
389
+ consumer needs is now declared by that consumer, which is the remedy the spec's
390
+ own retirement ledger prescribes ("declare that union locally — it is your
391
+ client's policy, not the platform's"):
392
+
393
+ - `@object-ui/react`'s `useOffline` owns `OfflineStrategy`, `ConflictResolution`,
394
+ `PersistStorageType`, `EvictionPolicyType`, `OfflineConfig`,
395
+ `OfflineCacheConfig`, `OfflineSyncConfig`;
396
+ - `@object-ui/core`'s `DndProtocol` / `KeyboardProtocol` own `DndConfig`,
397
+ `DragItem`, `DropZone`, `DragConstraint`, `DragHandle`, `DropEffect`,
398
+ `KeyboardNavigationConfig`, `KeyboardShortcut`, `FocusManagement`,
399
+ `FocusTrapConfig`;
400
+ - `@object-ui/types`' `mobile` module owns `SpecGestureConfig`,
401
+ `SwipeGestureConfig`, `PinchGestureConfig`, `LongPressGestureConfig`,
402
+ `TouchTargetConfig`, `TouchInteraction` (plus a new `SPEC_GESTURE_TYPES`
403
+ runtime tuple), so `@object-ui/mobile`'s import paths are unchanged.
404
+
405
+ Every shape is moved verbatim — same keys, same members, same optionality — so
406
+ no hook or bridge changes behaviour. Consumers importing these names from
407
+ `@object-ui/types` must import them from the owning package instead. Note the
408
+ spec's _surviving_ `ConnectorConflictResolution` (`/integration`, connector sync)
409
+ and `ConflictResolutionStrategy` (`/api`, route merge policy) are **different
410
+ concepts** — do not re-point at them.
411
+
412
+ - **`@object-ui/types` no longer re-exports `NotificationAction` or `EmbedConfig`**
413
+ (objectstack#5015, PR objectstack#5300). Both were published `ui` vocabulary with
414
+ no authoring door; no notification action was ever parsed from metadata and no
415
+ iframe route ever read an embed config. The presentation enums
416
+ (`NotificationType` / `NotificationSeverity` / `NotificationPosition`) and
417
+ `SharingConfig` **survive** and are untouched — public form sharing still gates
418
+ the anonymous endpoints on `allowAnonymous` + `publicLink`.
419
+ `@object-ui/core`'s `SharingProtocol` keeps `resolveEmbedConfig` /
420
+ `generateEmbedCode` against a locally declared `EmbedConfig`, so its surface is
421
+ unchanged.
422
+ - **`ThemeEngine` stops emitting nine retired CSS variable groups**
423
+ (objectstack#5021 option 2, PR objectstack#5289). `theme.animation`,
424
+ `theme.zIndex` and five typography groups (`fontSize` / `fontWeight` /
425
+ `lineHeight` / `letterSpacing`, plus `fontFamily.heading` / `fontFamily.mono`)
426
+ are tombstones the schema now rejects by name, so `--duration-*`, `--timing-*`,
427
+ `--z-*`, `--font-size-*`, `--font-weight-*`, `--line-height-*`,
428
+ `--letter-spacing-*`, `--font-heading` and `--font-mono` had become structurally
429
+ dead code — no author could produce the input that reached them.
430
+ `generateAnimationVars` and `generateZIndexVars` are removed from
431
+ `@object-ui/core`, and `@object-ui/types` drops `Animation` / `ZIndex` /
432
+ `AnimationSchema` / `ZIndexSchema`. **`theme.customVars` is the declared — and
433
+ since #5021 the only — door**: each entry is emitted verbatim as
434
+ `--<key>: <value>`, so a `--z-modal` or a `--duration-fast` goes there now.
435
+ LIVE emission is untouched byte for byte: `colors`, `borderRadius`, `shadows`,
436
+ `typography.fontFamily.base` (→ `--font-sans`) and `customVars`.
437
+ - **`@object-ui/types`' `HttpMethodSchema` now binds the spec's
438
+ `HttpMethodSubsetSchema`, and `HttpMethod` binds `HttpMethodSubset`**
439
+ (objectstack#5832, PR objectstack#5976 — objectui#3499). The spec renamed its
440
+ 5-value UI subset because `schemaNameFromExportKey` strips the `Schema` suffix,
441
+ so the 5-value and 7-value enums both published as `shared/HttpMethod` and the
442
+ later write won — the emitted JSON Schema and reference page described only one
443
+ of them. **The runtime domain is unchanged and this repo's exported names are
444
+ unchanged**; this follows the rename without touching cross-package semantics.
445
+ Deliberately NOT re-pointed at the spec's bare `HttpMethod`: that is the 7-value
446
+ enum, and widening to it would let `method: 'HEAD'` compile and then throw in
447
+ `HttpRequestSchema.parse()`.
448
+ - **`dashboard.widgets[].actionUrl` / `actionType` / `actionIcon` / `aria` are
449
+ refused, not stripped** (objectstack#5010, ADR-0049 enforce-or-remove). A
450
+ dashboard widget has no action button and never had one — every action the
451
+ dashboard dispatches comes from `header.actions[]` — and no renderer ever applied
452
+ the widget `aria`, so it promised accessibility compliance it did not deliver.
453
+ A stale dashboard now gets a named error telling it where the affordance moved,
454
+ instead of silently losing it. Run `os migrate meta --from 16` to rewrite.
455
+
456
+ ### Patch Changes
457
+
458
+ - 3765678: data-objectstack: pass the server's `drillRanges` date-bucket drill scope through `queryDataset` (restores date drill-through)
459
+
460
+ `queryDataset` rebuilds its result by **hand-picking** keys off the REST payload,
461
+ and `drillRanges` was never in the list — so the analytics service's date-range
462
+ drill sidecar (framework#1752) was dropped by the only real adapter in this repo,
463
+ while five consumer call sites were already reading it (`DatasetWidget.tsx:471`
464
+ and `:593`, `DatasetReportRenderer.tsx:316`, `:431`, `:855`).
465
+
466
+ The user-visible effect was not a degraded drill but a missing one. A
467
+ `dateGranularity` dimension groups a **span** of records into one bucket, which
468
+ equality filters cannot express, so `service-analytics` deliberately excludes
469
+ date dimensions from `dimensionFields`/`drillRawRows` and sends a parallel
470
+ half-open `[gte, lt)` range per row instead. For a chart or report grouped **only
471
+ by time** that makes `drillRanges` the _only_ thing that can make
472
+ `canDrill = !!object && (drillDims.length > 0 || !!drillRanges?.length)` true —
473
+ with the key dropped, the entire drill entry point disappeared. A mixed
474
+ date + non-date grouping kept its drill but built a filter with no time bound, so
475
+ clicking June's bar opened every month (a superset).
476
+
477
+ Neither side's tests could see it: the dashboard and report tests mock their own
478
+ data source and feed `drillRanges` in directly, and the adapter's own suite never
479
+ asserted the key. The new adapter-level tests therefore mock the **envelope the
480
+ server actually sends** — bare (`res.json(result)`, no `{ success, data }`
481
+ wrapper), carrying `sql`, and for a date-only grouping carrying `object` +
482
+ `drillRanges` and _no_ `dimensionFields`/`drillRawRows` — then assert the key
483
+ arrives verbatim and row-aligned, that the consumers' own `canDrill` predicate is
484
+ true, and that `buildDatasetDrillFilter` (the shared builder both surfaces call)
485
+ scopes the drilled list to the clicked bucket.
486
+
487
+ The declared entry type is `@object-ui/core`'s `DatasetDrillRange` **by
488
+ reference**, per the objectui#3613/#3752 discipline: it is the single in-repo
489
+ declaration of this shape (what the filter builder accepts and what both
490
+ renderers type their state with), and nothing in `@objectstack/spec` owns it yet,
491
+ so restating `{ field, gte, lt }` locally would create a third dialect of it.
492
+
493
+ `drillRawTotals` (the totals-row companion, framework#3214) is deliberately
494
+ **not** added: it has zero consumers in this repo, so passing it through would
495
+ add a declared-but-unexercised return key with no user-facing effect — it belongs
496
+ in the change that lands a totals-row drill and can test it.
497
+
498
+ - d83f6b3: data-objectstack: type `queryDataset`'s result `fields[]` as the spec's `AnalyticsResult.fields[]` element instead of a hand-written copy
499
+
500
+ The return-value half of the drift objectui#3613 fixed on the parameter side. The
501
+ adapter hand-listed five keys for a result column
502
+ (`name`/`type`/`label`/`format`/`currency`) and, like every restatement, stopped
503
+ at the contract of the day it was written: it never grew **`percentScale`**,
504
+ which `@objectstack/spec@17.0.0-rc.5` carries on
505
+ `AnalyticsResult.fields[]` and documents as mandatory reading for renderers —
506
+ "renderers that receive it must scale by it instead of guessing from the value"
507
+ (objectui#3136).
508
+
509
+ That omission was not cosmetic. `percentScale` is the server's answer to a
510
+ question a `%` format string cannot express (is the stored number a 0–1 fraction,
511
+ or already percentage points?), and objectui#3136 exists because guessing from
512
+ the value's magnitude printed a ratio of exactly `1` as "1.0%". Three in-repo
513
+ consumers read the field through their own local types
514
+ (`DatasetResultField` in `@object-ui/core`), so nothing was red here — but any
515
+ author reading columns through the adapter's **declared** return type got
516
+ `Property 'percentScale' does not exist`, i.e. the declaration actively steered
517
+ them back to the guess the spec bans.
518
+
519
+ `fields` is now the spec type by reference, so there is nothing left to re-sync;
520
+ the change is additive for existing consumers (one more optional key).
521
+ `queryDataset.test.ts` pins structural identity with the spec element, pins
522
+ `percentScale` as the `'fraction' | 'whole'` union rather than a widened
523
+ `string`, keeps a negative pin against the five-key shape, and adds a runtime
524
+ test that reads `percentScale` off a result column **through the declared type**.
525
+
526
+ The rest of the envelope stays locally declared, deliberately. It is the REST
527
+ envelope, not an `AnalyticsResult`: the route adds ADR-0021 D2 drill metadata
528
+ (`object` / `dimensionFields` / `drillRawRows`) on top of the spec result, and
529
+ this method rebuilds its own object from the payload without copying `sql` — so
530
+ declaring the envelope as `AnalyticsResult & { … }` would advertise a key the
531
+ adapter structurally cannot return. A pin records that too.
532
+
533
+ - 5f08c05: data-objectstack: type `queryDataset(selection)` as the spec's `DatasetSelection` instead of a hand-written copy
534
+
535
+ The adapter restated the selection contract inline, field by field, and the copy
536
+ had drifted three ways from the pinned `@objectstack/spec@17.0.0-rc.5`:
537
+
538
+ - **`compareTo.dimension` was required.** It has been optional since
539
+ objectstack#5011, _because the executor resolves it_: exactly one time
540
+ dimension carrying a `dateRange` is the one shifted, and zero or several
541
+ raises a loud error naming the candidates. Requiring it made the compiler
542
+ demand from every typed caller precisely the consumer-side dimension guess
543
+ that change forbids — trading a loud executor error for a silently wrong
544
+ comparison window. No runtime path hit this yet (the dashboard's
545
+ `DatasetWidget` passes `selection` as `unknown`), but a declaration is a live
546
+ instruction to anyone calling this client from TypeScript.
547
+ - **`timeDimensions` was widened to `unknown[]`**, erasing the very entry shape
548
+ the executor's resolution reads (`{ dimension, granularity?, dateRange? }`),
549
+ and **`runtimeFilter` to `Record<string, unknown>`**, erasing the
550
+ `$and`/`$or`/`$not` vocabulary the server parses.
551
+ - **`dateGranularity` was missing entirely** — the copy had simply stopped at
552
+ whatever the contract looked like the day it was written, so a typed caller
553
+ could not bucket a trend by month at all.
554
+
555
+ The parameter is now the spec type by reference, so there is nothing left to
556
+ re-sync. The fix is the removal of the dialect rather than a correction to it:
557
+ restating a contract owned elsewhere creates a second de-facto dialect of it, and
558
+ drift is then only a matter of time (AGENTS.md #0/#0.1). `queryDataset.test.ts`
559
+ pins structural identity with `DatasetSelection` plus each of the three drifts
560
+ individually, checked by this package's `tsc --noEmit`; a runtime test pins that
561
+ a dimension-less `compareTo` reaches the server untouched, so the adapter can
562
+ never start guessing on the executor's behalf.
563
+
564
+ The response type is deliberately left alone — it is the REST envelope
565
+ (`object` / `dimensionFields` / `drillRawRows`), not a restatement of
566
+ `AnalyticsResult`.
567
+
568
+ - 41d6022: The console no longer reads `/meta/*` before it knows whether it has a session, and a failed request now says which request failed
569
+
570
+ Opening a logged-out console painted ~30 red `HTTP request failed` lines before
571
+ the login form was drawn. Two independent causes, fixed independently
572
+ (objectui#4042).
573
+
574
+ **1. Requests fired before the session was known.** `ConnectedShellInner` now
575
+ withholds the metadata tree until `GET /auth/get-session` resolves, so
576
+ `meta/object` / `meta/view` / `meta/app` are never issued blind. `useAuth()`
577
+ outside an `AuthProvider` reports `isLoading: false`, so an embed with no auth
578
+ provider is unaffected, and every protected route already sat behind an
579
+ `AuthGuard` that resolves auth first — the signed-in data flow is unchanged.
580
+
581
+ The console's landing route (`<Route path="/">`) was the actual entry point for
582
+ the burst: it mounted `ConnectedShell` with no guard above it, so simply opening
583
+ `/_console/` mounted the whole data layer as an anonymous visitor. It is now
584
+ guarded, which also means an unauthenticated visitor reaches `/login` without a
585
+ single doomed request. `examples/console-starter` had the same shape and got the
586
+ same fix.
587
+
588
+ **2. Two requests per type, per mount — not an unauthenticated artefact.**
589
+ Consumers read metadata during the FIRST render (`useActionModal` reads
590
+ `objects`, whose getter kicks `ensureType('object')` and `ensureType('view')`
591
+ from the render phase), before any effect runs. `MetadataProvider`'s preview-mode
592
+ effect then cleared the whole cache on mount, discarding those two entries while
593
+ their requests were in flight; the next render found them `idle` and refetched
594
+ both. The effect now skips its mount run — on mount the cache is empty and there
595
+ was never anything to drop; it only ever meant something on a later
596
+ `previewDrafts` change. That halved `meta/object` and `meta/view` on **every**
597
+ mount, signed in included.
598
+
599
+ A second duplicate only appeared once a read had failed: `entry.promise`
600
+ collapses callers that arrive while a request is in flight, but callers arriving
601
+ just after a failure each started a fresh attempt. A failed type now stays
602
+ un-retried for ~1s, which collapses one mount's burst of callers into a single
603
+ attempt. This is deliberately not the 5-minute `ttlMs` — later callers still
604
+ retry on their own, and `refresh()` / `invalidate()` retry immediately and
605
+ unconditionally, so no explicit recovery path changes.
606
+
607
+ **3. `HTTP request failed` now identifies the request.** `@objectstack/client`
608
+ reports every non-2xx as
609
+ `logger.error("HTTP request failed", undefined, { method, url, status, error })`,
610
+ and the console's logger forwarded that verbatim — so the identifying fields
611
+ lived only in the third argument, and anything that flattens a console record to
612
+ text rendered them `[object Object]` / `Object`. A screenful of failures could
613
+ not tell you a single URL or status. The message string now carries them:
614
+
615
+ ```text
616
+ HTTP request failed: GET /api/v1/meta/object -> 401 [UNAUTHORIZED]
617
+ ```
618
+
619
+ The structured bag is still passed alongside for DevTools to expand — text for
620
+ the flatteners, object for the inspectors, neither at the other's expense. The
621
+ formatter is exported as `formatHttpFailureMessage`, and `createQuietHttpLogger`
622
+ is now exported too so an app wiring its own `ObjectStackClient` gets the same
623
+ identified failures.
624
+
625
+ Nothing is newly silenced. The only demotion remains 404-on-an-optional-
626
+ collection (`sys_presence`, `sys_activity`), which is an expected outcome of a
627
+ request we still mean to make; a 401 that survives the session gate — a
628
+ mid-session expiry, say — stays a visible, fully-identified error. The cure for
629
+ doomed requests is not issuing them, never hiding them once issued.
630
+
631
+ - 7e2b7e9: Fix saved list-view preferences never reading back (density, column widths, sort, hidden columns, inline edit)
632
+
633
+ `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".
634
+
635
+ 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.
636
+
637
+ 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.
638
+
639
+ - Updated dependencies [6719877]
640
+ - Updated dependencies [56ff091]
641
+ - Updated dependencies [d229dfa]
642
+ - Updated dependencies [4bc6c23]
643
+ - Updated dependencies [c3b01a7]
644
+ - Updated dependencies [e06810e]
645
+ - Updated dependencies [ab3ad4f]
646
+ - Updated dependencies [c2fd122]
647
+ - Updated dependencies [48132f7]
648
+ - Updated dependencies [1d723e3]
649
+ - Updated dependencies [0109f54]
650
+ - Updated dependencies [7e5bb5d]
651
+ - Updated dependencies [fbc23e0]
652
+ - Updated dependencies [e6fdbdc]
653
+ - Updated dependencies [6bb454a]
654
+ - Updated dependencies [523be48]
655
+ - Updated dependencies [7e2b7e9]
656
+ - Updated dependencies [c1e1e6b]
657
+ - @object-ui/core@17.4.0
658
+ - @object-ui/types@17.4.0
659
+
3
660
  ## 17.3.0
4
661
 
5
662
  ### Minor Changes
@@ -1022,6 +1679,7 @@ ${order}"` is exactly the shape `ObjectGrid` builds from its view metadata's
1022
1679
  objects+fields were editable in Studio; this reworks both surfaces.
1023
1680
 
1024
1681
  **Setup (assign + read-only):**
1682
+
1025
1683
  - The six facets (`object_permissions`, `field_permissions`, `system_permissions`,
1026
1684
  `row_level_security`, `tab_permissions`, `admin_scope`) now render read-only on
1027
1685
  the `sys_permission_set` record page as a compact summary (counts, or capability
@@ -1036,6 +1694,7 @@ ${order}"` is exactly the shape `ObjectGrid` builds from its view metadata's
1036
1694
 
1037
1695
  **Studio (design every facet):** the permission matrix editor gains structured
1038
1696
  editors for the facets that were JSON-only —
1697
+
1039
1698
  - **System Capabilities**: a multi-select over the live `sys_capability` registry
1040
1699
  (scope-grouped, labelled chips).
1041
1700
  - **Row-Level Security**: per-policy rows (object · operation · enabled) with CEL
@@ -1052,6 +1711,7 @@ ${order}"` is exactly the shape `ObjectGrid` builds from its view metadata's
1052
1711
  record the Setup summary reads, so a fresh Studio edit isn’t reflected in Setup’s
1053
1712
  read-only view until the projection refreshes — tracked as a framework follow-up
1054
1713
  (enforcement reads the authoritative metadata).
1714
+
1055
1715
  - @object-ui/types@13.2.0
1056
1716
  - @object-ui/core@13.2.0
1057
1717
 
@@ -1359,6 +2019,7 @@ ${order}"` is exactly the shape `ObjectGrid` builds from its view metadata's
1359
2019
 
1360
2020
  **`@object-ui/plugin-designer`** — two new route-ready pages that
1361
2021
  together close the "Data Model" management loop in the Setup app:
2022
+
1362
2023
  - `MetadataObjectsPage` — lists every object schema (via
1363
2024
  `MetadataClient.list('object')`), renders the existing
1364
2025
  `ObjectManager`, and persists edits/deletes through PUT/DELETE on
@@ -1389,6 +2050,7 @@ ${order}"` is exactly the shape `ObjectGrid` builds from its view metadata's
1389
2050
  the Setup app → _All Metadata Types_.
1390
2051
 
1391
2052
  ### New: `@object-ui/app-shell` views/metadata-admin
2053
+
1392
2054
  - **`MetadataDirectoryPage`** — auto-grouped tile directory by domain, with
1393
2055
  free-text search, domain chips, and a _Writable only_ filter.
1394
2056
  - **`MetadataResourceListPage` / `MetadataResourceEditPage` / `…CreatePage` / `…HistoryPage`** —
@@ -1412,6 +2074,7 @@ ${order}"` is exactly the shape `ObjectGrid` builds from its view metadata's
1412
2074
  `t(key)` helper.
1413
2075
 
1414
2076
  ### New routing variant
2077
+
1415
2078
  - App nav now supports `{ type: 'component', componentRef, params? }` items.
1416
2079
  `AppContent` resolves them through the existing `ComponentRegistry`.
1417
2080
  - Built-in components registered: `metadata:directory`, `metadata:resource`,
@@ -1420,12 +2083,14 @@ ${order}"` is exactly the shape `ObjectGrid` builds from its view metadata's
1420
2083
  / page.
1421
2084
 
1422
2085
  ### Plugin-designer
2086
+
1423
2087
  - Lazy-exported `ObjectManager`, `FieldDesigner`, `ObjectViewConfigurator`,
1424
2088
  `DashboardEditor`, `PageCanvasEditor`, `MetadataObjectsPage`, and
1425
2089
  `MetadataFieldsPage` so the engine can mount them on demand.
1426
2090
 
1427
2091
  The temporary `/dev/meta` route is removed. Setup app navigation flows
1428
2092
  through the new component routes.
2093
+
1429
2094
  - @object-ui/types@6.2.0
1430
2095
  - @object-ui/core@6.2.0
1431
2096
 
@@ -1533,6 +2198,7 @@ ${order}"` is exactly the shape `ObjectGrid` builds from its view metadata's
1533
2198
 
1534
2199
  Extract the bulk-vs-per-row decision into a reusable
1535
2200
  `executeBulkBatch(input, ops)` helper in `@object-ui/core`:
2201
+
1536
2202
  - Single decision tree shared by both update and delete fast paths.
1537
2203
  - Bulk success → no per-row pass.
1538
2204
  - Bulk partial-count → aggregate batch error.
@@ -1576,6 +2242,7 @@ ${order}"` is exactly the shape `ObjectGrid` builds from its view metadata's
1576
2242
  multi-user editing scenarios.
1577
2243
 
1578
2244
  **`@object-ui/data-objectstack`**
2245
+
1579
2246
  - Exports `ConcurrentUpdateError` (carries `currentVersion` and
1580
2247
  `currentRecord`) and `isConcurrentUpdateError()` type guard.
1581
2248
  - `update()` / `delete()` accept `opts.ifMatch` and forward it via the
@@ -1588,6 +2255,7 @@ ${order}"` is exactly the shape `ObjectGrid` builds from its view metadata's
1588
2255
  from conflicts without parsing the wire format.
1589
2256
 
1590
2257
  **`@object-ui/core`**
2258
+
1591
2259
  - `ApiDataSource.update()` and `.delete()` accept `opts.ifMatch` and emit
1592
2260
  the `If-Match` HTTP header.
1593
2261
 
@@ -1630,6 +2298,7 @@ ${order}"` is exactly the shape `ObjectGrid` builds from its view metadata's
1630
2298
  their settings.
1631
2299
 
1632
2300
  The adapter now defaults to:
2301
+
1633
2302
  - `resource`: `sys_user_preference`
1634
2303
  - field shape: `(user_id, key, value)` instead of `(user_id, kind, payload)`
1635
2304
  - option name: **`key`** instead of `kind`