@objectstack/core 17.0.0 → 17.2.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,739 @@
1
1
  # @objectstack/core
2
2
 
3
+ ## 17.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - ee2ff45: `ObjectKernel` no longer pre-injects the in-memory `job` fallback for the `job` core-service slot — a fallback must not fake capability (#10746, maintainer ruling 2026-08-22). `createMemoryJob()`'s `schedule()` records a job and never fires it (it owns no timer), so pre-injecting it made every "prefer the platform job service, else own a timer" consumer take the job-service branch on a kernel without `@objectstack/service-job` and then silently never run: `plugin-reports` logged `dispatcher registered with job service` and dispatched nothing, ever.
8
+
9
+ Behavior change, FROM → TO: on an `ObjectKernel` without a registered `job` service, `getService('job')` FROM resolving a non-scheduling in-memory registry TO throwing `Service 'job' not found`. Consumers' documented no-job-service paths take over (`plugin-reports` falls through to its own `setInterval` and scheduled reports actually dispatch; schedule triggers and declarative jobs warn loudly instead of scheduling into the void), and the kernel says the absence out loud at boot: `Core service missing, functionality may be degraded: job`.
10
+
11
+ One-line fix if you relied on the old behavior: install `@objectstack/service-job` for real scheduling, or — if you deliberately want the manual-trigger in-memory registry — register it explicitly: `kernel.registerService('job', createMemoryJob())` (the factory is still exported from `@objectstack/core`).
12
+
13
+ ### Patch Changes
14
+
15
+ - 3b2af5e: `resolveUserAuthzGrants` issues its five independent reads (sys_user, both sys_member reads, sys_user_position, sys_user_permission_set) concurrently (#10825) — 8 sequential round trips become 4 waves on the fullest path (2 on the lightest), with byte-identical rows, filters, limits and tenancy scoping, pinned by a differential golden suite captured from the sequential implementation. No caching; nothing survives a request; authorization semantics unchanged by construction.
16
+ - 47cd3ec: The kernel's two `Promise.race` timeout guards — the startup guard around each
17
+ plugin's `init`/`start`, and the shutdown guard around `performShutdown()` —
18
+ now reclaim **both** halves of the guard when the race settles: the timer is
19
+ cleared *and* the losing promise is settled (#10604).
20
+
21
+ Neither site settled its loser, so the timeout promise and the reaction
22
+ `Promise.race` held on it were retained for the life of the process — four
23
+ leaking promises per showcase test run under `vitest --detectAsyncLeaks`, now
24
+ zero. The two hand-rolled copies had also drifted into doing opposite halves of
25
+ the same cleanup: the startup site cleared its timer and never `unref`'d, the
26
+ shutdown site `unref`'d and never cleared. Both now go through one internal
27
+ `TimeoutGuard`, so they cannot drift apart again. No exported API changes.
28
+
29
+ **Behaviour change, at the shutdown guard:** the shutdown timer is no longer
30
+ `unref()`d. Two consequences for an embedding host (CLI, auth-proxy, test
31
+ runner):
32
+
33
+ - After a **successful** shutdown, no timer is left armed. Previously the guard
34
+ survived its own race and stayed scheduled to fire against a kernel already
35
+ `'stopped'`. That late rejection was *handled* — `Promise.race` had attached a
36
+ rejection handler to it — so this was never an unhandled-rejection risk; it
37
+ was retained work and a wakeup after teardown.
38
+ - When teardown **hangs**, the guard now actually fires. An unref'd timer does
39
+ not keep the event loop alive, so a process with nothing else to run could
40
+ exit silently — status 0, teardown incomplete — before `shutdownTimeout`
41
+ elapsed, leaving `Shutdown timed out — forcing exit` and its `exit(1)`
42
+ unreachable in exactly the case they exist for. Reclaiming on settle keeps the
43
+ guard ref'd exactly as long as the race is undecided, which is the guarantee
44
+ the startup guard already had (#4813).
45
+
46
+ If your host relied on a hung `shutdown()` letting the process fall out of the
47
+ event loop on its own, it will now wait up to `shutdownTimeout` (default 60s)
48
+ and then hard-exit with status 1. Lower `shutdownTimeout` in the kernel config
49
+ to shorten that window.
50
+ - 9d7d2de: `resolveLocalizationContext` now memoizes a FAILED read's fallback per `(ql, tenantId, userId)` for 30s (#10221).
51
+
52
+ On a fresh environment whose `sys_setting` table hasn't been created/migrated yet, every authenticated request re-ran the same `sys_setting` localization read, and every one of those reads failed the same way ("no such table"). The `#2409` batching had already collapsed the three per-key reads a single request used to issue into one query, but that one query still repeated on every subsequent request, and `driver-sql`'s `backendStatementFault` logs a `[sql-driver] DATABASE_ERROR` warning on every failed read — so the identical warning printed once per request and buried real errors in between.
53
+
54
+ Only the case where the underlying read genuinely fails (a backend fault, e.g. the missing table) is cached; a successful read — including a legitimate "nothing configured yet" empty result — is never cached and always re-reads on the next call, so a settings write takes effect immediately. (An earlier version of this fix cached every outcome, mirroring `packages/plugins/plugin-audit/src/audit-writers.ts`'s existing TTL cache of this same read — safe there because audit-trail enrichment is best-effort, but not safe for `@objectstack/rest`'s use of this function: analytics date-bucketing reads the org timezone on every query and `packages/qa/dogfood/test/analytics-timezone.dogfood.test.ts` — the #1982/#2018 golden regression — asserts the very next read reflects a just-written timezone.) The `UTC` / `en-US` fallback behavior itself is unchanged; this only stops the failing query — and its log line — from re-running every request. The cache is keyed on the `ql` engine instance first, so two environments/tenants sharing one process never share a cached outcome, and self-heals within one TTL window once `sys_setting` exists.
55
+ - c815c50: `resolveLocalizationContext` prefers `settings.getMany` — one grouped namespace read instead of three per-key `get()`s (#10826); older services without `getMany` keep the three parallel gets, and a thrown `getMany` lands in the same direct `$in` fallback a thrown `get` did.
56
+ - 795ea05: A lapsed `sys_member` row now confers no org role either — one row, one answer (#10982)
57
+
58
+ `resolveUserAuthzGrants` reads `sys_member` once and derives two facts from it:
59
+ `accessible_org_ids` (the `group` posture's read reach, ADR-0105 D2) and the
60
+ org-administration role projection into `positions` (ADR-0095 D3). Only the
61
+ first applied the ADR-0091 validity window. A membership outside
62
+ `[valid_from, valid_until)` was therefore excluded from org access while still
63
+ projecting its better-auth role — two answers from one read, and with
64
+ `role: 'owner'` the role reaches the `organization_admin` capability that
65
+ `derivePosture` reads for `TENANT_ADMIN`.
66
+
67
+ The role projection now drops out-of-window rows **before** the derivation, the
68
+ same shape `sys_user_permission_set` already had, so an expired membership can
69
+ no more yield `org_owner` than an expired `admin_full_access` can yield
70
+ `platform_admin`. Fail-closed per ADR-0091 D2. Maintainer ruling, 2026-08-22
71
+ live session (item 2): a lapsed membership is *no membership*, not merely *no
72
+ org access*.
73
+
74
+ **Why `patch` and not a breaking bump, argued in the open.** This is a real
75
+ change of authorization semantics — a membership that used to confer a role
76
+ stops conferring it — so the direction is a tightening, and tightenings are the
77
+ kind of change that normally earns a major. It is nevertheless `patch` because
78
+ the population it can affect is provably empty: `sys_member` declares neither
79
+ `valid_from` nor `valid_until` (see `sys-member.object.ts`), and `isGrantActive`
80
+ reads an absent bound as unbounded, so **no row any deployment can currently
81
+ store is lapsed** and every existing membership resolves exactly as before. That
82
+ is asserted directly rather than reasoned about, in
83
+ `resolve-authz-context.test.ts` ("a membership with NO bounds is unbounded —
84
+ every shipped row is unaffected"), alongside the load-bearing leg that an
85
+ in-window membership still projects its role. Landing it now is the cheap
86
+ moment: once the columns exist, the same change becomes a migration carrying
87
+ live semantics.
88
+
89
+ **Not in scope, and deliberately so.** This does not add the validity columns to
90
+ `sys_member`, and it does not reach into `sys_user_permission_set` rows that
91
+ plugin-security's `reconcileOrgAdminGrant` provisioned from a membership role.
92
+ Such a grant is standing authority in its own right with its own ADR-0091
93
+ window; the role is only its provisioning source (ADR-0095 D3). The boundary is
94
+ pinned as a measured fact rather than left as an assumption.
95
+ - 504c8d5: Materialize the RBAC catalog **per organization**, so a walled deployment can
96
+ administer positions, permission sets and sharing rules again (#10103).
97
+
98
+ On a walled deployment (`group` / `isolated`) every principal — an organization
99
+ owner and a platform admin alike — listed **zero** positions, permission sets
100
+ and sharing rules while the tables held rows. Nothing could be bound through
101
+ Setup, and a declared `hierarchy-security` could never be armed by an operator
102
+ however loudly an app declared it.
103
+
104
+ Every row in those three tables was organization-less. plugin-security's Layer 0
105
+ composes a strict `organization_id = :tenant` for a walled posture and the
106
+ middleware ANDs it into the read AST over the driver's
107
+ `(organization_id = :tenant OR organization_id IS NULL)`; the conjunction of the
108
+ two is the strict equality alone, so the driver's null arm was annihilated on
109
+ every authenticated read.
110
+
111
+ **The wall is not changed, at either layer.** The rows get an owner instead:
112
+
113
+ - `bootstrapDeclaredPositions`, `bootstrapBuiltinRoles`,
114
+ `bootstrapDeclaredPermissions` (plugin-security) and
115
+ `bootstrapDeclaredSharingRules` (plugin-sharing) upsert by
116
+ `(name, organization_id)` and run **one pass per organization** under a walled
117
+ posture — the framework built-ins (`platform_admin`, `org_*`, `everyone`,
118
+ `guest`) included, matching `sys_user_position`, which is already
119
+ per-organization, and matching both objects' own `unique: 'organization'` name
120
+ index.
121
+ - Seeding also fires on **organization creation**, not only at `kernel:ready`, so
122
+ a tenant created after startup does not administer an empty catalog until the
123
+ next restart.
124
+ - `single` posture is **unchanged**: exactly one organization-less pass, which is
125
+ the correct shape there.
126
+
127
+ An organization-less row is now invalid state under a walled posture. Nothing is
128
+ reaped — grants (`sys_user_position`, `sys_position_permission_set`,
129
+ `sys_user_permission_set`, `sys_record_share`) point at these rows by id, so
130
+ deleting them would revoke standing access with no signal at the moment of loss.
131
+ Instead a per-organization pass that meets pre-fix organization-less rows for
132
+ names it seeds **says so loudly**, naming the rows and the remedy, and still
133
+ creates that organization's own copies. The failure this closes is the silent
134
+ no-op: a tenant-threaded pass that sees the old row through the driver's
135
+ compatibility arm, reads the name as already represented, and creates nothing
136
+ while reporting success.
137
+
138
+ Two enforcement-plane reads are scoped in the same change, because the exposure
139
+ they carry only exists once per-organization copies exist:
140
+
141
+ - `resolveUserAuthzContext`'s position name-sweep (`@objectstack/core`) resolved
142
+ `sys_position` by name across **every** organization, so the junction read
143
+ behind it collected another organization's `everyone` binding — a cross-organization
144
+ grant bleed, and an O(organizations) read on the per-request path. It is now
145
+ threaded through the driver's tenant chokepoint, keeping per-request resolution
146
+ O(the caller's own organization's catalog).
147
+ - plugin-security's permission-set `dbLoader` resolved sets by name unscoped,
148
+ with a `limit` equal to the number of names — correct while one row existed per
149
+ name, a truncation the moment copies exist. It is now scoped to the caller's
150
+ organization and its bound widened.
151
+
152
+ Boot reconciliation is O(changed declarations): each pass reads what its
153
+ organization already has and writes only where a declaration actually differs, so
154
+ the common boot performs no writes at all. Steady state rides the
155
+ organization-creation hook.
156
+
157
+ Cross-links #10119 / PR #10422, whose criteria-sweep scoping makes per-organization
158
+ sharing rules cheaper than the unscoped sweep they replace.
159
+ - Updated dependencies [6936d07]
160
+ - Updated dependencies [59eb04d]
161
+ - Updated dependencies [9f05b7d]
162
+ - Updated dependencies [7d2d112]
163
+ - Updated dependencies [5fa0d72]
164
+ - Updated dependencies [02b3b07]
165
+ - Updated dependencies [914c413]
166
+ - Updated dependencies [55809a0]
167
+ - Updated dependencies [52db1d1]
168
+ - Updated dependencies [5649efb]
169
+ - Updated dependencies [2306a76]
170
+ - Updated dependencies [e5ea701]
171
+ - Updated dependencies [a40dcc1]
172
+ - Updated dependencies [def0d3e]
173
+ - Updated dependencies [8d0bb79]
174
+ - Updated dependencies [5acb58d]
175
+ - Updated dependencies [2e3cf95]
176
+ - Updated dependencies [4c93387]
177
+ - Updated dependencies [a037f7c]
178
+ - Updated dependencies [3ee8ddf]
179
+ - Updated dependencies [16cef97]
180
+ - Updated dependencies [a79bd35]
181
+ - Updated dependencies [6ceaa4b]
182
+ - Updated dependencies [15ea214]
183
+ - Updated dependencies [de19489]
184
+ - Updated dependencies [c684d00]
185
+ - Updated dependencies [923c424]
186
+ - Updated dependencies [1ec36b7]
187
+ - Updated dependencies [5f2e54c]
188
+ - Updated dependencies [189373b]
189
+ - Updated dependencies [35ad101]
190
+ - Updated dependencies [ceb33a9]
191
+ - Updated dependencies [73d9795]
192
+ - Updated dependencies [8012960]
193
+ - Updated dependencies [f34f56b]
194
+ - Updated dependencies [f399618]
195
+ - Updated dependencies [75e9301]
196
+ - Updated dependencies [2810695]
197
+ - @objectstack/spec@17.2.0
198
+
199
+ ## 17.1.0
200
+
201
+ ### Minor Changes
202
+
203
+ - 2782805: feat(security): the REST 401 anonymous-deny body carries `code: "UNAUTHENTICATED"` alongside the existing `error` / `message` keys (#9487)
204
+
205
+ Every other REST error family answers `{ error, code }`, with the machine code
206
+ in `code` — the 401 family was the one outlier, answering
207
+ `{ error: "UNAUTHENTICATED", message }` with no `code` key at all. A client
208
+ keying on `body.code` (the shape the other families teach, and the first read
209
+ of `@objectstack/client`'s `err.code`) read `undefined` for every
210
+ authentication failure.
211
+
212
+ `ANONYMOUS_DENY_BODY` now carries `code: "UNAUTHENTICATED"` as well.
213
+ **Additive only** (maintainer-ruled): no key is removed or moved — `error`
214
+ keeps holding the same code value it always has, so every existing reader
215
+ keeps working. The wire effect surfaces through `@objectstack/rest`'s
216
+ `enforceAuth`, which writes this constant verbatim on every `/data`, `/meta`
217
+ and `/reports` 401. This does not settle ADR-0112 D5 (flat vs nested envelope
218
+ convergence); both declared envelope families are unchanged in kind.
219
+ - e43d63a: feat(identity): API keys are minted against the minter's active organization, and carry it into the request (#8287)
220
+
221
+ <!-- adr-0087: not-required (no-migration-prescription) One additive column on
222
+ an `isSystem` object declaring `protection: { lock: 'full' }`, which tenants
223
+ cannot author, so there is no consumer metadata to migrate and nothing
224
+ authorable is renamed, retired or tombstoned — no conversion to register. The
225
+ behavioural change is that a minted key now carries an organization, that a key
226
+ which cannot carry one is refused under the posture where it could never read
227
+ anything, and that an ex-member's key stops authenticating. -->
228
+
229
+ On a deployment running `OS_TENANCY_POSTURE=isolated`, a minted API key could
230
+ read **nothing at all**. `sys_api_key` carried no organization column, so key
231
+ authentication established a user but no active organization — and the
232
+ `isolated` Layer 0 wall is `organization_id = activeOrganizationId`, which with
233
+ no active organization matches no row. Every organization-scoped read answered
234
+ `200` with `total 0` while the console went on offering minting, so a tenant
235
+ admin could mint a valid-looking secret and discover only at call time that it
236
+ read nothing. (There was no cross-tenant leak — the failure was in the other
237
+ direction.)
238
+
239
+ **The column was absent by an inherited rule, not by oversight.**
240
+ `resolveInjectedSystemColumns` injects `organization_id` into every registered
241
+ object *except* `managedBy: 'better-auth'` ones, and `sys_api_key` carries that
242
+ flag — even though better-auth's `apiKey` plugin is not loaded and the table is
243
+ hand-rolled ObjectStack. So the fix needs the declaration *and* the ADR-0105 D7
244
+ extension-field registration to stay consistent. The read side, by contrast,
245
+ was **already wired**: `resolveApiKeyPrincipal` already read an organization
246
+ into `tenantId` and `resolveAuthzContext` already adopted it — it was reading a
247
+ column no mint path ever wrote.
248
+
249
+ **What changes**
250
+
251
+ - `sys_api_key` declares `active_organization_id` (+ index, and the column is
252
+ shown in the "My Keys" and "All" list views, because the card's complaint was
253
+ a credential whose reach its owner could not see).
254
+ - `POST /api/v1/keys` **inherits** the caller's active organization — there is
255
+ deliberately no org parameter and no cross-org key — and **re-checks the
256
+ caller's `sys_member` membership at mint time**, honouring ADR-0091 validity
257
+ windows. Under a walled posture it refuses (400) rather than minting a key
258
+ with no organization, and refuses (403) for an organization the caller is not
259
+ a member of. The mint response echoes the organization the key is pinned to.
260
+ - The verifier reads **one spelling** (Prime Directive #12): the
261
+ `row.organization_id ?? row.organizationId` chain it used to carry was a
262
+ consumer-side tolerance for a producer that did not exist.
263
+ - An **ex-member's key fails closed at verify time** — no principal, not a
264
+ degrade to a user-only principal, which would resurrect the same
265
+ `200 + total 0` silent-empty. Checked at verify rather than by revoking on
266
+ membership loss, because membership ends through many paths (better-auth org
267
+ endpoints, SCIM, a direct `sys_member` delete, a lapsing validity window) and
268
+ a hook must catch every one or it silently misses. It costs **zero extra
269
+ queries**: the resolver has already read `sys_member` for this user.
270
+ - **Pre-existing org-less keys are never backfilled** — that would silently
271
+ upgrade credentials minted under a different promise. They keep working under
272
+ `single` (no wall) and under `group` (whose wall derives from the owner's
273
+ memberships independently of the active organization, so they already work
274
+ there), and are **refused under `isolated`**, where they are provably dead
275
+ today.
276
+
277
+ **The column is deliberately named `active_organization_id`, not
278
+ `organization_id`** — the `sys_session` spelling, for the same concept: the
279
+ organization a credential makes *active*. `objectHasOrgIdField` tests for the
280
+ literal `organization_id`, and Layer 0 exempts objects without it, so the other
281
+ name would have made `sys_api_key` itself org-walled. Both walled postures
282
+ exclude NULL, so every pre-existing org-less row would have vanished from its
283
+ **own owner's** "My Keys" list while, under `group`, continuing to
284
+ authenticate — a live credential nobody could see or revoke, which is a fresh
285
+ instance of the very class this change removes.
286
+ - a38408a: fix(core): both kernels agree that a duplicate plugin registration OVERWRITES, and say so out loud (#9864)
287
+
288
+ Registering two plugins under the same `name` used to mean two different things
289
+ depending on which kernel was running:
290
+
291
+ | kernel | behaviour before |
292
+ |---|---|
293
+ | `ObjectKernel` (what `os serve` runs) | accepted and overwrote, with **no check and no distinguishing log line** — `Plugin registered: <name>@<version>` printed twice, reading as two plugins running |
294
+ | `LiteKernel` (tests, serverless, edge) | threw `[Kernel] Plugin '<name>' already registered` |
295
+
296
+ Under the maintainer's ruling (2026-08-19, option B) both kernels now apply one
297
+ declared contract: **duplicate registration by `name` overwrites — last-one-wins
298
+ — and emits a `warn` naming the plugin and both versions.**
299
+
300
+ ```
301
+ WARN Plugin superseded: 'com.objectstack.audit' — the later registration (v2.0.0)
302
+ REPLACED the earlier one (v1.0.0). Only the later instance is initialized and
303
+ started; the earlier one is discarded without ever running init(). Duplicate
304
+ registration by name is last-one-wins on both kernels by declared contract
305
+ (#9864) — register the plugin once if that is not what you meant.
306
+ ```
307
+
308
+ **This declares and warns about behaviour that already shipped; it does not fix a
309
+ user-visible bug.** The overwrite is load-bearing today — it is exactly what lets
310
+ a stack's own `plugins` entry supersede a plugin the CLI auto-registered earlier
311
+ in the same boot (`AuditPlugin`, #9863) — and every boot path that worked before
312
+ works the same way now. What changes is that the behaviour is declared, audible,
313
+ and pinned against **both** kernels
314
+ (`packages/core/src/plugin-registration.contract.test.ts`) rather than being an
315
+ accident of whichever kernel a reader happened to open. This was the fourth
316
+ measured instance of one contract implemented twice across the two kernels
317
+ (#5170, #5282, #8357 adjacent).
318
+
319
+ **What this changes for a caller**
320
+
321
+ - `LiteKernel.use()` no longer throws on a duplicate name. FROM: catch
322
+ `[Kernel] Plugin '<name>' already registered` to detect a double registration.
323
+ TO: there is no throw to catch — a duplicate is a `warn` and the later instance
324
+ wins. Code that registered a plugin twice and relied on the refusal should
325
+ register it once instead.
326
+ - `ObjectKernel` emits one `warn` where it previously emitted nothing, and
327
+ **suppresses** its `Plugin registered:` line for the superseding registration,
328
+ so the count of those lines equals the number of plugins that actually boot.
329
+ - The level is part of the contract: `warn`, never `info`. The CLI's default
330
+ kernel level is `warn`, and its boot-quiet window replays `warn` while
331
+ discarding in-window `info` — an `info` notice would be invisible on exactly
332
+ the boot path where this was measured.
333
+
334
+ **Measured, not assumed:** the displaced instance holds nothing that needs
335
+ teardown. Registration is legal only while the kernel is `idle`, so a supersede
336
+ can only ever displace a plugin that has never been initialized; `init()`,
337
+ `start()` and `destroy()` all run later, over a registry the displaced entry has
338
+ already left. `PluginLoader.loadPlugin()` — which `ObjectKernel` runs first — is
339
+ pure validation plus a name-keyed map write of its own, and invokes nothing on
340
+ the plugin. Calling `destroy()` on the displaced instance would be the bug, not
341
+ the fix: it is the paired teardown for an `init()` that never ran.
342
+ - 5f5e234: fix(security): `sys_permission_set.active` and `sys_position.active` now actually stop granting access (#8613)
343
+
344
+ <!-- adr-0087: not-required (no-migration-prescription) Nothing authorable is
345
+ added, renamed or retired. `active` is a ROW property of a `sys_permission_set`
346
+ / `sys_position` record, not a key on `PermissionSetSchema` (which is a strict
347
+ object in `packages/spec` and deliberately declares no such key — see
348
+ `permission-set-projection.ts`'s ROW_STATE_COLUMNS). No spec schema, export or
349
+ stored metadata shape changes, so there is no conversion to register and no
350
+ tombstone to write. The change is a runtime predicate at the authorization
351
+ resolution seam; the remedy for an affected deployment is operational
352
+ (re-activate rows that were switched off), not a metadata migration. -->
353
+
354
+ **BREAKING for deployments that already switched a permission set or position
355
+ off.** Both objects ship a Deactivate action whose confirmation dialog promises,
356
+ in all four locales, that access stops:
357
+
358
+ > Deactivate this permission set? Existing assignments stay in place but stop
359
+ > granting access until re-activated.
360
+ > Deactivate this position? Users keep their assignment but the position stops
361
+ > granting permissions until re-activated.
362
+
363
+ Nothing read the column. Measured on the real resolver: a position seeded
364
+ `active: false` still granted its permission sets, and a permission set seeded
365
+ `active: false` still returned `posture: PLATFORM_ADMIN` with its system
366
+ permissions. Deactivation moved a badge in Setup and nothing else — while the
367
+ admin who had just revoked a compromised or over-broad grant was told the
368
+ opposite, and whose likely next step was therefore *not* the action that would
369
+ have worked (delete the set, or remove the assignments).
370
+
371
+ **What changes at runtime.** `resolveAuthzContext` / `resolveUserAuthzGrants`
372
+ (`@objectstack/core`) — the single seam every transport resolves authorization
373
+ through — now drop a deactivated row **before** any derivation:
374
+
375
+ - a deactivated `sys_position` no longer contributes its
376
+ `sys_position_permission_set` grants, and its name leaves `positions` (so the
377
+ name-reuse path cannot resolve the same grant one layer down);
378
+ - a deactivated `sys_permission_set` contributes no name, no
379
+ `system_permissions`, no `tab_permissions`, **and no `PLATFORM_ADMIN`
380
+ posture** — the flag is applied before the posture is derived, not after;
381
+ - the `plugin-security` DB loader applies the same predicate, which is what
382
+ judges a set reached by NAME through an active position of the same name.
383
+
384
+ Both tables were already read at that seam, so this costs **zero new hot-path
385
+ queries**.
386
+
387
+ **⚠️ Read this before upgrading.** Any `sys_permission_set` or `sys_position`
388
+ row currently carrying `active: false` **stops granting the moment this
389
+ lands** — on live data, with no migration step to notice. That is the correct
390
+ direction (it is what the dialog said when someone clicked Deactivate), but on
391
+ an installation that used the switch believing it was inert it is a real
392
+ revocation. Before upgrading, list the deactivated rows and re-activate any that
393
+ are still meant to grant:
394
+
395
+ ```
396
+ GET /api/v1/data/sys_permission_set?filters=[["active","=",false]]
397
+ GET /api/v1/data/sys_position?filters=[["active","=",false]]
398
+ ```
399
+
400
+ A row whose `active` column is **absent or NULL** is unaffected: the predicate
401
+ is "explicitly deactivated", never "explicitly active", so rows that predate the
402
+ column keep granting exactly as before.
403
+
404
+ **Break-glass, closed in the same change** (`@objectstack/plugin-auth`).
405
+ Enforcing the flag opened a one-click, installation-wide lockout: deactivating
406
+ `admin_full_access` un-makes every platform admin at once, through a payload
407
+ that touches neither `name` nor any identity table, and re-activating requires
408
+ the permission the click just took away (the seeders deliberately never
409
+ reconcile `active`, so no restart restores it). The last-administrator guard now
410
+ judges that write like the delete and rename spellings it already refused, and
411
+ an environment whose break-glass set is *already* off is read as emptied rather
412
+ than as a bootstrap window — so it does not silently disarm the guard for every
413
+ other identity write. Re-activation itself stays permitted, or the refusal would
414
+ have no way out from inside the product.
415
+ - f8eb736: feat(security): bind the break-glass standing-key lists to what the authz resolver actually reads — the correspondence stops being prose (#8734)
416
+
417
+ `plugin-auth`'s last-administrator guard (ADR-0024 D5.2) decides whether a
418
+ pending write can empty the administrator population by testing the payload
419
+ against three standing-key lists (`MEMBER_STANDING_KEYS`,
420
+ `GRANT_STANDING_KEYS`, `PERMISSION_SET_STANDING_KEYS`). A payload touching none
421
+ of them is skipped without any reads — so a column `resolveAuthzContext` starts
422
+ reading that a list omits is a write class the guard **silently stops judging**,
423
+ on the one path whose failure mode is an installation-wide administrator lockout
424
+ with no in-product recovery.
425
+
426
+ Nothing bound the two together. The correspondence lived in a comment, and it
427
+ had already gone false once: #6084 wrote — naming `active` explicitly — that
428
+ everything a permission-set write touches other than `name` is invisible to "who
429
+ is an administrator". That was true when written; #8613 made `active` a
430
+ resolution-time predicate and the sentence became false. Nothing mechanical
431
+ would have caught it, because the guard's own tests stay green precisely when
432
+ the guard is never consulted.
433
+
434
+ **The mechanism is two links, and the first one is a measurement.**
435
+
436
+ - `@objectstack/core` now exports `ADMIN_STANDING_SURFACE` — declared beside the
437
+ resolver, listing every table the administrator-derivation path reads, each
438
+ classified `derives` or `reads-only` with its reason, and for the deriving
439
+ tables every column read. It is asserted **equal** to what the real
440
+ `resolveAuthzContext` reads, observed at runtime through a recording engine
441
+ that records every property access and every `where` key per table. Observation
442
+ rather than source extraction because the reads that matter have moved into
443
+ helpers: `active` is read by `isRowActive(row)` and the ADR-0091 window bounds
444
+ by `isGrantActive(row, now)`, neither named at the resolver's own call site —
445
+ the exact shape #8613 had.
446
+
447
+ - `@objectstack/plugin-auth` now exports its standing-key lists plus
448
+ `STANDING_KEYS_BY_TABLE` and `STANDING_KEY_EXCLUSIONS`, and a gate requires
449
+ every column of that measured surface to have an answer: it is standing-bearing
450
+ (in a list) or it is excluded with the reason it cannot empty the administrator
451
+ population. There is no third state — the third state is what `active` was
452
+ between #6084 and #8613.
453
+
454
+ So a resolver change that starts reading a new column fails at the first link
455
+ until the declaration is updated, and at the second until the guard has an
456
+ explicit answer for it. Landing #8613 green would have required writing down that
457
+ deactivating `admin_full_access` cannot empty the administrator population —
458
+ which is false, and which is what the old comment asserted by accident.
459
+
460
+ **No guard behaviour changes.** Every list keeps exactly the values it had; the
461
+ gate is one-directional by construction (it can only ever demand that the guard
462
+ judges *more*), because the other direction would put pressure on a break-glass
463
+ guard to fire less often.
464
+
465
+ The table-level half is covered too: a resolver that started deriving
466
+ administrator standing from a **new** table is invisible to any column-set
467
+ comparison, since the table is absent from both sides — so the surface enumerates
468
+ every table the path reads, and an unclassified one fails.
469
+
470
+ ### Patch Changes
471
+
472
+ - 7ff3975: feat(spec): `IHttpServer` gains an optional `afterResponse` response-observing
473
+ hook so HTTP metrics are transport-agnostic instead of Hono-only (#9835)
474
+
475
+ The contract addition (additive — a new optional member plus the
476
+ `HttpResponseObservation` / `HttpResponseObserver` types and the reserved
477
+ `UNMATCHED_ROUTE_PATTERN` label): a transport invokes each registered observer
478
+ exactly once per answered request with `{ method, routePattern, status,
479
+ elapsedMs }`, after the response exists — the observation point the `use()`
480
+ middleware contract cannot express (it runs before dispatch and never sees a
481
+ status). `routePattern` is REQUIRED to be the registered route pattern
482
+ (`/api/v1/data/:id`), never the concrete path, so no adapter re-decides metric
483
+ cardinality. Optionality is feature-detected runtime-real
484
+ (`typeof server.afterResponse === 'function'`); a transport that does not
485
+ implement the seam reports **no** HTTP metrics — zero there means "not
486
+ instrumented", never "no traffic".
487
+
488
+ Implementations and consumers in the same change:
489
+
490
+ - `@objectstack/plugin-hono-server`: `HonoHttpServer` implements the seam (the
491
+ ruled #9650 raw-app middleware becomes its delivery path — same reach,
492
+ including `getRawApp()` mounts and middleware-refused 429s); unrouted
493
+ requests are now labelled with the reserved `unmatched` pattern (previously
494
+ they could surface as `/*`).
495
+ - `@objectstack/observability`: new `armHttpRequestCounter(server, metrics)`
496
+ arms the `http_requests_total` counter through the seam at most once per
497
+ server (first caller wins), which is what makes "exactly one counter per
498
+ server" structural.
499
+ - `@objectstack/runtime`: the dispatcher offers its `observability.metrics`
500
+ registry to the seam (a host that wires only the dispatcher now counts every
501
+ inbound surface) and suppresses its own per-route copy of
502
+ `http_requests_total` when the transport implements the seam — retiring the
503
+ #9833 double count. Request-id echo, the duration histogram, the error
504
+ counter and the error reporter are unchanged.
505
+ - `@objectstack/http-conformance`: `NodeHttpServer` implements the seam, and a
506
+ new cross-adapter conformance suite locks the semantics for both adapters.
507
+ - `@objectstack/core`: re-exports the new contract types/constant.
508
+ - 24173e9: fix(rest): read an offset-free import cell in the business timezone, not the host `TZ` (#8485)
509
+
510
+ `parseDateCell` ended in `new Date(s)`. A spreadsheet cell like
511
+ `2026-08-01 06:00:00` carries no offset, so ECMAScript resolves it against the
512
+ **process** timezone, and the instant bulk import stored became a property of
513
+ the deployment host:
514
+
515
+ ```
516
+ TZ=Asia/Shanghai → 2026-07-31T22:00:00.000Z
517
+ TZ=UTC → 2026-08-01T06:00:00.000Z
518
+ ```
519
+
520
+ Same file, same tenant, same cell — eight hours apart, decided by a setting
521
+ nobody authoring the spreadsheet can see, and never consulting the business
522
+ timezone the route had already resolved one frame up
523
+ (`ExecutionContext.timezone`, the platform-default → global → tenant cascade).
524
+
525
+ Since the export renders `datetime` cells in that business timezone (#8373), the
526
+ advertised export → edit in a spreadsheet → re-import round trip was lossless
527
+ only where the host `TZ` happened to equal the business zone. `import-coerce.ts`
528
+ opens by calling itself "the inverse of `export-format.ts`"; it now is one, and
529
+ the regression proof asserts inverse-ness on the **pair** — every fixture under
530
+ a host `TZ` deliberately different from the business timezone, because a test
531
+ that runs only under a matching `TZ` cannot fail.
532
+
533
+ **An offset-free datetime cell is now read in the caller's business timezone**,
534
+ through `@objectstack/core`'s new `zonedWallClockToUtcMs` — the DST-safe wall
535
+ clock → instant primitive that `zonedDateStartToUtcMs` (the date-bucket drill
536
+ path) is now the midnight special case of. One implementation of zone
537
+ arithmetic, `Intl` offsets from the platform tz database, never hand-rolled;
538
+ generalising the existing one rather than hand-rolling a second in `rest` is
539
+ what keeps the export and import halves of this seam from drifting apart again.
540
+ Two wall clocks are not a bijection with instants, and both degenerate DST
541
+ readings resolve to the earlier candidate instant — a gap reading lands just
542
+ before the gap, an ambiguous reading on its first occurrence (pinned, measured).
543
+
544
+ Three things deliberately do **not** move:
545
+
546
+ - **A cell that carries an explicit offset** (`…Z`, `…+08:00`) already names one
547
+ instant and is honoured exactly as written. This change affects naive cells
548
+ only.
549
+ - **The date-only fast path stays UTC.** `YYYY-MM-DD` is UTC per ECMAScript and
550
+ a `date` is a timezone-naive calendar day (ADR-0053); sweeping it into the
551
+ zoned handling to make the code look uniform would silently re-time every
552
+ date-only import to fix nothing.
553
+ - **No timezone resolved ⇒ UTC**, never the process clock. That is the fallback
554
+ the export's cell path takes in the same case, so the round trip stays exact
555
+ for deployments that configure no zone — and a process-`TZ` fallback would
556
+ preserve the defect for exactly the deployments that cannot see it. This is
557
+ the one **behaviour change for existing deployments**: a host with a non-UTC
558
+ `TZ` and no resolved business timezone previously read naive cells in the host
559
+ clock and now reads them as UTC. An explicitly resolved `'UTC'` is a resolved
560
+ zone, not a missing one.
561
+
562
+ Two adjacent legs of the same defect, both on the naive-cell path:
563
+
564
+ - **A naive cell landing in a `date` or `time` field** now takes the typed
565
+ components verbatim (`2026-08-01 06:00:00` → `2026-08-01` / `06:00:00`).
566
+ Those branches also read the process clock, so a host east of the cell stored
567
+ the *previous calendar day* for a `date` column.
568
+ - **An xlsx date cell.** An Excel serial date carries no timezone; ExcelJS
569
+ materialises it as a `Date` whose UTC components are the sheet's wall clock,
570
+ and `import-prepare.ts` rendered it with `toISOString()` — stamping a `Z` the
571
+ file never had. That fabricated offset then outranked the business timezone by
572
+ the very carve-out above, so every real date cell in a user-authored workbook
573
+ imported as UTC whatever the tenant's zone. It now flattens to the same
574
+ offset-free `YYYY-MM-DD HH:mm:ss` a CSV export writes, which is what that
575
+ function's contract already claimed to produce.
576
+ - e1bb0ca: fix(qa): `HttpTestAdapter` resolves the Data Protocol mount from the server's `/discovery`, and falls back to the convention loudly (#7983)
577
+
578
+ The record-shaped `os test` action types (`create_record`, `read_record`,
579
+ `update_record`, `delete_record`, `query_records`) built their URLs from the
580
+ **defaults** of `RestApiConfigSchema.apiPath` and
581
+ `CrudEndpointsConfigSchema.dataPrefix`, because the adapter is handed an origin
582
+ and nothing else. A deployment that moved the mount got a 404 that reads like the
583
+ suite author's own URL mistake rather than a platform limitation.
584
+
585
+ The adapter now asks the server, following the `getRoute` precedent in
586
+ `@objectstack/client`: **one memoised `GET {apiBase}/discovery` per run** (`os
587
+ test` builds one adapter for the whole run), addressing whatever `routes.data`
588
+ advertises, with the schema-derived convention as the fallback. Measured on a
589
+ booted stack (REST route generator + dispatcher bridge), before and after:
590
+
591
+ | deployment | before | after |
592
+ |---|---|---|
593
+ | stock | created | created |
594
+ | `crud.dataPrefix: '/objects'` | `HTTP Error 404` | created |
595
+ | `api.apiPath: '/api/2026-01'` | `HTTP Error 404` | `HTTP Error 404`, now naming the mount |
596
+
597
+ The `apiPath` row is **not** closed, and the reason is structural: `apiPath`
598
+ moves the base that `/discovery` is itself mounted under, so the document that
599
+ would name the new mount sits behind the prefix that is missing. The one
600
+ discovery document at a fixed path does not rescue it — `/.well-known/objectstack`
601
+ advertises the **dispatcher's** `${prefix}/data`, measured as `/api/v1/data`
602
+ under all three configs above — so it is deliberately not probed: trusting it
603
+ would attach a false provenance ("discovery told us") to the same 404.
604
+
605
+ Instead that case degrades loudly. Falling back to the convention prints a
606
+ warning naming the mount it will address, the probe that failed and the remedy,
607
+ and every 404/405 from a record action now carries the mount it addressed and
608
+ where that mount came from. `api_call` is unchanged, issues no probe, and remains
609
+ the escape hatch for a host the probe cannot reach.
610
+ - 402c125: fix(objectql): a temporal filter comparand the platform cannot interpret is refused at the engine door instead of answering 200 with zero rows (#8690)
611
+
612
+ <!-- adr-0087: not-required (no-migration-prescription) Nothing authorable is
613
+ renamed, retired or tombstoned — no spec schema is touched at all. The change
614
+ is a new runtime refusal at the engine's filter collection point, plus the
615
+ routing decline that stops the raw-SQL analytics path bypassing it. -->
616
+
617
+ A `datetime` / `date` / `time` field filtered with a bare string the platform
618
+ cannot read — `last_30_days`, `not-a-date-at-all` — was bound **as written**
619
+ all the way to the driver, where the comparison is false for every row. The
620
+ caller received `HTTP 200`, an empty result set, and nothing to indicate the
621
+ filter was meaningless. An unknown `{placeholder}` in the same position was
622
+ already refused loudly (`FILTER_TOKEN_UNKNOWN` / 400, listing the resolvable
623
+ tokens), so one API answered two shapes of unusable comparand two different
624
+ ways.
625
+
626
+ It is concretely reachable rather than theoretical: `last_7_days` /
627
+ `last_30_days` / `last_90_days` are **declared preset names** in the dashboard
628
+ schema. The shipped console lowers them to `{N_days_ago}` macros before they
629
+ reach the API, so the console path was always safe — but a saved report, an
630
+ integration, an MCP client or an AI-authored query sends the preset name itself
631
+ and got a silent zero. An empty chart is the hardest failure to debug: it is
632
+ indistinguishable from "there is genuinely no data".
633
+
634
+ Such a comparand is now refused at the ObjectQL engine's single filter
635
+ collection point, with `code: 'INVALID_FILTER'` and `status: 400`, naming the
636
+ field, the value, the key path and the spellings that would work. That seam is
637
+ the one place holding the caller's comparand and the field's **declared type**
638
+ at the same moment, and every verb (`find` / `findOne` / `count` / `aggregate`
639
+ / `update` / `delete`) and both filter spellings (the array sugar and the
640
+ lowered condition) pass through it, so all four backends inherit one answer
641
+ rather than four. `NativeSQLStrategy` additionally **declines** such a query so
642
+ the raw-SQL analytics path falls through to that door instead of binding the
643
+ value into its own statement.
644
+
645
+ Deliberately unchanged, each by ruling: a `{placeholder}` keeps its existing
646
+ refusal one layer down (the door runs before token resolution and steps around
647
+ them, so `{30_days_ago}` still resolves normally); non-string comparands are
648
+ untouched (a number is epoch milliseconds, a `Date` is an instant); and the
649
+ **empty string** keeps today's behaviour exactly — it binds as `''` and matches
650
+ every non-null row, which is a separate question that remains its own card.
651
+ - Updated dependencies [56656aa]
652
+ - Updated dependencies [07e630e]
653
+ - Updated dependencies [2f65b1b]
654
+ - Updated dependencies [720ee95]
655
+ - Updated dependencies [f287435]
656
+ - Updated dependencies [9aa8890]
657
+ - Updated dependencies [7c9c1dd]
658
+ - Updated dependencies [75b7c24]
659
+ - Updated dependencies [d5552ca]
660
+ - Updated dependencies [d9813a9]
661
+ - Updated dependencies [8640fb2]
662
+ - Updated dependencies [2420641]
663
+ - Updated dependencies [2ad91c3]
664
+ - Updated dependencies [f57fb38]
665
+ - Updated dependencies [00777a0]
666
+ - Updated dependencies [d491625]
667
+ - Updated dependencies [420804d]
668
+ - Updated dependencies [716ac9b]
669
+ - Updated dependencies [62b1427]
670
+ - Updated dependencies [7ea1372]
671
+ - Updated dependencies [23abe27]
672
+ - Updated dependencies [985a9cd]
673
+ - Updated dependencies [a8189ae]
674
+ - Updated dependencies [26e70fb]
675
+ - Updated dependencies [42b05af]
676
+ - Updated dependencies [2b292ce]
677
+ - Updated dependencies [abcf853]
678
+ - Updated dependencies [8b9eba5]
679
+ - Updated dependencies [d575779]
680
+ - Updated dependencies [94f7ef8]
681
+ - Updated dependencies [c5ac5e4]
682
+ - Updated dependencies [a777944]
683
+ - Updated dependencies [dd88e1c]
684
+ - Updated dependencies [856527c]
685
+ - Updated dependencies [870f710]
686
+ - Updated dependencies [79c46da]
687
+ - Updated dependencies [7ff3975]
688
+ - Updated dependencies [29d055b]
689
+ - Updated dependencies [65589d6]
690
+ - Updated dependencies [2c86fe3]
691
+ - Updated dependencies [e196c6a]
692
+ - Updated dependencies [4ab7523]
693
+ - Updated dependencies [19539b4]
694
+ - Updated dependencies [11b779e]
695
+ - Updated dependencies [739fe5b]
696
+ - Updated dependencies [4bfe1a5]
697
+ - Updated dependencies [2065e31]
698
+ - Updated dependencies [b69d0f5]
699
+ - Updated dependencies [4d47afe]
700
+ - Updated dependencies [e4e5c6e]
701
+ - Updated dependencies [9a56784]
702
+ - Updated dependencies [d00d2f6]
703
+ - Updated dependencies [df0c12d]
704
+ - Updated dependencies [d31785f]
705
+ - Updated dependencies [c308a4f]
706
+ - Updated dependencies [e2899f6]
707
+ - Updated dependencies [3851f87]
708
+ - Updated dependencies [2a29caa]
709
+ - Updated dependencies [09a6eee]
710
+ - Updated dependencies [1a7f907]
711
+ - Updated dependencies [cd455c8]
712
+ - Updated dependencies [30d3752]
713
+ - Updated dependencies [c80e7ae]
714
+ - Updated dependencies [09a9a8a]
715
+ - Updated dependencies [07026cf]
716
+ - Updated dependencies [5d4f3d5]
717
+ - Updated dependencies [4d80e8b]
718
+ - Updated dependencies [30b1c63]
719
+ - Updated dependencies [079b457]
720
+ - Updated dependencies [e43b211]
721
+ - Updated dependencies [890b38f]
722
+ - Updated dependencies [8bee54b]
723
+ - Updated dependencies [7a537ce]
724
+ - Updated dependencies [593c4bf]
725
+ - Updated dependencies [ff08691]
726
+ - Updated dependencies [60e0f90]
727
+ - Updated dependencies [90c5285]
728
+ - Updated dependencies [7901b2d]
729
+ - Updated dependencies [56bca91]
730
+ - Updated dependencies [79394d7]
731
+ - Updated dependencies [730fd9a]
732
+ - Updated dependencies [44bc51d]
733
+ - Updated dependencies [73cfddf]
734
+ - Updated dependencies [d634e66]
735
+ - @objectstack/spec@17.1.0
736
+
3
737
  ## 17.0.0
4
738
 
5
739
  ### Major Changes