@objectstack/core 17.0.0 → 17.1.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,543 @@
1
1
  # @objectstack/core
2
2
 
3
+ ## 17.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 2782805: feat(security): the REST 401 anonymous-deny body carries `code: "UNAUTHENTICATED"` alongside the existing `error` / `message` keys (#9487)
8
+
9
+ Every other REST error family answers `{ error, code }`, with the machine code
10
+ in `code` — the 401 family was the one outlier, answering
11
+ `{ error: "UNAUTHENTICATED", message }` with no `code` key at all. A client
12
+ keying on `body.code` (the shape the other families teach, and the first read
13
+ of `@objectstack/client`'s `err.code`) read `undefined` for every
14
+ authentication failure.
15
+
16
+ `ANONYMOUS_DENY_BODY` now carries `code: "UNAUTHENTICATED"` as well.
17
+ **Additive only** (maintainer-ruled): no key is removed or moved — `error`
18
+ keeps holding the same code value it always has, so every existing reader
19
+ keeps working. The wire effect surfaces through `@objectstack/rest`'s
20
+ `enforceAuth`, which writes this constant verbatim on every `/data`, `/meta`
21
+ and `/reports` 401. This does not settle ADR-0112 D5 (flat vs nested envelope
22
+ convergence); both declared envelope families are unchanged in kind.
23
+ - e43d63a: feat(identity): API keys are minted against the minter's active organization, and carry it into the request (#8287)
24
+
25
+ <!-- adr-0087: not-required (no-migration-prescription) One additive column on
26
+ an `isSystem` object declaring `protection: { lock: 'full' }`, which tenants
27
+ cannot author, so there is no consumer metadata to migrate and nothing
28
+ authorable is renamed, retired or tombstoned — no conversion to register. The
29
+ behavioural change is that a minted key now carries an organization, that a key
30
+ which cannot carry one is refused under the posture where it could never read
31
+ anything, and that an ex-member's key stops authenticating. -->
32
+
33
+ On a deployment running `OS_TENANCY_POSTURE=isolated`, a minted API key could
34
+ read **nothing at all**. `sys_api_key` carried no organization column, so key
35
+ authentication established a user but no active organization — and the
36
+ `isolated` Layer 0 wall is `organization_id = activeOrganizationId`, which with
37
+ no active organization matches no row. Every organization-scoped read answered
38
+ `200` with `total 0` while the console went on offering minting, so a tenant
39
+ admin could mint a valid-looking secret and discover only at call time that it
40
+ read nothing. (There was no cross-tenant leak — the failure was in the other
41
+ direction.)
42
+
43
+ **The column was absent by an inherited rule, not by oversight.**
44
+ `resolveInjectedSystemColumns` injects `organization_id` into every registered
45
+ object *except* `managedBy: 'better-auth'` ones, and `sys_api_key` carries that
46
+ flag — even though better-auth's `apiKey` plugin is not loaded and the table is
47
+ hand-rolled ObjectStack. So the fix needs the declaration *and* the ADR-0105 D7
48
+ extension-field registration to stay consistent. The read side, by contrast,
49
+ was **already wired**: `resolveApiKeyPrincipal` already read an organization
50
+ into `tenantId` and `resolveAuthzContext` already adopted it — it was reading a
51
+ column no mint path ever wrote.
52
+
53
+ **What changes**
54
+
55
+ - `sys_api_key` declares `active_organization_id` (+ index, and the column is
56
+ shown in the "My Keys" and "All" list views, because the card's complaint was
57
+ a credential whose reach its owner could not see).
58
+ - `POST /api/v1/keys` **inherits** the caller's active organization — there is
59
+ deliberately no org parameter and no cross-org key — and **re-checks the
60
+ caller's `sys_member` membership at mint time**, honouring ADR-0091 validity
61
+ windows. Under a walled posture it refuses (400) rather than minting a key
62
+ with no organization, and refuses (403) for an organization the caller is not
63
+ a member of. The mint response echoes the organization the key is pinned to.
64
+ - The verifier reads **one spelling** (Prime Directive #12): the
65
+ `row.organization_id ?? row.organizationId` chain it used to carry was a
66
+ consumer-side tolerance for a producer that did not exist.
67
+ - An **ex-member's key fails closed at verify time** — no principal, not a
68
+ degrade to a user-only principal, which would resurrect the same
69
+ `200 + total 0` silent-empty. Checked at verify rather than by revoking on
70
+ membership loss, because membership ends through many paths (better-auth org
71
+ endpoints, SCIM, a direct `sys_member` delete, a lapsing validity window) and
72
+ a hook must catch every one or it silently misses. It costs **zero extra
73
+ queries**: the resolver has already read `sys_member` for this user.
74
+ - **Pre-existing org-less keys are never backfilled** — that would silently
75
+ upgrade credentials minted under a different promise. They keep working under
76
+ `single` (no wall) and under `group` (whose wall derives from the owner's
77
+ memberships independently of the active organization, so they already work
78
+ there), and are **refused under `isolated`**, where they are provably dead
79
+ today.
80
+
81
+ **The column is deliberately named `active_organization_id`, not
82
+ `organization_id`** — the `sys_session` spelling, for the same concept: the
83
+ organization a credential makes *active*. `objectHasOrgIdField` tests for the
84
+ literal `organization_id`, and Layer 0 exempts objects without it, so the other
85
+ name would have made `sys_api_key` itself org-walled. Both walled postures
86
+ exclude NULL, so every pre-existing org-less row would have vanished from its
87
+ **own owner's** "My Keys" list while, under `group`, continuing to
88
+ authenticate — a live credential nobody could see or revoke, which is a fresh
89
+ instance of the very class this change removes.
90
+ - a38408a: fix(core): both kernels agree that a duplicate plugin registration OVERWRITES, and say so out loud (#9864)
91
+
92
+ Registering two plugins under the same `name` used to mean two different things
93
+ depending on which kernel was running:
94
+
95
+ | kernel | behaviour before |
96
+ |---|---|
97
+ | `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 |
98
+ | `LiteKernel` (tests, serverless, edge) | threw `[Kernel] Plugin '<name>' already registered` |
99
+
100
+ Under the maintainer's ruling (2026-08-19, option B) both kernels now apply one
101
+ declared contract: **duplicate registration by `name` overwrites — last-one-wins
102
+ — and emits a `warn` naming the plugin and both versions.**
103
+
104
+ ```
105
+ WARN Plugin superseded: 'com.objectstack.audit' — the later registration (v2.0.0)
106
+ REPLACED the earlier one (v1.0.0). Only the later instance is initialized and
107
+ started; the earlier one is discarded without ever running init(). Duplicate
108
+ registration by name is last-one-wins on both kernels by declared contract
109
+ (#9864) — register the plugin once if that is not what you meant.
110
+ ```
111
+
112
+ **This declares and warns about behaviour that already shipped; it does not fix a
113
+ user-visible bug.** The overwrite is load-bearing today — it is exactly what lets
114
+ a stack's own `plugins` entry supersede a plugin the CLI auto-registered earlier
115
+ in the same boot (`AuditPlugin`, #9863) — and every boot path that worked before
116
+ works the same way now. What changes is that the behaviour is declared, audible,
117
+ and pinned against **both** kernels
118
+ (`packages/core/src/plugin-registration.contract.test.ts`) rather than being an
119
+ accident of whichever kernel a reader happened to open. This was the fourth
120
+ measured instance of one contract implemented twice across the two kernels
121
+ (#5170, #5282, #8357 adjacent).
122
+
123
+ **What this changes for a caller**
124
+
125
+ - `LiteKernel.use()` no longer throws on a duplicate name. FROM: catch
126
+ `[Kernel] Plugin '<name>' already registered` to detect a double registration.
127
+ TO: there is no throw to catch — a duplicate is a `warn` and the later instance
128
+ wins. Code that registered a plugin twice and relied on the refusal should
129
+ register it once instead.
130
+ - `ObjectKernel` emits one `warn` where it previously emitted nothing, and
131
+ **suppresses** its `Plugin registered:` line for the superseding registration,
132
+ so the count of those lines equals the number of plugins that actually boot.
133
+ - The level is part of the contract: `warn`, never `info`. The CLI's default
134
+ kernel level is `warn`, and its boot-quiet window replays `warn` while
135
+ discarding in-window `info` — an `info` notice would be invisible on exactly
136
+ the boot path where this was measured.
137
+
138
+ **Measured, not assumed:** the displaced instance holds nothing that needs
139
+ teardown. Registration is legal only while the kernel is `idle`, so a supersede
140
+ can only ever displace a plugin that has never been initialized; `init()`,
141
+ `start()` and `destroy()` all run later, over a registry the displaced entry has
142
+ already left. `PluginLoader.loadPlugin()` — which `ObjectKernel` runs first — is
143
+ pure validation plus a name-keyed map write of its own, and invokes nothing on
144
+ the plugin. Calling `destroy()` on the displaced instance would be the bug, not
145
+ the fix: it is the paired teardown for an `init()` that never ran.
146
+ - 5f5e234: fix(security): `sys_permission_set.active` and `sys_position.active` now actually stop granting access (#8613)
147
+
148
+ <!-- adr-0087: not-required (no-migration-prescription) Nothing authorable is
149
+ added, renamed or retired. `active` is a ROW property of a `sys_permission_set`
150
+ / `sys_position` record, not a key on `PermissionSetSchema` (which is a strict
151
+ object in `packages/spec` and deliberately declares no such key — see
152
+ `permission-set-projection.ts`'s ROW_STATE_COLUMNS). No spec schema, export or
153
+ stored metadata shape changes, so there is no conversion to register and no
154
+ tombstone to write. The change is a runtime predicate at the authorization
155
+ resolution seam; the remedy for an affected deployment is operational
156
+ (re-activate rows that were switched off), not a metadata migration. -->
157
+
158
+ **BREAKING for deployments that already switched a permission set or position
159
+ off.** Both objects ship a Deactivate action whose confirmation dialog promises,
160
+ in all four locales, that access stops:
161
+
162
+ > Deactivate this permission set? Existing assignments stay in place but stop
163
+ > granting access until re-activated.
164
+ > Deactivate this position? Users keep their assignment but the position stops
165
+ > granting permissions until re-activated.
166
+
167
+ Nothing read the column. Measured on the real resolver: a position seeded
168
+ `active: false` still granted its permission sets, and a permission set seeded
169
+ `active: false` still returned `posture: PLATFORM_ADMIN` with its system
170
+ permissions. Deactivation moved a badge in Setup and nothing else — while the
171
+ admin who had just revoked a compromised or over-broad grant was told the
172
+ opposite, and whose likely next step was therefore *not* the action that would
173
+ have worked (delete the set, or remove the assignments).
174
+
175
+ **What changes at runtime.** `resolveAuthzContext` / `resolveUserAuthzGrants`
176
+ (`@objectstack/core`) — the single seam every transport resolves authorization
177
+ through — now drop a deactivated row **before** any derivation:
178
+
179
+ - a deactivated `sys_position` no longer contributes its
180
+ `sys_position_permission_set` grants, and its name leaves `positions` (so the
181
+ name-reuse path cannot resolve the same grant one layer down);
182
+ - a deactivated `sys_permission_set` contributes no name, no
183
+ `system_permissions`, no `tab_permissions`, **and no `PLATFORM_ADMIN`
184
+ posture** — the flag is applied before the posture is derived, not after;
185
+ - the `plugin-security` DB loader applies the same predicate, which is what
186
+ judges a set reached by NAME through an active position of the same name.
187
+
188
+ Both tables were already read at that seam, so this costs **zero new hot-path
189
+ queries**.
190
+
191
+ **⚠️ Read this before upgrading.** Any `sys_permission_set` or `sys_position`
192
+ row currently carrying `active: false` **stops granting the moment this
193
+ lands** — on live data, with no migration step to notice. That is the correct
194
+ direction (it is what the dialog said when someone clicked Deactivate), but on
195
+ an installation that used the switch believing it was inert it is a real
196
+ revocation. Before upgrading, list the deactivated rows and re-activate any that
197
+ are still meant to grant:
198
+
199
+ ```
200
+ GET /api/v1/data/sys_permission_set?filters=[["active","=",false]]
201
+ GET /api/v1/data/sys_position?filters=[["active","=",false]]
202
+ ```
203
+
204
+ A row whose `active` column is **absent or NULL** is unaffected: the predicate
205
+ is "explicitly deactivated", never "explicitly active", so rows that predate the
206
+ column keep granting exactly as before.
207
+
208
+ **Break-glass, closed in the same change** (`@objectstack/plugin-auth`).
209
+ Enforcing the flag opened a one-click, installation-wide lockout: deactivating
210
+ `admin_full_access` un-makes every platform admin at once, through a payload
211
+ that touches neither `name` nor any identity table, and re-activating requires
212
+ the permission the click just took away (the seeders deliberately never
213
+ reconcile `active`, so no restart restores it). The last-administrator guard now
214
+ judges that write like the delete and rename spellings it already refused, and
215
+ an environment whose break-glass set is *already* off is read as emptied rather
216
+ than as a bootstrap window — so it does not silently disarm the guard for every
217
+ other identity write. Re-activation itself stays permitted, or the refusal would
218
+ have no way out from inside the product.
219
+ - f8eb736: feat(security): bind the break-glass standing-key lists to what the authz resolver actually reads — the correspondence stops being prose (#8734)
220
+
221
+ `plugin-auth`'s last-administrator guard (ADR-0024 D5.2) decides whether a
222
+ pending write can empty the administrator population by testing the payload
223
+ against three standing-key lists (`MEMBER_STANDING_KEYS`,
224
+ `GRANT_STANDING_KEYS`, `PERMISSION_SET_STANDING_KEYS`). A payload touching none
225
+ of them is skipped without any reads — so a column `resolveAuthzContext` starts
226
+ reading that a list omits is a write class the guard **silently stops judging**,
227
+ on the one path whose failure mode is an installation-wide administrator lockout
228
+ with no in-product recovery.
229
+
230
+ Nothing bound the two together. The correspondence lived in a comment, and it
231
+ had already gone false once: #6084 wrote — naming `active` explicitly — that
232
+ everything a permission-set write touches other than `name` is invisible to "who
233
+ is an administrator". That was true when written; #8613 made `active` a
234
+ resolution-time predicate and the sentence became false. Nothing mechanical
235
+ would have caught it, because the guard's own tests stay green precisely when
236
+ the guard is never consulted.
237
+
238
+ **The mechanism is two links, and the first one is a measurement.**
239
+
240
+ - `@objectstack/core` now exports `ADMIN_STANDING_SURFACE` — declared beside the
241
+ resolver, listing every table the administrator-derivation path reads, each
242
+ classified `derives` or `reads-only` with its reason, and for the deriving
243
+ tables every column read. It is asserted **equal** to what the real
244
+ `resolveAuthzContext` reads, observed at runtime through a recording engine
245
+ that records every property access and every `where` key per table. Observation
246
+ rather than source extraction because the reads that matter have moved into
247
+ helpers: `active` is read by `isRowActive(row)` and the ADR-0091 window bounds
248
+ by `isGrantActive(row, now)`, neither named at the resolver's own call site —
249
+ the exact shape #8613 had.
250
+
251
+ - `@objectstack/plugin-auth` now exports its standing-key lists plus
252
+ `STANDING_KEYS_BY_TABLE` and `STANDING_KEY_EXCLUSIONS`, and a gate requires
253
+ every column of that measured surface to have an answer: it is standing-bearing
254
+ (in a list) or it is excluded with the reason it cannot empty the administrator
255
+ population. There is no third state — the third state is what `active` was
256
+ between #6084 and #8613.
257
+
258
+ So a resolver change that starts reading a new column fails at the first link
259
+ until the declaration is updated, and at the second until the guard has an
260
+ explicit answer for it. Landing #8613 green would have required writing down that
261
+ deactivating `admin_full_access` cannot empty the administrator population —
262
+ which is false, and which is what the old comment asserted by accident.
263
+
264
+ **No guard behaviour changes.** Every list keeps exactly the values it had; the
265
+ gate is one-directional by construction (it can only ever demand that the guard
266
+ judges *more*), because the other direction would put pressure on a break-glass
267
+ guard to fire less often.
268
+
269
+ The table-level half is covered too: a resolver that started deriving
270
+ administrator standing from a **new** table is invisible to any column-set
271
+ comparison, since the table is absent from both sides — so the surface enumerates
272
+ every table the path reads, and an unclassified one fails.
273
+
274
+ ### Patch Changes
275
+
276
+ - 7ff3975: feat(spec): `IHttpServer` gains an optional `afterResponse` response-observing
277
+ hook so HTTP metrics are transport-agnostic instead of Hono-only (#9835)
278
+
279
+ The contract addition (additive — a new optional member plus the
280
+ `HttpResponseObservation` / `HttpResponseObserver` types and the reserved
281
+ `UNMATCHED_ROUTE_PATTERN` label): a transport invokes each registered observer
282
+ exactly once per answered request with `{ method, routePattern, status,
283
+ elapsedMs }`, after the response exists — the observation point the `use()`
284
+ middleware contract cannot express (it runs before dispatch and never sees a
285
+ status). `routePattern` is REQUIRED to be the registered route pattern
286
+ (`/api/v1/data/:id`), never the concrete path, so no adapter re-decides metric
287
+ cardinality. Optionality is feature-detected runtime-real
288
+ (`typeof server.afterResponse === 'function'`); a transport that does not
289
+ implement the seam reports **no** HTTP metrics — zero there means "not
290
+ instrumented", never "no traffic".
291
+
292
+ Implementations and consumers in the same change:
293
+
294
+ - `@objectstack/plugin-hono-server`: `HonoHttpServer` implements the seam (the
295
+ ruled #9650 raw-app middleware becomes its delivery path — same reach,
296
+ including `getRawApp()` mounts and middleware-refused 429s); unrouted
297
+ requests are now labelled with the reserved `unmatched` pattern (previously
298
+ they could surface as `/*`).
299
+ - `@objectstack/observability`: new `armHttpRequestCounter(server, metrics)`
300
+ arms the `http_requests_total` counter through the seam at most once per
301
+ server (first caller wins), which is what makes "exactly one counter per
302
+ server" structural.
303
+ - `@objectstack/runtime`: the dispatcher offers its `observability.metrics`
304
+ registry to the seam (a host that wires only the dispatcher now counts every
305
+ inbound surface) and suppresses its own per-route copy of
306
+ `http_requests_total` when the transport implements the seam — retiring the
307
+ #9833 double count. Request-id echo, the duration histogram, the error
308
+ counter and the error reporter are unchanged.
309
+ - `@objectstack/http-conformance`: `NodeHttpServer` implements the seam, and a
310
+ new cross-adapter conformance suite locks the semantics for both adapters.
311
+ - `@objectstack/core`: re-exports the new contract types/constant.
312
+ - 24173e9: fix(rest): read an offset-free import cell in the business timezone, not the host `TZ` (#8485)
313
+
314
+ `parseDateCell` ended in `new Date(s)`. A spreadsheet cell like
315
+ `2026-08-01 06:00:00` carries no offset, so ECMAScript resolves it against the
316
+ **process** timezone, and the instant bulk import stored became a property of
317
+ the deployment host:
318
+
319
+ ```
320
+ TZ=Asia/Shanghai → 2026-07-31T22:00:00.000Z
321
+ TZ=UTC → 2026-08-01T06:00:00.000Z
322
+ ```
323
+
324
+ Same file, same tenant, same cell — eight hours apart, decided by a setting
325
+ nobody authoring the spreadsheet can see, and never consulting the business
326
+ timezone the route had already resolved one frame up
327
+ (`ExecutionContext.timezone`, the platform-default → global → tenant cascade).
328
+
329
+ Since the export renders `datetime` cells in that business timezone (#8373), the
330
+ advertised export → edit in a spreadsheet → re-import round trip was lossless
331
+ only where the host `TZ` happened to equal the business zone. `import-coerce.ts`
332
+ opens by calling itself "the inverse of `export-format.ts`"; it now is one, and
333
+ the regression proof asserts inverse-ness on the **pair** — every fixture under
334
+ a host `TZ` deliberately different from the business timezone, because a test
335
+ that runs only under a matching `TZ` cannot fail.
336
+
337
+ **An offset-free datetime cell is now read in the caller's business timezone**,
338
+ through `@objectstack/core`'s new `zonedWallClockToUtcMs` — the DST-safe wall
339
+ clock → instant primitive that `zonedDateStartToUtcMs` (the date-bucket drill
340
+ path) is now the midnight special case of. One implementation of zone
341
+ arithmetic, `Intl` offsets from the platform tz database, never hand-rolled;
342
+ generalising the existing one rather than hand-rolling a second in `rest` is
343
+ what keeps the export and import halves of this seam from drifting apart again.
344
+ Two wall clocks are not a bijection with instants, and both degenerate DST
345
+ readings resolve to the earlier candidate instant — a gap reading lands just
346
+ before the gap, an ambiguous reading on its first occurrence (pinned, measured).
347
+
348
+ Three things deliberately do **not** move:
349
+
350
+ - **A cell that carries an explicit offset** (`…Z`, `…+08:00`) already names one
351
+ instant and is honoured exactly as written. This change affects naive cells
352
+ only.
353
+ - **The date-only fast path stays UTC.** `YYYY-MM-DD` is UTC per ECMAScript and
354
+ a `date` is a timezone-naive calendar day (ADR-0053); sweeping it into the
355
+ zoned handling to make the code look uniform would silently re-time every
356
+ date-only import to fix nothing.
357
+ - **No timezone resolved ⇒ UTC**, never the process clock. That is the fallback
358
+ the export's cell path takes in the same case, so the round trip stays exact
359
+ for deployments that configure no zone — and a process-`TZ` fallback would
360
+ preserve the defect for exactly the deployments that cannot see it. This is
361
+ the one **behaviour change for existing deployments**: a host with a non-UTC
362
+ `TZ` and no resolved business timezone previously read naive cells in the host
363
+ clock and now reads them as UTC. An explicitly resolved `'UTC'` is a resolved
364
+ zone, not a missing one.
365
+
366
+ Two adjacent legs of the same defect, both on the naive-cell path:
367
+
368
+ - **A naive cell landing in a `date` or `time` field** now takes the typed
369
+ components verbatim (`2026-08-01 06:00:00` → `2026-08-01` / `06:00:00`).
370
+ Those branches also read the process clock, so a host east of the cell stored
371
+ the *previous calendar day* for a `date` column.
372
+ - **An xlsx date cell.** An Excel serial date carries no timezone; ExcelJS
373
+ materialises it as a `Date` whose UTC components are the sheet's wall clock,
374
+ and `import-prepare.ts` rendered it with `toISOString()` — stamping a `Z` the
375
+ file never had. That fabricated offset then outranked the business timezone by
376
+ the very carve-out above, so every real date cell in a user-authored workbook
377
+ imported as UTC whatever the tenant's zone. It now flattens to the same
378
+ offset-free `YYYY-MM-DD HH:mm:ss` a CSV export writes, which is what that
379
+ function's contract already claimed to produce.
380
+ - e1bb0ca: fix(qa): `HttpTestAdapter` resolves the Data Protocol mount from the server's `/discovery`, and falls back to the convention loudly (#7983)
381
+
382
+ The record-shaped `os test` action types (`create_record`, `read_record`,
383
+ `update_record`, `delete_record`, `query_records`) built their URLs from the
384
+ **defaults** of `RestApiConfigSchema.apiPath` and
385
+ `CrudEndpointsConfigSchema.dataPrefix`, because the adapter is handed an origin
386
+ and nothing else. A deployment that moved the mount got a 404 that reads like the
387
+ suite author's own URL mistake rather than a platform limitation.
388
+
389
+ The adapter now asks the server, following the `getRoute` precedent in
390
+ `@objectstack/client`: **one memoised `GET {apiBase}/discovery` per run** (`os
391
+ test` builds one adapter for the whole run), addressing whatever `routes.data`
392
+ advertises, with the schema-derived convention as the fallback. Measured on a
393
+ booted stack (REST route generator + dispatcher bridge), before and after:
394
+
395
+ | deployment | before | after |
396
+ |---|---|---|
397
+ | stock | created | created |
398
+ | `crud.dataPrefix: '/objects'` | `HTTP Error 404` | created |
399
+ | `api.apiPath: '/api/2026-01'` | `HTTP Error 404` | `HTTP Error 404`, now naming the mount |
400
+
401
+ The `apiPath` row is **not** closed, and the reason is structural: `apiPath`
402
+ moves the base that `/discovery` is itself mounted under, so the document that
403
+ would name the new mount sits behind the prefix that is missing. The one
404
+ discovery document at a fixed path does not rescue it — `/.well-known/objectstack`
405
+ advertises the **dispatcher's** `${prefix}/data`, measured as `/api/v1/data`
406
+ under all three configs above — so it is deliberately not probed: trusting it
407
+ would attach a false provenance ("discovery told us") to the same 404.
408
+
409
+ Instead that case degrades loudly. Falling back to the convention prints a
410
+ warning naming the mount it will address, the probe that failed and the remedy,
411
+ and every 404/405 from a record action now carries the mount it addressed and
412
+ where that mount came from. `api_call` is unchanged, issues no probe, and remains
413
+ the escape hatch for a host the probe cannot reach.
414
+ - 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)
415
+
416
+ <!-- adr-0087: not-required (no-migration-prescription) Nothing authorable is
417
+ renamed, retired or tombstoned — no spec schema is touched at all. The change
418
+ is a new runtime refusal at the engine's filter collection point, plus the
419
+ routing decline that stops the raw-SQL analytics path bypassing it. -->
420
+
421
+ A `datetime` / `date` / `time` field filtered with a bare string the platform
422
+ cannot read — `last_30_days`, `not-a-date-at-all` — was bound **as written**
423
+ all the way to the driver, where the comparison is false for every row. The
424
+ caller received `HTTP 200`, an empty result set, and nothing to indicate the
425
+ filter was meaningless. An unknown `{placeholder}` in the same position was
426
+ already refused loudly (`FILTER_TOKEN_UNKNOWN` / 400, listing the resolvable
427
+ tokens), so one API answered two shapes of unusable comparand two different
428
+ ways.
429
+
430
+ It is concretely reachable rather than theoretical: `last_7_days` /
431
+ `last_30_days` / `last_90_days` are **declared preset names** in the dashboard
432
+ schema. The shipped console lowers them to `{N_days_ago}` macros before they
433
+ reach the API, so the console path was always safe — but a saved report, an
434
+ integration, an MCP client or an AI-authored query sends the preset name itself
435
+ and got a silent zero. An empty chart is the hardest failure to debug: it is
436
+ indistinguishable from "there is genuinely no data".
437
+
438
+ Such a comparand is now refused at the ObjectQL engine's single filter
439
+ collection point, with `code: 'INVALID_FILTER'` and `status: 400`, naming the
440
+ field, the value, the key path and the spellings that would work. That seam is
441
+ the one place holding the caller's comparand and the field's **declared type**
442
+ at the same moment, and every verb (`find` / `findOne` / `count` / `aggregate`
443
+ / `update` / `delete`) and both filter spellings (the array sugar and the
444
+ lowered condition) pass through it, so all four backends inherit one answer
445
+ rather than four. `NativeSQLStrategy` additionally **declines** such a query so
446
+ the raw-SQL analytics path falls through to that door instead of binding the
447
+ value into its own statement.
448
+
449
+ Deliberately unchanged, each by ruling: a `{placeholder}` keeps its existing
450
+ refusal one layer down (the door runs before token resolution and steps around
451
+ them, so `{30_days_ago}` still resolves normally); non-string comparands are
452
+ untouched (a number is epoch milliseconds, a `Date` is an instant); and the
453
+ **empty string** keeps today's behaviour exactly — it binds as `''` and matches
454
+ every non-null row, which is a separate question that remains its own card.
455
+ - Updated dependencies [56656aa]
456
+ - Updated dependencies [07e630e]
457
+ - Updated dependencies [2f65b1b]
458
+ - Updated dependencies [720ee95]
459
+ - Updated dependencies [f287435]
460
+ - Updated dependencies [9aa8890]
461
+ - Updated dependencies [7c9c1dd]
462
+ - Updated dependencies [75b7c24]
463
+ - Updated dependencies [d5552ca]
464
+ - Updated dependencies [d9813a9]
465
+ - Updated dependencies [8640fb2]
466
+ - Updated dependencies [2420641]
467
+ - Updated dependencies [2ad91c3]
468
+ - Updated dependencies [f57fb38]
469
+ - Updated dependencies [00777a0]
470
+ - Updated dependencies [d491625]
471
+ - Updated dependencies [420804d]
472
+ - Updated dependencies [716ac9b]
473
+ - Updated dependencies [62b1427]
474
+ - Updated dependencies [7ea1372]
475
+ - Updated dependencies [23abe27]
476
+ - Updated dependencies [985a9cd]
477
+ - Updated dependencies [a8189ae]
478
+ - Updated dependencies [26e70fb]
479
+ - Updated dependencies [42b05af]
480
+ - Updated dependencies [2b292ce]
481
+ - Updated dependencies [abcf853]
482
+ - Updated dependencies [8b9eba5]
483
+ - Updated dependencies [d575779]
484
+ - Updated dependencies [94f7ef8]
485
+ - Updated dependencies [c5ac5e4]
486
+ - Updated dependencies [a777944]
487
+ - Updated dependencies [dd88e1c]
488
+ - Updated dependencies [856527c]
489
+ - Updated dependencies [870f710]
490
+ - Updated dependencies [79c46da]
491
+ - Updated dependencies [7ff3975]
492
+ - Updated dependencies [29d055b]
493
+ - Updated dependencies [65589d6]
494
+ - Updated dependencies [2c86fe3]
495
+ - Updated dependencies [e196c6a]
496
+ - Updated dependencies [4ab7523]
497
+ - Updated dependencies [19539b4]
498
+ - Updated dependencies [11b779e]
499
+ - Updated dependencies [739fe5b]
500
+ - Updated dependencies [4bfe1a5]
501
+ - Updated dependencies [2065e31]
502
+ - Updated dependencies [b69d0f5]
503
+ - Updated dependencies [4d47afe]
504
+ - Updated dependencies [e4e5c6e]
505
+ - Updated dependencies [9a56784]
506
+ - Updated dependencies [d00d2f6]
507
+ - Updated dependencies [df0c12d]
508
+ - Updated dependencies [d31785f]
509
+ - Updated dependencies [c308a4f]
510
+ - Updated dependencies [e2899f6]
511
+ - Updated dependencies [3851f87]
512
+ - Updated dependencies [2a29caa]
513
+ - Updated dependencies [09a6eee]
514
+ - Updated dependencies [1a7f907]
515
+ - Updated dependencies [cd455c8]
516
+ - Updated dependencies [30d3752]
517
+ - Updated dependencies [c80e7ae]
518
+ - Updated dependencies [09a9a8a]
519
+ - Updated dependencies [07026cf]
520
+ - Updated dependencies [5d4f3d5]
521
+ - Updated dependencies [4d80e8b]
522
+ - Updated dependencies [30b1c63]
523
+ - Updated dependencies [079b457]
524
+ - Updated dependencies [e43b211]
525
+ - Updated dependencies [890b38f]
526
+ - Updated dependencies [8bee54b]
527
+ - Updated dependencies [7a537ce]
528
+ - Updated dependencies [593c4bf]
529
+ - Updated dependencies [ff08691]
530
+ - Updated dependencies [60e0f90]
531
+ - Updated dependencies [90c5285]
532
+ - Updated dependencies [7901b2d]
533
+ - Updated dependencies [56bca91]
534
+ - Updated dependencies [79394d7]
535
+ - Updated dependencies [730fd9a]
536
+ - Updated dependencies [44bc51d]
537
+ - Updated dependencies [73cfddf]
538
+ - Updated dependencies [d634e66]
539
+ - @objectstack/spec@17.1.0
540
+
3
541
  ## 17.0.0
4
542
 
5
543
  ### Major Changes