@objectstack/core 17.1.0 → 17.3.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,1651 @@
1
1
  # @objectstack/core
2
2
 
3
+ ## 17.3.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 655b106: fix(metadata): register a `packages[]` artifact per package at the metadata door so every object has one owner across every door (#14599)
8
+
9
+ A release artifact carrying `packages[]` (ADR-0130 D4) was read at the metadata
10
+ door as if it carried one package: `MetadataPlugin._parseAndRegisterArtifact`
11
+ iterated the **flattened top level** and stamped every item with the artifact's
12
+ own `manifest.id`. For an artifact composed with `composeStacks(…, { manifest:
13
+ 'preserve' })` that id is one arbitrary member's — `selectManifest`'s `'last'`
14
+ pick — so a two-package artifact registered the **module's** object under the
15
+ **App** package's identity, while the ObjectQL load path, reading the same
16
+ artifact's `packages[]`, owned it under the module's.
17
+
18
+ The platform then held two answers to "who owns this object", and which one a
19
+ consumer saw depended on the door it went through. Measured on a real boot of
20
+ `examples/app-multi-package`:
21
+
22
+ - `GET /api/v1/meta/object` served `crm_order` **twice** — the list merge keys
23
+ slots by `${packageId}${name}`, so the two differently-attributed copies
24
+ landed in two slots;
25
+ - `GET /api/v1/meta/object?package=<the App package>` returned the **module's**
26
+ object, because the App-stamped copy was re-ingested into the registry as that
27
+ package's contribution;
28
+ - the layers door named the App package while the item door and
29
+ `GET /api/v1/packages` named the module;
30
+ - Studio's Data pillar for the App package listed the module's object — ADR-0130
31
+ Consequences §1.3a ("Studio's scope is the package") did not hold.
32
+
33
+ **The door now reads both shapes, and attributes every item to the body it was
34
+ found in.** `packages` present → each assembled package body's collections are
35
+ registered stamped with **that body's** id; `packages` absent → the single
36
+ `manifest` branch runs exactly as before (D7). The owner is read off the body an
37
+ item was found in — never reverse-derived by matching a top-level item's name
38
+ against a name-to-package index, which would be the second metadata-identity
39
+ resolution path #14512's triage rejected by name.
40
+
41
+ **Ordering and the entry gate are reused, not re-derived (D5).** The door calls
42
+ the same `resolveArtifactPackageOrder` the ObjectQL load path calls, so the two
43
+ readers of one `packages[]` cannot disagree about the registration order **or**
44
+ about which artifacts are loadable at all.
45
+
46
+ ⚠️ **`resolveArtifactPackageOrder` / `artifactPackageId` moved to
47
+ `@objectstack/core`** — hence the `minor` there. They were in
48
+ `@objectstack/objectql`, which **depends on** `@objectstack/metadata`, so the
49
+ metadata door could not import them from where they lived; `@objectstack/core`
50
+ already owns `resolvePluginOrder` and is already a dependency of both readers,
51
+ so hosting them there adds **no edge** to the package graph. `@objectstack/objectql`
52
+ re-exports both under their existing names — its published surface is unchanged,
53
+ which is why it is graded `patch`. `@objectstack/runtime` is `patch` for the
54
+ dispatcher error vocabulary's `file:` anchors, repointed at the new path.
55
+
56
+ **Single-package artifacts are byte-for-byte unaffected (D7)**, measured rather
57
+ than asserted: the whole `manager.register` sequence for a single-`manifest`
58
+ artifact — every call, in order, with the id and version each item was stamped
59
+ with — is pinned as a literal in
60
+ `packages/metadata/src/plugin-artifact-packages-attribution.test.ts` and was
61
+ recorded identically on both legs of the ablation. A real boot of
62
+ `examples/app-todo` answers every door identically before and after.
63
+
64
+ **Nothing a booted instance can see today disappears.** Every live
65
+ `ARTIFACT_FIELD_TO_TYPE` key is a member of `AssembledPackageBodySchema`
66
+ (measured, not assumed), so iterating bodies loses no collection; and because
67
+ `packages` composes by `concat`, an artifact whose top level carries a
68
+ definition no package body repeats keeps it — registered once, attributed to the
69
+ artifact's own identity, and logged, because it means the artifact's two halves
70
+ disagree about what it ships.
71
+
72
+ ⛔ The **producer** half is untouched: `composeStacks` and `os build` keep
73
+ emitting the flattened top level alongside `packages[]`. Whether they should is
74
+ #14512's decision, not this door's.
75
+ - 4bd6faa: feat(engine,core,cluster): the authorization-cache invalidation substrate — an engine-seam write epoch, the `authz.invalidated` channel, and a non-optional boot-time posture statement (#11968)
76
+
77
+ The substrate step (§10.3) of the accepted #11633 cross-request caching design
78
+ (maintainer acceptance 2026-08-25, Fork 2 → B). It ships the invalidation
79
+ machinery once, before the grants cache (#11967) that will consume it, so that
80
+ leg does not carry it. **Nothing here caches anything.**
81
+
82
+ - **`ObjectQL.writeEpoch`** — a monotonic counter advanced by the engine
83
+ middleware seam on every `insert` / `update` / `delete`, ahead of the whole
84
+ chain (and so ahead of any `isSystem` bypass a middleware applies). It
85
+ generalises the private counter `@objectstack/plugin-security` has carried
86
+ since #10757: the mechanism was always the engine's, and hoisting it lets a
87
+ second consumer share **one** signal instead of minting a parallel one that
88
+ watches a different set of writes. A seam rather than a list of call sites,
89
+ because a forgotten call site fails as silent over-permission and writing
90
+ through the engine is the only way to write at all — including better-auth's
91
+ own adapter.
92
+ - **`authz.invalidated`** — one new channel on the existing `IPubSub`, bridged
93
+ in the shape `MetadataClusterBridgePlugin` already uses. ⭐ **The TTL a
94
+ consuming cache carries is the correctness contract; this channel is not.** No
95
+ shipped driver delivers better than at-most-once (`cluster.mdx` §4.2), so a
96
+ missed message is *expected*, the bridge stays out of the write path (a
97
+ publish failure is logged and swallowed, never awaited by the writer), and the
98
+ channel only moves the *typical* convergence from one TTL to one network hop.
99
+ That statement lives in the code at the channel, where a consumer reads it.
100
+ - **The boot-time posture statement** — non-optional by the ruling. Whenever a
101
+ grants cache is enabled (`OS_AUTHZ_GRANTS_CACHE_TTL_MS` > 0) and there is no
102
+ cross-node invalidation bus, the deployment is told so at `warn`, every boot,
103
+ naming the window it accepted and the remedy. It is a statement, not a
104
+ refusal: a TTL-bounded per-process cache is a legitimate configuration. It is
105
+ said out loud because a silently-absent invalidation bridge is how a security
106
+ control gets disabled with nobody noticing (#4785). The in-process `memory`
107
+ driver counts as **no** bus — a cluster service exists on the shipped default
108
+ while fanning out to nobody, which is the case a "is a cluster service
109
+ registered?" check answers `yes` to and is wrong about.
110
+
111
+ **Runtime behaviour is unchanged.** With no cache consumer the epoch has zero
112
+ subscribers, so nothing is published and nothing is invalidated; with the
113
+ shipped default TTL of `0` the bridge attaches nothing and logs nothing above
114
+ `debug`. The one composition change worth naming: `Runtime` now registers
115
+ `AuthzClusterBridgePlugin` **unconditionally**, including under `cluster: false`
116
+ — that is not an oversight, it is the loudest case the posture check has, and
117
+ skipping it there would put the statement's absence exactly where the missing
118
+ bus is.
119
+
120
+ `@objectstack/plugin-security` is a `patch`: its permission-set memo now reads
121
+ the engine's epoch when the wired engine exposes one and keeps its private
122
+ counter otherwise (test doubles, embeddings). The covered set of writes is
123
+ identical — the plugin's own middleware was already global — and it is now
124
+ identical *by construction* rather than by two files agreeing on which
125
+ operations count.
126
+ - 86cbe37: feat(core): cross-request authorization grants cache — leg B of #11633 (#11971)
127
+
128
+ `resolveUserAuthzGrants` can now cache its resolved envelope across requests,
129
+ governed by `OS_AUTHZ_GRANTS_CACHE_TTL_MS`. **The default is `0` — the cache is
130
+ OFF and the shipped behaviour is unchanged** (Fork 4 of the accepted #11633
131
+ design): a deployment that enables it accepts the configured staleness window
132
+ explicitly, and the boot-time posture statement says so out loud when no
133
+ cross-node invalidation bus is attached.
134
+
135
+ With the cache on:
136
+
137
+ - **Coarse write-invalidation (Fork 1A).** Any engine write to a watched
138
+ authorization object (`sys_member`, `sys_user_position`,
139
+ `sys_user_permission_set`, `sys_position`, `sys_position_permission_set`,
140
+ `sys_permission_set`, `sys_user`) retires every entry on the writing node —
141
+ a grant/revoke/role change is observed by the very next request there, by
142
+ invalidation and not by TTL. `metadata.changed` and peer-node
143
+ `authz.invalidated` hints retire wholesale via the engine write epoch.
144
+ `sys_session` is deliberately not watched (its once-a-minute
145
+ `last_activity_at` cadence would turn the cache into a non-cache).
146
+ - **Expiry-boundary rule.** Entries expire at `min(ttl, nextBoundary)`, where
147
+ `nextBoundary` is the earliest upcoming ADR-0091 `valid_from`/`valid_until`
148
+ among the rows consulted — a validity window flipping is a permission change
149
+ with no write anywhere, so the timer is the only mechanism for that class.
150
+ - **Ruled bypass list.** The permission explainer
151
+ (`plugin-security` `buildContextForUser`) and `runAs:'user'` automation runs
152
+ (`service-automation`) always resolve fresh, and never populate the cache.
153
+ - The TTL remains the correctness contract; the `authz.invalidated` bus only
154
+ narrows the typical cross-node window (no shipped driver exceeds
155
+ at-most-once delivery).
156
+ - 6a180e4: fix(core,rest,services)!: a permission-store read failure now fails LOUD instead of resolving as an authenticated caller holding zero capabilities (#13279)
157
+
158
+ **BREAKING** runtime behaviour change on the shared authorization resolver,
159
+ shipped as `minor` under the repo's launch-window convention.
160
+
161
+ `resolveAuthzContext`'s per-read helper `tryFind` answered a THROWN read exactly
162
+ the way it answered an EMPTY one: `[]`. So an outage of the permission store
163
+ resolved as a well-formed context for an authenticated principal holding no
164
+ capabilities, and the package-management door answered
165
+ `403 FORBIDDEN` — "Reading packages requires the `studio.access` or
166
+ `setup.access` capability." That answer was measured byte-identical
167
+ (`JSON.stringify` equal, against a control that separates two answers which do
168
+ differ) to what a caller who genuinely holds nothing receives. An administrator
169
+ was told they lack a capability, during an outage of the store that holds the
170
+ capability.
171
+
172
+ Maintainer ruling 2026-08-30, verbatim 「第一批其余同意」: `tryFind` 区分「无行」
173
+ 与「读失败」,读失败 fail-loud —— 权限库不可达时不再解析为「已认证零能力」,而是
174
+ 响亮拒绝(与真实能力拒绝的 403 可区分)。
175
+
176
+ Second maintainer ruling the same day (第 5 场总监席决裁批 #9, verbatim 「同意」),
177
+ after implementing the first one showed that "the read failed" is two facts:
178
+ 采**选项 A** —— 把 `isMissingTableError` 从 `@objectstack/metadata` 迁至
179
+ `@objectstack/types`(core 已依赖),metadata 保留 re-export 兼容;`tryFind` 仅对
180
+ **未被判定为「表未 provision」**的读失败抛 `AuthzStoreUnavailableError`。
181
+
182
+ **What changed.** A permission-store read that is issued and throws now raises
183
+ `AuthzStoreUnavailableError`, which carries the EXISTING ADR-0112 wire code
184
+ `SERVICE_UNAVAILABLE` and status `503`. No code is added to the closed wire
185
+ vocabulary and no response envelope gains or loses a key — only which declared
186
+ code an outage selects. Doors that map thrown errors through
187
+ `resolveThrownHttpError` answer 503 with no per-door change.
188
+
189
+ **What did NOT change**, and is pinned:
190
+
191
+ - A reachable, genuinely EMPTY store (reads return no rows) still resolves to
192
+ zero capabilities.
193
+ - A genuine capability denial still answers `403 FORBIDDEN` with its message.
194
+ - An ABSENT engine (`ql` unwired, so no read is ever issued) still resolves to
195
+ an empty-but-valid envelope.
196
+ - Anonymous requests never reach the store, so an outage cannot make them loud.
197
+ - A REAL engine whose `sys_*` tables were never provisioned resolves to zero
198
+ capabilities, quietly — pinned to be byte-identical to the empty-store
199
+ envelope, in every dialect spelling and in the production wrapper shape where
200
+ the driver's phrase is on `cause` rather than the outer message.
201
+
202
+ **The boundary between the two kinds of read failure.** An earlier revision of
203
+ this changeset claimed "embedders without a data plane are unaffected". That
204
+ claim was too broad; it is retracted here, and the gap it named is now closed
205
+ rather than merely disclosed. A read also throws when the table was never
206
+ PROVISIONED — a real engine, wired and reachable, whose `sys_*` tables were
207
+ never created — and that is a supported deployment shape, not an outage. There
208
+ "zero capabilities" is the TRUE answer rather than a fabrication: nothing is
209
+ provisioned, so nothing was withheld. Only an UNREACHABLE store — the ruling's
210
+ own word 不可达 — leaves the capability set unknown, and only an unknown answer
211
+ may not be reported as a denial.
212
+
213
+ Treating the two alike was measured, not theorised: it turned four CI suites
214
+ red, all from `no such table` on `sys_user` / `sys_member` /
215
+ `sys_user_position` / `sys_user_permission_set`. Ordinary CRUD in
216
+ `@objectstack/client` answered `503`; batch validation errors that owe `400`
217
+ answered `503`, because authorization refused before validation ran; runtime
218
+ notifications answered `401` where authenticated callers must be served `200`;
219
+ and two `.integration.test.ts` noise guards reported that the driver and engine
220
+ diagnostics for `sys_position` stopped being emitted — the eager throw aborted
221
+ the resolution before that later read was ever issued, so a change made to stop
222
+ a failed read being silent had made two other channels silent.
223
+
224
+ `tryFind` therefore raises `AuthzStoreUnavailableError` only for a read failure
225
+ that is NOT positively identified as an unprovisioned table.
226
+
227
+ **`isMissingTableError` moved to `@objectstack/types`.** The classifier that
228
+ draws that boundary already existed and was already right — driver-code based
229
+ rather than prose-sniffing, documented so that "cannot say" never means "be
230
+ loud". It lived in `@objectstack/metadata`, which DEPENDS ON `@objectstack/core`,
231
+ so the resolver could not import it. Rather than keep a second copy of a
232
+ security-relevant predicate, the ruling relocated the one classifier to
233
+ `@objectstack/types` — the package core already depends on, and the repo's own
234
+ stated Home rule for a cross-package error predicate ("every consumer of the
235
+ question already depends on it, so adopting the predicate never adds an edge",
236
+ `packages/types/src/unique-violation.ts`). `@objectstack/metadata/errors` still
237
+ exports `isMissingTableError`, re-exported from the new home, so no consumer of
238
+ that published subpath changes.
239
+
240
+ Its sibling `isSchemaAlreadyExistsError` moved with it — the two are not two
241
+ modules but two signatures over one matcher, and separating them would have
242
+ meant re-rolling the matcher, which is the duplication the module exists to
243
+ prevent. Both are now exported from `@objectstack/types`; the metadata subpath
244
+ deliberately still publishes only `isMissingTableError`, which is the only one
245
+ anything imports through it.
246
+
247
+ ⚠️ **Signed-off risk, recorded because it is load-bearing.** Gating loudness on
248
+ a driver-error predicate was approved with its false-positive direction stated:
249
+ mis-reading a genuine outage as "table not provisioned" silently restores the
250
+ quiet 403 this change removes, with no thrown error and no other failing test.
251
+ That direction is accepted, not overlooked — the predicate keys on driver codes,
252
+ SQLSTATEs and errnos first, excludes the known superstring traps up front, and
253
+ returns `false` for anything it does not positively recognise, so an
254
+ unrecognised outage stays loud by default. The risk is written beside the
255
+ predicate in `resolve-authz-context.ts` and both directions are pinned by name
256
+ in `authz-store-unavailable.test.ts`. ⛔ Do not widen `isMissingTableError` to
257
+ make a first boot quieter: every widening moves outages into the quiet branch.
258
+
259
+ **All-transport, not just REST.** Every transport authorizing through
260
+ `resolveAuthzContext` inherits this. Six of the eight production transports
261
+ wrapped the call in a fail-closed `catch` that would have re-silenced the
262
+ outage — measured, not assumed: with the resolver loud but the nets untouched,
263
+ the package door answered `401`, i.e. the outage merely changed disguises. Those
264
+ `catch` blocks now re-raise via `isAuthzStoreUnavailableError` and keep their
265
+ previous behaviour for every other fault. The transport set is rebuilt from
266
+ source and audited for set equality on every test run, so a transport added
267
+ later cannot inherit the old silence unnoticed.
268
+
269
+ Callers that treat any throw from `resolveAuthzContext` as "anonymous" should
270
+ re-raise `isAuthzStoreUnavailableError(err)` instead: degrading it restores the
271
+ disguise this removes.
272
+
273
+ <!-- adr-0087: not-required (runtime-interface-only packages/core/src/security/resolve-authz-context.ts#ResolvedAuthzContext, packages/core/src/security/authz-store-unavailable.ts#AuthzStoreUnavailableError) The breaking surface is runtime TypeScript in `@objectstack/core`'s security module and nothing else: `resolveAuthzContext` stops always-resolving and raises `AuthzStoreUnavailableError` when a permission-store read is issued and throws. NO metadata surface is touched in either direction. No Zod schema changes, no `packages/spec` declaration is added or removed, no authorable key moves, no stored row shape changes, and no object definition is edited — a customer's metadata app is byte-for-byte unaffected, so `objectstack migrate meta` has nothing to visit and there is no tombstone to mint. The wire vocabulary is likewise untouched: `SERVICE_UNAVAILABLE` is an EXISTING `StandardErrorCode` member that `HttpStatusErrorCodeMap` already maps to 503, so this change only selects a different DECLARED code for an outage rather than adding one. Both named symbols resolve at HEAD as exported declarations whose files are not `*.zod.ts`, are not under `packages/spec/src/contracts/`, are not object definitions and are not `z.input` projections; neither is referenced in code by any metadata surface (the `packages/spec` hits for `resolveAuthzContext` are comment prose describing the envelope, which this gate masks). The channel that reaches an affected consumer is therefore code review and this changeset, never the upgrade guide: a ledger entry could not express "your fail-closed catch should re-raise this error", because there is no metadata for a migration to rewrite. -->
274
+ - d8024f0: feat(core): `Plugin.type` is the closed set the spec declares — a `PluginType` derived from `CORE_PLUGIN_TYPES` (#13925)
275
+
276
+ **BREAKING** accept-set narrowing on a published type, shipped as `minor`
277
+ under the repo's launch-window convention for breaking changes. `Plugin.type`
278
+ (and, through it, `PluginMetadata.type`) was declared `string`, so nothing
279
+ type-checked a plugin author against the eight values the platform accepts —
280
+ the TSDoc beside it carried the whole enumeration as prose, and prose drifted.
281
+ Maintainer ruling 2026-09-01: the Zod enum in `@objectstack/spec`
282
+ (`PluginSchema.type`, declared `z.enum(['standard', ...CORE_PLUGIN_TYPES])`)
283
+ is the authority and the contract was always a closed set; the `string` in
284
+ core was the mismatch, and narrowing it is core aligning to the declared
285
+ contract rather than a new restriction. Paid in one stroke — no warning window.
286
+
287
+ What changes:
288
+
289
+ - `@objectstack/core` now exports `PluginType`, derived from the spec's own
290
+ constant: `'standard' | (typeof CORE_PLUGIN_TYPES)[number]` — today
291
+ `standard`, `ui`, `driver`, `server`, `app`, `theme`, `agent`, `objectql`.
292
+ It is not re-spelled in core, so the compiler's accept set and the Zod gate's
293
+ cannot drift apart; a runtime parity test pins the two against each other.
294
+ - `Plugin.type` is typed `PluginType`. A literal outside the set, or a value
295
+ typed `string`, no longer compiles. Runtime behaviour is unchanged: the Zod
296
+ gate refused such a value before and still does (`invalid_value` at `type`).
297
+
298
+ **Migration.** A plugin that declares one of the eight members needs no change.
299
+ A plugin that assigned a computed or `string`-typed value narrows it at the
300
+ producer — declare the literal, or type the variable `PluginType` — rather than
301
+ casting at the assignment; a value that was never one of the eight was never a
302
+ valid plugin type and was already refused at parse time.
303
+
304
+ <!-- adr-0087: not-required (no-migration-prescription) A TypeScript narrowing on a published runtime interface, aligning `packages/core` to the accept set `packages/spec` already declared. No metadata key, spec symbol, Zod schema, object definition or stored representation is added, removed or renamed — `CORE_PLUGIN_TYPES` and `PluginSchema.type` are read, not changed — so `objectstack migrate meta` has nothing to rewrite and there is no tombstone to mint. The channel that reaches an affected author is the compiler, at the assignment, which is more precise than a ledger line; which member a formerly `string`-typed value should become is authoring intent no migration entry can decide. The in-repo census under the workspace typecheck is recorded on the PR. -->
305
+ - 4635f3e: fix(spec,core): `HotReloadConfig.stateStrategy` refuses the two values it never implemented; `distributedConfig` retired (#12340, ADR-0049)
306
+
307
+ <!-- adr-0087: registered hot-reload-inert-state-strategies-retired -->
308
+
309
+ **BREAKING** accept-set narrowing + export removal, landing after the v17.0.0
310
+ cut (the lockstep launch-window convention ships it as `minor`; the
311
+ prescription is registered under protocol major 18 —
312
+ `RETIRED_DEFS_BY_MAJOR[18]` + the D3 semantic entry
313
+ `hot-reload-inert-state-strategies-retired` — where `os migrate meta` users
314
+ will look).
315
+
316
+ This is ADR-0049 applied one level INSIDE the library the 2026-08-25 #11825
317
+ ruling deliberately kept. That ruling retired the authorable lifecycle-config
318
+ container and kept `HotReloadConfigSchema` as a host-driven library parameter
319
+ type; this change measures the kept vocabulary's own remainder and finds the
320
+ same defect in it. The keep itself stands — `HotReloadConfigSchema`,
321
+ `PluginStateSnapshotSchema` and the health vocabularies still export, and
322
+ `HotReloadManager` / `PluginHealthMonitor` are untouched.
323
+
324
+ The `'disk'` and `'distributed'` arms of `PluginStateManager.saveState` both
325
+ wrote to the SAME in-memory `Map` as `'memory'` — the in-source comments said
326
+ "memory fallback" — and announced the substitution at DEBUG level only. A host
327
+ that asked for durable or cluster-replicated state got process-local memory
328
+ and no error: state that does not survive the restart it was configured to
329
+ survive. `distributedConfig` had ZERO readers anywhere, so an author could
330
+ name a Redis endpoint, a TTL and a replication factor and nothing ever opened
331
+ a connection.
332
+
333
+ FROM → TO:
334
+
335
+ - `stateStrategy: 'disk'` → `stateStrategy: 'memory'` — byte-identical runtime
336
+ behaviour, because `'disk'` already stored to memory. It is the spelling
337
+ that was false, not the behaviour.
338
+ - `stateStrategy: 'distributed'` → `stateStrategy: 'memory'` — same, or
339
+ `'none'` to disable state preservation outright.
340
+ - `distributedConfig: { … }` → *(removed)* — delete the key. It left with the
341
+ `'distributed'` value its own doc comment called it "required" for.
342
+ - `DistributedStateConfigSchema` / `DistributedStateConfig` /
343
+ `DistributedStateConfigParsed` → *(removed)* — the orphan value schema of
344
+ that one key.
345
+
346
+ One-line fix: replace `'disk'` or `'distributed'` with `'memory'` and delete
347
+ any `distributedConfig` — you were already getting in-memory state. There is
348
+ no in-tree replacement for durable or distributed plugin state; persist it in
349
+ the host, which owns the process lifetime these strategies pretended to
350
+ outlive. Real disk or distributed persistence returns only via the ENFORCE
351
+ route of ADR-0049 — the implementation first, the declaration with it.
352
+
353
+ The retirement kit:
354
+
355
+ - **enum-value narrowing** (`['memory','disk','distributed','none']` →
356
+ `['memory','none']`): invisible to all four ratchets by construction (the
357
+ def still emits), so the prescription hangs on the enum's own `error` map
358
+ dispatched by `issue.input` — the `crypto.hash` / `managedBy: 'system'`
359
+ precedent. A value that was never legal still gets zod's own enum message,
360
+ so a typo is not told it "was removed".
361
+ - **whole-def deletion** (route 3 — `HotReloadConfig` is not an authorable
362
+ surface: no metadata-type binding, stack collection or manifest embed ever
363
+ carried it, and nothing in the tree parses `HotReloadConfigSchema` outside
364
+ its own unit test, so there is no authored document to rewrite and nobody
365
+ who could receive a parse-time tombstone): `kernel/DistributedStateConfig`
366
+ in `RETIRED_DEFS_BY_MAJOR[18]` plus the D3 semantic entry. Ratchets moved as
367
+ a def removal must — `api-surface` −3, `authorable-surface` −8,
368
+ `json-schema.manifest` −1.
369
+ - **runtime doors** in `@objectstack/core`, because route 3 leaves no
370
+ parse-time prescription: `HotReloadManager.registerPlugin` now refuses an
371
+ unhonoured `stateStrategy` and a leftover `distributedConfig` with an
372
+ ADR-0112 envelope (`code: VALIDATION_ERROR`, `status: 400`) carrying the
373
+ prescription. Refused BEFORE the `enabled` check, so a disabled config
374
+ cannot smuggle the false declaration through. TypeScript hosts never reach
375
+ it — `HotReloadConfigParsed['stateStrategy']` is now `'memory' | 'none'`, a
376
+ compile error at the call site.
377
+ - **pin move, declared**: `DistributedStateConfigSchema` was NAMED in the
378
+ #11825 survivor list, so this reverses one line of that ruling on new
379
+ evidence — #11825 measured the container's six groups, never this key's own
380
+ readers. The pin in `kernel/plugin-lifecycle-advanced-retirement.test.ts`
381
+ moves in the same commit with the reasoning recorded beside it, and asserts
382
+ the surrounding keep is intact.
383
+ - zero in-tree consumers passed `'disk'` or `'distributed'` (measured at
384
+ cdbd9204b6 with a firing positive control; every live caller passes
385
+ `'memory'` or `'none'`), so no in-repo source changes ride along.
386
+ - ee3595c: fix(spec,core): `HotReloadManager.startWatching` refuses instead of reporting success; `HotReloadConfig.watchPatterns` retired (#12428, ADR-0049)
387
+
388
+ <!-- adr-0087: registered hot-reload-watch-placeholder-retired -->
389
+
390
+ **BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep
391
+ launch-window convention ships it as `minor`; the prescription is registered
392
+ under protocol major 18 — `RETIRED_KEYS_BY_MAJOR[18]` + the D3 semantic entry
393
+ `hot-reload-watch-placeholder-retired` — where `os migrate meta` users will
394
+ look). Graded `minor` rather than `major` for the same reason #12340 was one
395
+ day earlier, in this same module.
396
+
397
+ ADR-0049 applied one symbol over from #12340, in the same file and on the same
398
+ per-key test. The #11825 keep still stands: `HotReloadConfigSchema` and
399
+ `PluginStateSnapshotSchema` still export, and `HotReloadManager` /
400
+ `PluginHealthMonitor` are untouched apart from the two doors below.
401
+
402
+ `HotReloadManager.startWatching` contained **no watcher**. Its whole body was a
403
+ guard plus `logger.info('File watching started', { patterns })`, above an
404
+ in-source note saying real watching "would require chokidar or similar". Where
405
+ #12340's inert fallback at least announced itself at DEBUG, this claimed
406
+ success at **INFO**: an operator who set `enabled: true` with `watchPatterns`
407
+ and read that line had been told the opposite of the truth. `watchHandles` was
408
+ only ever read, deleted, iterated and cleared and **never set**, so
409
+ `stopWatching`'s cleanup branch and the teardown loop over its keys were
410
+ structurally unreachable rather than merely untaken. `watchPatterns` therefore
411
+ had no reader that acted on it — its only two uses were log lines.
412
+
413
+ FROM → TO:
414
+
415
+ - `watchPatterns: ['src/**/*.ts']` → *(removed)* — delete the key. Declare your
416
+ globs wherever your own watcher reads them.
417
+ - `manager.startWatching(name)` → `manager.scheduleReload(name, reloadFn)`,
418
+ called from your own watcher's change handler. That is the debounced
419
+ integration point this class does implement, and it is unchanged.
420
+
421
+ One-line fix: delete `watchPatterns`, and call `scheduleReload` from your own
422
+ file watcher instead of `startWatching` — nothing was ever watched, so nothing
423
+ that used to happen stops happening. File watching is the host's job in this
424
+ host-driven library; `chokidar` is already a dependency of
425
+ `@objectstack/metadata`, `@objectstack/metadata-fs` and `@objectstack/cli` —
426
+ never of `@objectstack/core` — so a host has a working model to copy.
427
+
428
+ The retirement kit:
429
+
430
+ - **key tombstone**, and the build is what chose it: the plain deletion was
431
+ tried first and `gen:schema` gate (a) refused it, because
432
+ `HotReloadConfigSchema` is not `.strict()` and a bare deletion would be a
433
+ silent strip (#3733, ADR-0104) — the very defect being retired, one layer
434
+ down. #12340 could take route 3 because what left there was a whole *def*; a
435
+ key leaving a *surviving* def has no such exit. So `watchPatterns` is
436
+ `retiredKey()`-tombstoned, its surface line carries `[RETIRED]`, and
437
+ `kernel/HotReloadConfig:watchPatterns` is registered by exact key in
438
+ `RETIRED_KEYS_BY_MAJOR[18]`. A key tombstone on a surviving def moves
439
+ `authorable-surface` only — the def still emits, so `api-surface` and
440
+ `json-schema.manifest` do not.
441
+ - **no D2 conversion**, deliberately: the chain walks a normalized stack, and
442
+ `HotReloadConfig` is not an authorable surface — no metadata-type binding,
443
+ stack collection or manifest embed ever carried it — so a conversion would be
444
+ a transform with no seam that ever runs. For the same reason the prescription
445
+ carries no `os migrate meta` sentence, exactly as its `stateStrategy` sibling
446
+ in this module does not.
447
+ - **runtime doors** in `@objectstack/core`, because nothing in the tree parses
448
+ `HotReloadConfigSchema` outside its own unit test, so the tombstone alone
449
+ reaches nobody: `startWatching` now throws an ADR-0112 envelope
450
+ (`code: VALIDATION_ERROR`, `status: 400`) carrying the prescription, and
451
+ `registerPlugin` refuses a leftover `watchPatterns` the same way — before the
452
+ `enabled` check, so a disabled config cannot smuggle the false declaration
453
+ through. `startWatching` is kept as a throwing door rather than deleted so
454
+ that caller meets a prescription instead of a bare `TypeError`.
455
+ - **dead code removed with a firing positive control**: `watchHandles` and both
456
+ of its unreachable readers are gone. The zero was pinned first —
457
+ `reloadTimers.set` resolves a real writer in the same file and the same scan,
458
+ while `watchHandles.set` resolves nothing anywhere. `stopWatching` keeps the
459
+ half that always did something (it cancels a pending debounced reload), and
460
+ `shutdown` is unchanged in effect: the loop it lost iterated `watchHandles`
461
+ and therefore ran zero times.
462
+ - **ENFORCE and EXPERIMENTAL were both unavailable**, which is why this is a
463
+ removal: no runtime composes `HotReloadManager`, so enforcing would build for
464
+ a caller that does not exist; and a scan of every planning doc returned zero
465
+ mentions of hot-reload file watching against 145 control hits in the same
466
+ files, so there is no roadmap for `experimental` to point at.
467
+ - af56546: feat(platform-objects): packaged disable works without the automation service, and the activation ledger has one implementation (#12359, #12350)
468
+
469
+ Two halves of ADR-0126's "ledger convergence", bundled by maintainer ruling
470
+ (2026-08-26, verbatim and untranslated: 「同意」).
471
+
472
+ ## The registration follows the declaration (#12359)
473
+
474
+ `sys_metadata_activation` is declared in `@objectstack/platform-objects`, but
475
+ the only thing that REGISTERED it was the automation service's manifest —
476
+ because flows were the ledger's first and, until packaged actions landed, only
477
+ consumer. Packaged actions are a second consumer with a different owner: their
478
+ consult and write path live on the ObjectQL engine, present in every
479
+ composition that can execute an action.
480
+
481
+ So a deployment with actions and no automation service had no ledger table, and
482
+ the activation door answered **503 SERVICE_UNAVAILABLE** on every flip —
483
+ correctly (ADR-0126 §6 wall 3: a flip that cannot be made durable must not be
484
+ reported as one) and permanently. Measured on a real boot; it is now this
485
+ change's positive test, measured on the same boot:
486
+
487
+ ```
488
+ POST /api/v1/actions/_activation/showcase_task/showcase_mark_done {"enabled":false}
489
+ before -> 503 SERVICE_UNAVAILABLE after -> 200, and dispatch refuses 409 ACTION_DISABLED
490
+ ```
491
+
492
+ `PlatformObjectsPlugin` registers it now, so every composition carrying
493
+ platform-objects has the ledger and each future ADR-0126 §8 consumer (`tool`,
494
+ `skill`, `position`) inherits it. **MOVE, not add** — the automation service no
495
+ longer names the object. That was not a style choice: a second code package
496
+ claiming one object throws `Object "…" is already owned by package "…"`
497
+ (ADR-0029 D3/D7), measured, so adding a registrant would have been a boot
498
+ failure rather than a duplicate.
499
+
500
+ **Upgrade is a no-op for existing data, and that is measured rather than
501
+ asserted.** A manifest is also a ROUTING decision — `resolveDatasourceBinding`
502
+ step 4 routes an object by its owning package's `defaultDatasource` — so the
503
+ registrar carries the table's datasource with it:
504
+
505
+ ```
506
+ owner com.objectstack.service-automation (defaultDatasource:'cloud') -> 'cloud'
507
+ owner com.objectstack.platform-objects (none) -> undefined (global default driver)
508
+ ```
509
+
510
+ The ledger table already exists in live databases, so on any deployment
511
+ carrying a `cloud` datasource that difference would leave the rows in one
512
+ database and read another — every disabled artifact silently re-arming. The
513
+ ledger therefore rides its own manifest from the same plugin, carrying the
514
+ automation manifest's `scope` / `namespace` / `defaultDatasource` triple
515
+ verbatim. The three siblings (`sys_migration`, `sys_migration_journal`,
516
+ `sys_secret`) deliberately do not get it and keep riding the project database.
517
+
518
+ ## One implementation of the §4 row contract (#12350)
519
+
520
+ ADR-0126 §4 declares one activation ledger; it had two independent
521
+ implementations of that one row contract — `ObjectStoreFlowActivationStore`
522
+ (service-automation) and `ObjectStoreActionActivationStore` (objectql). They
523
+ agreed because the second was written from the first, and nothing structurally
524
+ held them together; §8 pre-charts `tool`, `skill` and `position`, and a third
525
+ and fourth copy is where the org-row skip and the `0`-is-false read get lost
526
+ quietly, in the direction (an artifact re-arming) nothing else measures.
527
+
528
+ Neither consumer could import the other, so the contract now lives once in
529
+ `@objectstack/core` — the package both already depend on — as
530
+ `ObjectStoreMetadataActivationStore(engine, metadataType)`, exported alongside
531
+ `InMemoryMetadataActivationStore`, `MetadataActivationRow`,
532
+ `MetadataActivationStore`, `MetadataActivationStoreEngine` and
533
+ `METADATA_ACTIVATION_TABLE`. Each consumer keeps its own name, its own
534
+ one-argument constructor and its own docs, and fixes the discriminator.
535
+
536
+ **No behaviour change and no API break.** `ObjectStoreFlowActivationStore` /
537
+ `InMemoryFlowActivationStore` / `FlowActivationStoreEngine` and
538
+ `ObjectStoreActionActivationStore` / `InMemoryActionActivationStore` /
539
+ `ActionActivationRow` / `ActionActivationStore` / `ActionActivationStoreEngine`
540
+ / `ACTION_ACTIVATION_TABLE` are exported from the same modules with the same
541
+ shapes. Row semantics are byte-equivalent: deployment-level rows scoped only by
542
+ the `metadata_type` discriminator, a driver `0` read as false, read-then-write
543
+ rather than a blind upsert, and no `delete` in the engine slice because
544
+ re-enabling rewrites the row.
545
+
546
+ Both existing pin suites stay green **unchanged**, which is what makes them the
547
+ proof the consolidation lost nothing — verified by ablation: mutating the one
548
+ shared implementation turns both of them red on their own assertions, so both
549
+ really reach it.
550
+ - a8c00e2: feat(core): cache successful `sys_setting` localization reads, invalidated synchronously on write (#11966)
551
+
552
+ Leg C (ship-first) of the accepted #11633 cross-request caching design
553
+ (maintainer acceptance 2026-08-25, forks 1A / 2B / 3A / TTL-0).
554
+ `resolveLocalizationContext` re-read `sys_setting` on **every** authenticated
555
+ request to answer the same three keys — `timezone` / `locale` / `currency` —
556
+ for a workspace whose values change roughly never. That read is now cached.
557
+
558
+ **Grade: `minor`, not `patch`.** It adds a deployment variable
559
+ (`OS_LOCALIZATION_CACHE_TTL_MS`) and changes the query pattern of a shipped code
560
+ path. Not `major`: the observable contract callers actually depend on — a
561
+ settings write is visible to the very next read — is preserved, and pinned.
562
+
563
+ Caching this read was tried once before and reverted. #10221's first version
564
+ memoized every outcome for 30s and CI went red on
565
+ `analytics-timezone.dogfood.test.ts`, which writes a new org timezone and
566
+ expects the very next analytics query to bucket under it; the cache was narrowed
567
+ to memoize **failures** only. That verdict was on **TTL-only** caching and it
568
+ still stands unamended. What changed is that the process now has invalidation
569
+ seams it did not have then:
570
+
571
+ - **Primary — the settings change seam.** `SettingsService.subscribe(ns, handler)`
572
+ dispatches synchronously and in-process from the write path, after the row is
573
+ persisted. (⚠️ #11633 calls this a "settings change bus"; no such module
574
+ exists — `subscribe()` is the seam. No change was needed in
575
+ `@objectstack/service-settings`: the seam was already public and already does
576
+ exactly this.)
577
+ - **Backstop — the engine write epoch** from #11968's substrate. Needed because
578
+ this resolver's own fallback reads `sys_setting` *directly*, so a seeder or
579
+ any other direct engine write emits no settings event at all. It is read
580
+ structurally rather than imported, because `@objectstack/objectql` depends on
581
+ `@objectstack/core` and the substrate declared `WriteEpochLike` separately for
582
+ exactly this consumer. A peer node's hint arrives as a local bump, so an
583
+ attached `authz.invalidated` bridge narrows cross-node convergence for free.
584
+ - **TTL** — the residual bound, for what neither seam can see. Default 30s,
585
+ `0` disables the cache on a real path rather than a degenerate one.
586
+
587
+ Two rules carry the change and are pinned rather than merely documented:
588
+
589
+ 1. **A success is cached only when the engine exposes the write epoch.** A `ql`
590
+ with no seam is a `ql` whose writes the cache cannot observe, so rather than
591
+ degrade to the TTL-only shape that was already reverted here once, the cache
592
+ declines. A partial `{ current }` shape is not a seam either — a counter
593
+ nothing can bump would read as a live invalidation source and pin the answer
594
+ for a whole TTL.
595
+ 2. **Invalidation retires success entries only.** #10221's failure memo exists
596
+ for an environment where `sys_setting` is missing; retiring it on a write
597
+ would restart precisely the per-request driver log spam that memo removed,
598
+ and no write can create a missing table. It stays TTL-bound and behaves
599
+ exactly as #10221/#11877 shipped it.
600
+
601
+ `analytics-timezone.dogfood.test.ts` is unchanged and unweakened — it is this
602
+ leg's acceptance test, and an ablation that reduces the cache to its TTL turns
603
+ it red on the same assertion the original revert was recorded against.
604
+ - 7131f12: **Security:** the "is this user id a platform admin?" question is now asked in exactly one place, and the two copies that answered it differently are gone (#10348, #10949).
605
+
606
+ ADR-0068 D2 defines platform standing as one thing — an unscoped `admin_full_access` grant, held now. `core/security/resolve-authz-context.ts` is the declared authority for authorization derivation and its header states that every entry point must resolve through it and never re-read the grant tables itself. `plugin-auth`'s `auth-manager.ts` did exactly that twice: once inside the `customSession` callback, and once in the predicate that authorizes `/sso/register` and, through the impersonation oracle, `/admin/impersonate-user`. Both copies are deleted. Both callers — and the session payload — now ask `hasPlatformAdminStanding(engine, userId)`, a projection of `resolveUserAuthzGrants` exported from `@objectstack/core`, so a platform-admin verdict is derived in one place for the whole platform.
607
+
608
+ **What that changes, and it is a tightening on all three counts.** The deleted copies applied neither the ADR-0091 validity window nor the ADR-0049 `active` check, and resolved `admin_full_access` by matching a name over a page of the permission-set catalogue. The authority applies both checks before any derivation and resolves the set by id. So:
609
+
610
+ - an **expired** platform-admin grant no longer authorizes `/sso/register` or `/admin/impersonate-user`, and no longer appears in the session payload;
611
+ - a **deactivated** `admin_full_access` permission set no longer confers platform standing anywhere — the deactivation dialog's promise now holds on these gates too;
612
+ - an environment holding **more permission sets than a single catalogue page** can no longer lose the `admin_full_access` row and demote every platform admin at once.
613
+
614
+ **One behaviour widens, and it was ruled deliberately** (maintainer, 2026-08-24). The `customSession` copy read without a system identity while the other read with one. The single authority reads as system, so on a strictly org-scoped deployment the session payload stops under-reporting platform admin — the fail-closed drift between the payload and the gates ends. Open-core composition is unaffected: the two reads reached identical rows there already.
615
+
616
+ **The org boundary is unchanged and now pinned at both gates.** An org owner, an org admin, a `TENANT_ADMIN`-posture principal and an org-scoped `admin_full_access` grant are all refused — the `PLATFORM_ADMIN` rung derives from the unscoped capability grant alone. The predicate takes an engine and a user id and nothing else: it deliberately does not accept the resolver's caller-supplied seeds, so no part of a request can supply part of its own verdict.
617
+
618
+ Population queries are a different kind and are untouched: `ensure-default-organization.ts` asks *which* user is the platform admin, which a per-user predicate cannot express.
619
+ - 33184fd: `PLATFORM_ADMIN` can now be anchored on deployment CONFIGURATION instead of a stored grant row: an account whose `sys_user.email` is on `OS_PLATFORM_OWNER_EMAIL` **and** whose `email_verified` reads verified resolves `PLATFORM_ADMIN` with the declared `admin_full_access` capability set, derived live on each authorization resolution (#11663 leg L2, design accepted 2026-08-25 as bundle 1A/2B/3A/4A/5A/6A/7A).
620
+
621
+ **Additive — nothing is revoked.** The legacy unscoped `admin_full_access` grant still confers exactly as it did; a holder whose standing rests on the row alone now gets a once-per-process pointer at the configuration line that re-anchors them. A deployment that has declared no administrators resolves byte-identically to before: the config list is empty, the derivation answers "not an admin" before it reads any row, and the pinned batch-equivalence query multiset is unchanged.
622
+
623
+ **The variable takes a list.** `OS_PLATFORM_OWNER_EMAIL` accepts one address or a comma-separated list of them — one normalization (`trim().toLowerCase()`), duplicates collapsed, blank entries dropped. ⛔ Any entry that is not an address **fails the whole variable closed** with a loud refusal naming it, rather than being skipped: silently dropping a typo would leave a narrower administrator set than the operator declared, with nothing anywhere to notice. Unset, blank or refused all mean **zero** config-derived administrators.
624
+
625
+ **Verified-email match only.** An unverified account holding a configured address confers nothing, and an ABSENT `email_verified` column reads unverified. The match reads the caller's own **stored** `sys_user` row, never the caller-supplied session email.
626
+
627
+ New exports from `@objectstack/core`: `resolvePlatformAdminEmails`, `parsePlatformAdminEmails`, `matchesConfiguredPlatformAdmin`, `normalizePlatformAdminEmail`, `PLATFORM_ADMIN_EMAIL_SEPARATOR`, `ADMIN_STANDING_NON_TABLE_INPUTS` and the test hooks beside them. `@objectstack/core` now depends on `@objectstack/types` (measured acyclic: `types` depends only on `spec`).
628
+
629
+ `@objectstack/plugin-auth`'s break-glass guard follows the derivation, as it must: `ADMIN_STANDING_SURFACE.sys_user` is reclassified `derives`, the last-administrator enumeration counts config-derived administrators through the resolver's own predicate, and a fifth write shape is judged — a change of address or an `email_verified` reset that would leave the environment with no administrator is refused, naming the configuration as the remedy. An ordinary profile write still costs the guard no reads.
630
+ - b72db01: fix(spec,core): `PluginHealthMonitor` stops claiming a restart it never performed; the three `PluginHealthCheck` restart keys retired (#12032, ADR-0049)
631
+
632
+ <!-- adr-0087: registered plugin-auto-restart-never-reinitialised -->
633
+
634
+ **BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep
635
+ launch-window convention ships it as `minor`; the prescriptions are registered
636
+ under protocol major 18 — three `RETIRED_KEYS_BY_MAJOR[18]` entries plus the D3
637
+ semantic entry `plugin-auto-restart-never-reinitialised` — where
638
+ `os migrate meta` users will look). Graded `minor` rather than `major` for the
639
+ same reason #12340 and #12428 were, the day before, in this same module.
640
+
641
+ ## What was measured
642
+
643
+ `PluginHealthMonitor.attemptRestart` called `plugin.destroy()` and stopped
644
+ there. The comment above the call read *"Call destroy and init to restart"*,
645
+ and `init` appeared in `health-monitor.ts` **only inside that comment**. So a
646
+ plugin whose health checks crossed `failureThreshold` with `autoRestart: true`
647
+ got: `destroy()`, a log line reading `Plugin restarted`, status `recovering`,
648
+ and periodic health checks that carried on running against the destroyed
649
+ instance. The default check when no `checkMethod` resolves is
650
+ `{ name: 'plugin-loaded', status: 'passed' }`, which a destroyed object passes
651
+ indefinitely — so the **terminal** report on a torn-down, never-re-initialised
652
+ plugin was `healthy`.
653
+
654
+ Reproduced at `ee3595cefd` before anything was changed, with
655
+ `successThreshold: 3`:
656
+
657
+ ```
658
+ round 1 (failing): status=failed destroyed=0 alive=true
659
+ after backoff: status=recovering destroyed=1 alive=false
660
+ recovery round 1: status=recovering destroyed=1 alive=false
661
+ recovery round 2: status=recovering destroyed=1 alive=false
662
+ recovery round 3: status=healthy destroyed=1 alive=false
663
+ ```
664
+
665
+ #11955 made that report *more* convincing rather than less: reaching `healthy`
666
+ now costs `successThreshold` consecutive passing rounds, so a destroyed plugin
667
+ has to earn a declared number of passes before it is misreported.
668
+ `restartAttempts` was incremented as though a restart had occurred, and
669
+ `maxRestartAttempts` / `restartBackoff` scheduled further "restarts" of a plugin
670
+ that was never brought back up.
671
+
672
+ ## Why REMOVE and not the other two ADR-0049 states
673
+
674
+ **ENFORCE** would have to build the restart, and the class cannot host one.
675
+ `Plugin.init(ctx)` needs a `PluginContext`; the only two `plugin.init(...)` call
676
+ sites in the tree are the kernel's own boot loops (`kernel-base.ts:202`,
677
+ `kernel.ts:607`), both over the full plugin list, with a context that is
678
+ `private` on `ObjectKernel` and `protected` on `KernelBase`. No host can obtain
679
+ one, so a host-provided re-init hook would have had nothing to call. (Positive
680
+ control for that scan: the same pass resolves five real non-test
681
+ `plugin.destroy()` call sites, so it does see lifecycle drivers.) Building a
682
+ per-plugin re-init API for a caller that does not exist — no runtime constructs
683
+ `PluginHealthMonitor` (#11825) — is the speculation ADR-0049's staged decision
684
+ names as the wrong default at this milestone, where the shippable liability is
685
+ the false promise and not the missing feature.
686
+
687
+ **EXPERIMENTAL** requires a roadmap. A scan of the whole `docs/` planning + ADR
688
+ corpus returned **zero** mentions of plugin auto-restart, against 118 control
689
+ hits for "health" and 13 for "hot reload" in the same corpus.
690
+
691
+ `maxRestartAttempts` and `restartBackoff` leave with `autoRestart` rather than
692
+ as a tidy-up: with no restart, *"Maximum restart attempts before giving up"* and
693
+ *"Backoff strategy for restart delays"* have nothing left to be the vocabulary
694
+ **of** — the test that took `distributedConfig` out with the `stateStrategy`
695
+ value it was documented as requiring (#12340).
696
+
697
+ ## What changes for a host
698
+
699
+ All three keys are **tombstoned**, not deleted: `PluginHealthCheckSchema` is not
700
+ `.strict()`, so a bare deletion would be a silent strip (#3733, ADR-0104) — a
701
+ milder form of the defect being retired. A TypeScript host gets a `tsc` error
702
+ (the keys are typed `never`); a parse raises the prescription; and
703
+ `PluginHealthMonitor.registerPlugin` refuses a hand-built config carrying any of
704
+ them with an ADR-0112 envelope (`code: VALIDATION_ERROR`, `status: 400`), thrown
705
+ before any state is stored so a refused config leaves no half-registered plugin
706
+ behind.
707
+
708
+ `PluginHealthMonitor` no longer calls `plugin.destroy()` at all. A plugin that
709
+ crosses `failureThreshold` is reported `degraded` / `unhealthy` / `failed` and
710
+ left running; acting on that is the host's job in this host-driven library
711
+ (#11825 route 2). Poll `getHealthStatus(pluginName)` / `getHealthReport(pluginName)`
712
+ and restart at the level that owns the plugin's lifetime.
713
+
714
+ Everything else in the monitor is unchanged: registration, periodic checks, the
715
+ `timeout` race and its refd-timer guard (#4875), both failure routes sharing the
716
+ counters (#11852), and `successThreshold` binding from every status that records
717
+ a failure (#11955). `recovering` is now written only by the success branch —
718
+ the one writer that ever meant it.
719
+ - 49f0dcf: feat(core): retire the inert `PluginMetadata` surfaces — `configSchema` with `PluginConfigValidator`, and `hotReloadable` (#11982, #12587, ADR-0049)
720
+
721
+ <!-- adr-0087: not-required (runtime-interface-only packages/core/src/plugin-loader.ts#PluginMetadata) PluginMetadata is a runtime TS interface in packages/core with no Zod schema, no spec declaration and no stored representation; no metadata surface references it (the PluginMetadata in packages/spec/src/kernel/plugin-validator.zod.ts is an unrelated locally-declared homonym). The deleted PluginConfigValidator / createPluginConfigValidator were runtime classes in the same non-metadata module family, so `objectstack migrate meta` has nothing to rewrite; the compiler is the notification channel — TS2353 on the removed fields, TS2305 on the removed exports. -->
722
+
723
+ **BREAKING**: removes a published-but-inert capability from the `.` entry of
724
+ `@objectstack/core`. Shipped as `minor` under the lockstep launch-window
725
+ convention (a `major` bump is refused repo-wide by `check:changeset-no-major`).
726
+
727
+ Removed, each measured at zero live consumers with positive controls (the
728
+ sibling `startupTimeout` is read live by the kernel's startup timeout guard);
729
+ maintainer ruled retire under ADR-0049 enforce-or-remove, 2026-08-27,
730
+ decision-inbox batch 5; recorded in ADR-0025 §3.7:
731
+
732
+ - `PluginMetadata.configSchema` — declared "Configuration schema for
733
+ validation", but the mechanism could never run: the loader's only call
734
+ passed no config, and no caller could — plugin factories close over their
735
+ config, so the kernel never receives it. Every one of ~40 production
736
+ `kernel.use()` compositions already passes config as constructor arguments
737
+ and works.
738
+ - `PluginConfigValidator` / `createPluginConfigValidator` — the validator
739
+ behind that field: real code with zero reachable invocations, deleted along
740
+ with its unit test and its export from the security barrel.
741
+ - `PluginMetadata.hotReloadable` — declared "Whether plugin supports hot
742
+ reload" with zero reads and zero declarations: `HotReloadManager.reloadPlugin`
743
+ gates only on its own registered reload configs, so `hotReloadable: false`
744
+ was hot-reloaded identically to `true`.
745
+ - The `packages/core/ADVANCED_FEATURES.md` example whose inline comment
746
+ promised "Config is validated before init is called" — false on the
747
+ retired ref, and the retired surface's only in-repo declaration site.
748
+
749
+ One-line fixes, per symbol. If you declared `configSchema` on a plugin:
750
+ delete the field and parse your config at the plugin's own seam —
751
+ `MyConfigSchema.parse(options)` in the plugin factory or constructor, the
752
+ pattern `packages/rest` uses. If you imported `PluginConfigValidator` or
753
+ `createPluginConfigValidator`: delete the import and hold your own
754
+ `schema.parse` call; the compiler (TS2305) locates every such site. If you
755
+ declared `hotReloadable`: delete the field — it never gated anything, and
756
+ hot-reload participation remains governed solely by
757
+ `HotReloadManager.registerReloadConfig`.
758
+
759
+ Re-declaring a kernel-owned config-validation surface is a fresh decision for
760
+ the day ADR-0025's plugin distribution layer lands, with #11982's zero-caller
761
+ measurement as its starting evidence.
762
+ - add4360: fix(core): tell "service never registered" apart from "service failed to construct" on the async path (#13905)
763
+
764
+ `PluginLoader.getService` — reached through `Kernel.getServiceAsync` — answered two
765
+ different facts with the same bare `Error`. "Nothing ever registered this service" and
766
+ "the service is registered and could not be built" arrived at a caller as one
767
+ indistinguishable rejection, separated only by message text.
768
+
769
+ That was load-bearing one layer out. `RestServer.computeExecCtx`'s kernel branch absorbs a
770
+ failed `getServiceAsync('objectql')` and degrades to "no engine is wired", and it must keep
771
+ doing so — a kernel with no data plane is a supported configuration, declared by
772
+ `rest-api-plugin.ts` as `optionalDependencies: ['com.objectstack.engine.objectql']`. So a
773
+ multi-tenant host whose engine *failed to construct* reached the same resolver as "no
774
+ engine is wired", degrading silently where it should have refused loudly. The branch could
775
+ not be repaired from outside, because the fact it needed had been collapsed before it
776
+ arrived.
777
+
778
+ The asynchronous path now carries the distinction the **synchronous** context accessor in
779
+ `kernel.ts` has always drawn from the registry. `@objectstack/core` publishes exactly two
780
+ new symbols for it:
781
+
782
+ - `isServiceNotRegisteredError(err)` — true only when nothing was ever registered under
783
+ that name;
784
+ - `SERVICE_NOT_REGISTERED_CODE` — the code the rejection carries.
785
+
786
+ The test is closed and its default is loud: exactly one rejection in `getService` means
787
+ "never registered" and only that one is branded, so every other way it can fail — a factory
788
+ that threw, a missing scope id, an unset loader context, a circular service dependency —
789
+ stays unbranded, and a consumer that absorbs only the branded rejection is loud about
790
+ everything else, including rejections added later.
791
+
792
+ ⛔ Not message matching. Adding a second text classifier on a resolution path is the failure
793
+ mode this change removes: reading "not found" off the async path once reported every
794
+ missing service as `is async - use await` — the wrong fix, pointing at the wrong layer.
795
+
796
+ Nothing existing moves. The rejection keeps a byte-identical message and `name: 'Error'`;
797
+ the only observable change is the two added own-properties.
798
+
799
+ ### Patch Changes
800
+
801
+ - efb3513: fix(platform-objects,core): `sys_metadata_activation` ships tenant-less — drop the reserved organization column (#15024)
802
+
803
+ The ADR-0126 activation ledger records that **this environment** switched a
804
+ packaged artifact off. That is deployment-level state, owned by no
805
+ organization — so the table ships with no tenant column at all.
806
+
807
+ It briefly declared one: an `organization_id` marked "RESERVED", nullable, and
808
+ written by nobody, held for a per-organization dimension ADR-0126 §5
809
+ pre-charted. A reserved nullable tenant column is exactly the shape the
810
+ total-organization-ownership record proposed in PR #14976 rules out, and this
811
+ one had no reader either. **This is a plain removal, not a migration:** the
812
+ table landed after the 17.2.0 tag, so no released version ever carried the
813
+ column and no deployment has data in it. Should a per-organization dimension
814
+ ever be wanted, it returns as a separate org-owned object — never as a column
815
+ on this ledger.
816
+
817
+ What changed:
818
+
819
+ - **`sys_metadata_activation` declares `systemFields: { tenant: false }`** and
820
+ no longer declares the column. Both halves are needed: the tenant anchor is
821
+ INJECTED at registration, so deleting the field alone would have left the
822
+ column exactly where it was. ⚠️ Deliberately NOT `tenancy: { enabled: false }`
823
+ — that key is the ADR-0066 D2 platform-global *posture*, which the sibling
824
+ `sys_sso_provider` uses for the opposite shape (a table that KEEPS its tenant
825
+ column and needs the wall over it stood down). Here there is no column to
826
+ wall. Both spellings reach `plugin-security`'s `tenancyDisabled`, which is
827
+ required rather than incidental: a Layer 0 wall composing an equality on a
828
+ column the table does not have denies every row.
829
+ - **The declared unique index states `unique: 'global'`** over
830
+ `(metadata_type, name)` instead of `'organization'`. ⚠️ The materialized DDL
831
+ is unchanged: `normalizeDeclaredIndex` prepends the NULL-safe tenant key part
832
+ only when the table HAS a tenant column, so `'organization'` already degraded
833
+ to exactly these two columns. What changes is that the declaration now states
834
+ the boundary it actually gets, rather than claiming a per-organization one
835
+ that does not exist. Still explicit rather than bare `unique: true`, which
836
+ lint `unique/unscoped-declared-index` warns on and protocol 18 rejects.
837
+ - **`ObjectStoreMetadataActivationStore` drops its NULL filter and its
838
+ org-row skip.** `list()` is now every activation row of its type, scoped by
839
+ the `metadata_type` discriminator alone, and `setActive` takes the single row
840
+ its keyed read returns instead of picking the NULL-organization one out of
841
+ the result. Both guarded a column that no longer exists; the declared unique
842
+ index over the two columns the lookup keys on is what makes that read
843
+ single-valued. `ObjectStoreFlowActivationStore` and
844
+ `ObjectStoreActionActivationStore` inherit the change.
845
+
846
+ Unchanged, and pinned: the operator gate on activation writes under walled
847
+ postures (ADR-0126 D3), the `execute()`-time flow consult and the dispatch-time
848
+ action consult, "absence of a row means ACTIVE", re-enabling UPDATES the row
849
+ rather than deleting it, and a driver `0` reading as false. The pins that
850
+ asserted the reserved column and the org-row skip are rewritten to pin the
851
+ column's ABSENCE rather than deleted — including at the injection authority
852
+ (`resolveInjectedSystemColumns`, which decides whether the column exists) and
853
+ in a real booted stack, where the row's key set is a reading of the physical
854
+ table.
855
+ - e27583e: docs(core): the `AuthzStoreUnavailableError` brand doc states the measured `structuredClone` behaviour instead of claiming survival (#14006)
856
+
857
+ Documentation only — no runtime change, no type change, no accept/reject
858
+ behaviour moves. It ships as a patch because the docblock is a **published
859
+ byte**: `tsup`'s declaration rollup carries it into `dist/index.d.ts` and
860
+ `dist/index.d.cts`, so it is what a consumer reads on hover.
861
+
862
+ The brand's docblock justified the string-keyed own property with two reasons
863
+ joined by an `and`, of which only the second was true:
864
+
865
+ > A string-keyed own property (not a `Symbol.for` registry key) so it survives
866
+ > `structuredClone`, and so a duplicated copy of this module still brands
867
+ > identically.
868
+
869
+ Measured on Node 22.22.2: the structured-clone algorithm gives `Error` a
870
+ dedicated serialization carrying `message`, `stack` and `cause` only, and drops
871
+ every other own property — the brand, the ADR-0112 `code`, `status` and
872
+ `object` alike (a subclass's own `name` returns as `'Error'`). The
873
+ plain-object control is the half that proves it: `{ __brand: true, code: 'C' }`
874
+ keeps **both** keys through the same call, so the loss is specific to `Error`,
875
+ not general to `structuredClone`.
876
+
877
+ The property and the reason that actually earns it are kept — a duplicated copy
878
+ of the module still brands identically, which is exactly what `instanceof`
879
+ cannot do across two installed copies of `@objectstack/core`. The false half is
880
+ replaced by the measured behaviour, carrying the reproducible script and the
881
+ Node version rather than a second unsourced assertion, and phrased to match
882
+ what `service-not-registered.ts` already records for its own brand (one
883
+ phrasing across the two modules, not two).
884
+
885
+ ⛔ The clone gap is deliberately NOT "fixed" with a `toJSON` or a custom
886
+ serialization: no call site crosses a clone boundary today
887
+ (`rethrowAuthzStoreUnavailable` on the rest rethrow paths,
888
+ `isAuthzStoreUnavailableError` inside service `catch` blocks — all in-process),
889
+ and adding one would widen the module's surface with nothing pulling on it. The
890
+ docblock instead names the trap the false claim invited: branching on the brand
891
+ across a worker or `postMessage` boundary would answer `false` and fail OPEN on
892
+ a security path.
893
+ - 983edf1: fix(core): `autoRestart` now fires for a health check that throws or times out, not only for one that returns a failure (#11852)
894
+
895
+ `PluginHealthMonitor.performHealthCheck` reaches its failure handling by two
896
+ disjoint routes, and only one of them could ever restart the plugin.
897
+
898
+ A check that **returned** a failure (`false` or `{ status: 'unhealthy' }`)
899
+ incremented `failureCounters`, cleared `successCounters`, and — once
900
+ `failureThreshold` consecutive failures accumulated — consulted `autoRestart`
901
+ and restarted the plugin. A check that **threw** took a separate `catch` block
902
+ that incremented `failureCounters` and stopped there: it never cleared
903
+ `successCounters` and never read `autoRestart`. Because `raceCheckTimeout`
904
+ rejects rather than resolving, every `timeout` overrun lands in that `catch`,
905
+ so a plugin that hung was marked `failed` and never restarted no matter how
906
+ many rounds passed or what `autoRestart` said. The severer of the two failure
907
+ modes was the one that could not trigger recovery.
908
+
909
+ Both routes now funnel into one `recordFailedRound` step that owns the
910
+ counters, the `failureThreshold` comparison and the `autoRestart` decision, so
911
+ a thrown or timed-out check is restart-eligible on exactly the same terms as a
912
+ returned failure.
913
+
914
+ The per-route *status* label is deliberately unchanged: a throw is still the
915
+ separate `failed` status applied immediately with no threshold, as
916
+ `content/docs/protocol/kernel/lifecycle.mdx` documents. Only the counters and
917
+ the restart decision are shared — those are what `failureThreshold` and
918
+ `autoRestart` declare, and neither names a route.
919
+ - 8120808: feat(tooling): `@objectstack/core` declares a `typecheck` script, and its test and examples layers enter the ratchet (#14613)
920
+
921
+ `packages/core/package.json` declared exactly `build`, `test` and `test:watch`.
922
+ Around twenty sibling packages declare `typecheck`, and `turbo run typecheck`
923
+ selects only packages that declare the task — so the lint workflow's typecheck
924
+ job had no way to reach this package, and `pnpm --filter @objectstack/core
925
+ typecheck` failed with `ERR_PNPM_RECURSIVE_RUN_NO_SCRIPT` for anyone who tried
926
+ it. The package's types ship anyway: `build` emits a 233 KB `dist/index.d.ts`,
927
+ and rest, runtime, mcp, services and plugins all import it.
928
+
929
+ The state was tracked but not runnable. `check:type-check-coverage` carried
930
+ `@objectstack/core` as a DEBT entry of 98 and had already re-measured it once
931
+ (91 to 98), so nothing was invisible — but a ledger only the gate can read is
932
+ not something a contributor working in the package can run, which is how a
933
+ dispatched task came to assume the script existed.
934
+
935
+ **Measured at `84b8190ae`, dependency closure built first.** The undivided
936
+ program (`tsc --noEmit -p tsconfig.json`, exactly as the DEBT entry measured it)
937
+ reports 98 errors across 12 files, and every one of the 12 is a `.test.ts`. The
938
+ same program restricted to the 63 non-test source files reports **zero**. So the
939
+ build layer graduated as it stood, and the 98 did not have to be repaired before
940
+ the script could exist.
941
+
942
+ **94 of the 98 were the check, not the code.** The repair is the split this
943
+ repo already runs for `spec`, `rest`, `objectql` and `client`: `tsconfig.json`
944
+ stays the build config and excludes the test layer; a new `tsconfig.test.json`
945
+ compiles that layer under the module semantics vitest actually executes it with
946
+ (`module: esnext`, `moduleResolution: bundler`), which retires 22 x TS2835, the
947
+ TS2347 beside them and the share of 71 x TS7006 they cascade into — an import
948
+ that does not resolve makes every symbol it names `any`. **No test file was
949
+ edited.** Strictness is inherited and untouched. The residue is 4 errors over 4
950
+ files, held per file and per signature in `test-typecheck-debt.json`, EXACT and
951
+ shrink-only.
952
+
953
+ **The `examples/` half was found by the new script, not by the card.** Declaring
954
+ `typecheck` flips the package from COVERED-BY-LEDGER to COVERED-BY-SCRIPT, and
955
+ `check:type-check-coverage`'s SOURCES_COVERED invariant immediately reported
956
+ `packages/core/examples` — 2 non-test source files in no tsc program at all.
957
+ Neither had ever compiled: `kernel-features-example.ts` imported `../index.js`
958
+ (above the package root, never existed) and `phase2-integration.ts` imported
959
+ `@objectstack/core`, i.e. this package self-referencing by a name it declares in
960
+ no dependency block. Collapsing that cascade exposed rather than removed errors,
961
+ 12 to 29, all of them real and none of them new: 20 reads of `ObjectKernel`'s
962
+ **private** `logger`; four members of the security scan result that do not exist
963
+ (`passed`, `score`, `summary.critical`, `summary.high`, where the type carries
964
+ `status` and per-severity counts); and two config literals passing the unparsed
965
+ shapes where `PluginHealthMonitor.registerPlugin` and
966
+ `HotReloadManager.registerPlugin` are declared over the `Parsed` ones. That last
967
+ pair is retirement drift — this file was edited by two retirements (restart keys,
968
+ `watchPatterns`) while no tsc program could check the result. Every correction is
969
+ pinned to this package's own signatures; `packages/spec` was not touched.
970
+
971
+ `packages/core` therefore leaves the DEBT ledger: the coverage gate now reads
972
+ 70/79 packages type-checked with 9 ledgered, where it read 68/78 with 10.
973
+ - f658793: Restore the #10096 standing invariant (「浏览器可达的 spec 导出面必须
974
+ schema-free」) for `@objectstack/core`'s plural→singular store-key fold.
975
+
976
+ `@objectstack/spec`: the `defineStack()` manifest-collection vocabulary
977
+ (`PLURAL_TO_SINGULAR`, `SINGULAR_TO_PLURAL`, `pluralToSingular`,
978
+ `singularToPlural`) moved to a schema-free module and is now ALSO exported
979
+ from the sanctioned schema-free entry `@objectstack/spec/meta-spelling`
980
+ (widened per the #10096 ruling's reference pattern). `@objectstack/spec/shared`
981
+ keeps the same four symbols as re-exports — no consumer-visible removal. The
982
+ manifest map and `META_URL_TO_SINGULAR` remain deliberately distinct contracts
983
+ (#8424).
984
+
985
+ `@objectstack/core`: `canonicalMetadataServiceType`'s one value import moves
986
+ from `@objectstack/spec/shared` to `@objectstack/spec/meta-spelling`, so
987
+ browser consumers of `@objectstack/core` (every `@objectstack/client` bundle)
988
+ no longer link the zod schema closure through the store-key fold.
989
+ - 0a8ebf3: Scope the legacy platform-admin deprecation pointer to walled tenancy postures
990
+
991
+ The request-side notice that tells an operator their unscoped `admin_full_access`
992
+ grant row is the OLD anchor — "it is removed in a later release", "re-anchor this
993
+ deployment by declaring its administrators in configuration" — was emitted without
994
+ regard to the deployment's tenancy posture, so it fired on `single` rigs too.
995
+
996
+ `single` is the DEFAULT posture, and on a `single` rig that row is not legacy at
997
+ all: the boot-time `bootstrapPlatformAdmin` mints it to promote the first human
998
+ user, and that promotion is ruled correct and unchanged. Such a deployment was
999
+ therefore being told, once per process, to migrate off an anchor that is not
1000
+ scheduled to go away, toward a variable its own promotion is pinned never to read.
1001
+
1002
+ The pointer is now gated on `postureEnforcesWall(resolveTenancyPosture())`, the
1003
+ same predicate and the same source the boot-side detector already reads, so the
1004
+ migration window's loudness is scoped to the walled postures actually in it.
1005
+ Walled rigs are unaffected and still receive the notice.
1006
+
1007
+ ⛔ Standing is not touched: this is a log-line trigger, not access control. Every
1008
+ deployment resolves exactly the `PLATFORM_ADMIN` it resolved before.
1009
+ - fd289be: `HotReloadManager`'s refusal messages — the plugin-registration doors for retired `stateStrategy` values and removed config keys, and the `startWatching()` removal notice — no longer cite internal tracker ids. The prescriptions keep their customer-resolvable anchors (ADR-0049 enforce-or-remove, the `@objectstack/spec` / `@objectstack/core` versions, and the `scheduleReload` migration call); the `#NNNN` tokens, which resolve to nothing for the host author reading the refusal, are gone.
1010
+ - 2d5cee3: docs(core,service-cluster): retire the two docblocks left stale by `IPubSub`'s corrected delivery guarantee (#12836)
1011
+
1012
+ #12651 corrected `IPubSub`'s contract docblock: delivery is whatever the
1013
+ configured driver declares, no shipped driver exceeds at-most-once, a missed
1014
+ message is EXPECTED, and handlers must be idempotent **and** tolerate loss.
1015
+ Two docblocks elsewhere still described the world before that correction.
1016
+
1017
+ **`@objectstack/core` — `security/authz-invalidation-channel.ts`.** It carried a
1018
+ paragraph asserting, in the present tense, that the interface docblock "still
1019
+ says" *At-least-once delivery*, and that repairing it was a `packages/spec`
1020
+ change filed separately. That filing was #12651 and it has landed, so the
1021
+ paragraph is now false rather than merely stale — it sends the next reader
1022
+ looking for a live disagreement between the interface and the drivers that no
1023
+ longer exists. Replaced with a plain pointer to the interface docblock.
1024
+ Everything else in that docblock is unchanged: the at-most-once reasoning, the
1025
+ TTL-is-the-bound rule, and the best-effort-at-the-publish-site note all still
1026
+ hold.
1027
+
1028
+ **`@objectstack/service-cluster` — `memory/pubsub.ts`.** The line "At-least-once
1029
+ semantics held vacuously (a single in-process delivery)" was wrong on its own
1030
+ terms even before #12651: the same docblock states that handler errors are
1031
+ swallowed and logged via `onError`, so a handler that throws loses the message
1032
+ with no retry and no persistence. That is not at-least-once in any sense, and
1033
+ "vacuously" does not save it. Replaced with the honest statement — one
1034
+ synchronous in-process delivery attempt per subscriber, no persistence, no
1035
+ retry, no replay.
1036
+
1037
+ Prose only. No behaviour change, and no test changed.
1038
+ - a17da05: fix(core): only a backend fault populates `resolveLocalizationContext`'s failure memo (#11877)
1039
+
1040
+ `resolveLocalizationContext` memoizes an outcome for 30s whenever the read
1041
+ "failed" (#10221 — so a repeatedly-failing `sys_setting` query does not re-run,
1042
+ and the driver does not re-log it, on every request). The write condition was
1043
+ wider than the cache's own docblock: six legs set the flag and only **one** of
1044
+ them is the backend fault the docblock describes (the direct `ql.find` throw).
1045
+ The other five are the **settings service refusing** — a thrown `getMany`, each
1046
+ of the three older per-key `get`s, and the whole-block "service unavailable"
1047
+ handler.
1048
+
1049
+ Those five legs are reachable inside the settings engine's **bind window**
1050
+ (`SettingsService.getMany` refuses all-or-nothing for a `localization`
1051
+ namespace whose manifest is not yet registered), so:
1052
+
1053
+ - A caller that deliberately re-reads **after** the bind — the #11580 stdio
1054
+ repair re-resolves at `kernel:bootstrapped` for exactly this reason — was
1055
+ answered from the memo taken **inside** the window for up to 30s. The
1056
+ correction silently did not happen, with nothing in the output saying so.
1057
+ - A settings refusal standing alongside a perfectly **successful** direct read
1058
+ memoized that successful value — the staleness the docblock forbids outright
1059
+ and that `analytics-timezone.dogfood.test.ts` (#1982/#2018) exists to catch.
1060
+
1061
+ The memo is now written only for the direct-read fault. **#10221's protection
1062
+ is unchanged for the legs it was built for**: its environment (table not
1063
+ migrated yet) still memoizes, because the direct read throws there whether or
1064
+ not a settings refusal stands in front of it — pinned in both directions. And
1065
+ nothing is lost on the narrowed legs: those refusals throw out of an in-memory
1066
+ registry check *before* any query and *before* any log line, so memoizing them
1067
+ suppressed neither.
1068
+
1069
+ No signature, export or accepted-input change — the flag is internal to the
1070
+ module.
1071
+ - 7c41693: fix(core,plugin-auth,plugin-security): every `OS_PLATFORM_OWNER_EMAIL` reader asks the ONE list-aware parser (#13147)
1072
+
1073
+ `OS_PLATFORM_OWNER_EMAIL` accepts one address **or a comma-separated list** of
1074
+ them (#11663 Choice 2B). The list parse landed in a single home
1075
+ (`@objectstack/core`'s `platform-admin.ts`) and the authorization derivation
1076
+ consumed it — but every other reader kept calling `resolvePlatformOwnerEmail()`,
1077
+ which returns the operator's value trimmed and otherwise verbatim, and kept
1078
+ treating that whole string as ONE address.
1079
+
1080
+ An operator who configured a list therefore entered a self-contradictory state:
1081
+ authorization recognised them as a platform administrator, while four separate
1082
+ capabilities silently did nothing. Every direction failed **closed** — no
1083
+ privilege escalation existed at any point — but a declared capability vanished
1084
+ with no error anywhere:
1085
+
1086
+ - `bootstrap-platform-admin` promoted **nobody**, logging "will be promoted when
1087
+ that account registers" on every boot forever;
1088
+ - the walled operator stamp (`plugin-auth`) stamped **no** list member verified,
1089
+ so the account it should have provisioned was then refused elevation as
1090
+ `walled_owner_not_verified`;
1091
+ - `isVerifiedPlatformOwnerSession` / `platform-owner-wall-bypass` let **nobody**
1092
+ across the Layer 0 organization wall — the largest of the affected surfaces;
1093
+ - the walled boot diagnostic printed the raw list in the slot where an operator
1094
+ reads one address, and its dev-seed silence clause never matched.
1095
+
1096
+ All six readers now ask the same parser. `@objectstack/core` gains
1097
+ `isConfiguredPlatformAdminEmail(email, config)` — the membership half of
1098
+ `matchesConfiguredPlatformAdmin`, spelled once and shared, for the readers that
1099
+ hold a bare address rather than a `sys_user` row (the elevation gate keeps its
1100
+ two halves apart so `walled_owner_not_registered` and `walled_owner_not_verified`
1101
+ stay distinct answers; the stamp is handed an email before any row exists; the
1102
+ wall takes a fast negative before spending a row read). `PlatformAdminEmailConfig`
1103
+ gains `declaredSpellings`, the entries as the operator typed them, so the by-email
1104
+ `sys_user` lookup and the boot diagnostic get the as-typed form **from the one
1105
+ parse** instead of splitting the raw value a second time.
1106
+
1107
+ Behaviour for a single declared address is unchanged, including the
1108
+ case-insensitive match and the verbatim-spelling store lookup. A **refused**
1109
+ list (Choice 2B fails the whole variable closed on one unparseable entry) now
1110
+ reaches these readers as "zero administrators", which is the same answer they
1111
+ already gave for an unset variable — never a silently narrower set.
1112
+
1113
+ Two readers deliberately keep reading the raw value: the walled-boot refusal and
1114
+ the verification-path probe guard in `auth-plugin.ts` both use it as a pure
1115
+ truthiness test ("did the operator declare anything at all?"), which is
1116
+ grammar-independent. A census pin now enumerates the raw readers across both
1117
+ plugin packages and fails on a seventh.
1118
+ - 9688f58: `os plugin publish` now verifies the artifact's own declared `manifest.integrity` digests before uploading, and refuses the publish on a digest mismatch, a declared entry with no file, or a packaged file the map does not declare (an absent map still publishes — the field is optional). The pure checker, `verifyIntegrity`, lives in `@objectstack/core` beside the artifact-signature contract. Unpack-time re-verification remains the cloud control plane's obligation (#11331) and is not changed by this release.
1119
+ - 556ebc1: docs(core): correct `Plugin.type`'s TSDoc enumeration — it omitted `objectql` (#13762)
1120
+
1121
+ `Plugin.type` is typed `string` in `@objectstack/core`, so its TSDoc is the only
1122
+ enumeration a plugin author reading the interface ever sees; nothing type-checks
1123
+ them against it. That comment listed seven values while the declared set is
1124
+ eight: `PluginSchema.type` in `@objectstack/spec` is
1125
+ `z.enum(['standard', ...CORE_PLUGIN_TYPES])`, and `CORE_PLUGIN_TYPES` carries
1126
+ `objectql` — the type `packages/objectql/src/plugin.ts` declares on the engine
1127
+ plugin essentially every runtime loads first.
1128
+
1129
+ The comment now lists all eight and names `CORE_PLUGIN_TYPES` as the
1130
+ authoritative set. The same omission in the hand-written
1131
+ `content/docs/plugins/anatomy.mdx` transcription of this interface is corrected
1132
+ in the same change.
1133
+ - f7b25c5: fix(core): `successThreshold` now binds from every status that records a failure, so a declared count above 2 stops being unreachable (#11955)
1134
+
1135
+ `PluginHealthMonitor` consulted `successThreshold` only while a plugin's status
1136
+ was `unhealthy` or `degraded`. The first success in a recovery wrote
1137
+ `recovering` — a status that gate did not name — so the **second** success took
1138
+ the outer `else` and went straight to `healthy` without the counter being read
1139
+ at all. `failed` was in neither set either, so a plugin whose check threw
1140
+ recovered on its **first** success.
1141
+
1142
+ The declared value was therefore capped in practice:
1143
+
1144
+ | Status when the successes start | Consecutive successes actually required |
1145
+ | :--- | :--- |
1146
+ | `unhealthy` / `degraded` | 2, whatever `successThreshold` said |
1147
+ | `failed` / `recovering` | 1, whatever `successThreshold` said |
1148
+
1149
+ A declared `successThreshold: 5` was indistinguishable from `2`. The default is
1150
+ `1`, which is exactly the value at which the defect is invisible — every
1151
+ declared value above it was the one that misbehaved.
1152
+
1153
+ The counter is now consulted on the way out of every status that records an
1154
+ observed failure — `degraded`, `unhealthy`, `failed` and `recovering` — so
1155
+ `successThreshold: N` requires N consecutive successes from each of them, as
1156
+ its declaration says ("Consecutive successes needed to mark healthy"). The gate
1157
+ is a map that is exhaustive over `PluginHealthStatus`, so a status added to the
1158
+ spec fails to compile until it is placed on one side or the other; that is what
1159
+ `recovering` slipped through before.
1160
+
1161
+ `healthy` and `unknown` still promote on the first success, deliberately: the
1162
+ count is declared as a **recovery** criterion ("Number of consecutive successes
1163
+ to recover from unhealthy state") and neither of those records a failure to
1164
+ recover from — `unknown` is the status `registerPlugin` writes before any check
1165
+ has run.
1166
+
1167
+ **Behaviour change, only for configs that declare `successThreshold` above 1.**
1168
+ At the default `1` every route is byte-for-byte what it was: one success has
1169
+ always been enough and still is. A plugin declaring a higher count now takes
1170
+ the number of consecutive successes it asked for before it is reported
1171
+ `healthy`, including after a `failed` round and after an `autoRestart`.
1172
+
1173
+ This also makes #11852's `successCounters` reset load-bearing. That fix cleared
1174
+ the counter on the thrown failure route, and could not be pinned: the counter's
1175
+ only read site was unreachable with a stale non-zero value, so any test would
1176
+ have passed for the wrong reason. With `failed` gated on the counter, a throw
1177
+ that interrupts a recovery now demonstrably starts the count over.
1178
+ - Updated dependencies [809d417]
1179
+ - Updated dependencies [387e231]
1180
+ - Updated dependencies [f794e4e]
1181
+ - Updated dependencies [cae2169]
1182
+ - Updated dependencies [b812a54]
1183
+ - Updated dependencies [2d4fa75]
1184
+ - Updated dependencies [0e4e51b]
1185
+ - Updated dependencies [e84bbf6]
1186
+ - Updated dependencies [effae80]
1187
+ - Updated dependencies [d62f990]
1188
+ - Updated dependencies [c45d8e6]
1189
+ - Updated dependencies [2e3e8c7]
1190
+ - Updated dependencies [e621291]
1191
+ - Updated dependencies [40a93b5]
1192
+ - Updated dependencies [101ad2c]
1193
+ - Updated dependencies [d5b330d]
1194
+ - Updated dependencies [dda969c]
1195
+ - Updated dependencies [1f45690]
1196
+ - Updated dependencies [277948f]
1197
+ - Updated dependencies [8bdd955]
1198
+ - Updated dependencies [f3bbbef]
1199
+ - Updated dependencies [4f24e9d]
1200
+ - Updated dependencies [6a180e4]
1201
+ - Updated dependencies [474242f]
1202
+ - Updated dependencies [63cd487]
1203
+ - Updated dependencies [bd4aa4e]
1204
+ - Updated dependencies [803eaab]
1205
+ - Updated dependencies [f8e8f03]
1206
+ - Updated dependencies [eae824e]
1207
+ - Updated dependencies [f6fa22c]
1208
+ - Updated dependencies [8a483b3]
1209
+ - Updated dependencies [97bcd99]
1210
+ - Updated dependencies [df59de0]
1211
+ - Updated dependencies [96e25a8]
1212
+ - Updated dependencies [f75a38a]
1213
+ - Updated dependencies [7a25e7d]
1214
+ - Updated dependencies [1fa05a6]
1215
+ - Updated dependencies [c85a265]
1216
+ - Updated dependencies [dcb10a5]
1217
+ - Updated dependencies [773a999]
1218
+ - Updated dependencies [35dffea]
1219
+ - Updated dependencies [776a098]
1220
+ - Updated dependencies [5060877]
1221
+ - Updated dependencies [4f6325d]
1222
+ - Updated dependencies [52954c0]
1223
+ - Updated dependencies [2aa8456]
1224
+ - Updated dependencies [93809a3]
1225
+ - Updated dependencies [7c0d0c3]
1226
+ - Updated dependencies [daae7aa]
1227
+ - Updated dependencies [8dc22d6]
1228
+ - Updated dependencies [279431e]
1229
+ - Updated dependencies [948dd6b]
1230
+ - Updated dependencies [3b4c56c]
1231
+ - Updated dependencies [ae8edd2]
1232
+ - Updated dependencies [e25403c]
1233
+ - Updated dependencies [a81aa9d]
1234
+ - Updated dependencies [64baa68]
1235
+ - Updated dependencies [9fa70d7]
1236
+ - Updated dependencies [09db64a]
1237
+ - Updated dependencies [92916e7]
1238
+ - Updated dependencies [a84f3ea]
1239
+ - Updated dependencies [f2eaae8]
1240
+ - Updated dependencies [56c093c]
1241
+ - Updated dependencies [c09451b]
1242
+ - Updated dependencies [ba64877]
1243
+ - Updated dependencies [7345308]
1244
+ - Updated dependencies [79b6a22]
1245
+ - Updated dependencies [30d96ab]
1246
+ - Updated dependencies [f658793]
1247
+ - Updated dependencies [c95ad19]
1248
+ - Updated dependencies [e58ea8b]
1249
+ - Updated dependencies [4a17645]
1250
+ - Updated dependencies [3795c5f]
1251
+ - Updated dependencies [8ab926b]
1252
+ - Updated dependencies [7317cf2]
1253
+ - Updated dependencies [e25e839]
1254
+ - Updated dependencies [5997207]
1255
+ - Updated dependencies [8b13cc8]
1256
+ - Updated dependencies [4a4a35d]
1257
+ - Updated dependencies [86e765a]
1258
+ - Updated dependencies [1d7e76a]
1259
+ - Updated dependencies [53dc739]
1260
+ - Updated dependencies [fd289be]
1261
+ - Updated dependencies [03bf7b1]
1262
+ - Updated dependencies [f90e820]
1263
+ - Updated dependencies [18d816a]
1264
+ - Updated dependencies [e8bd715]
1265
+ - Updated dependencies [b91c351]
1266
+ - Updated dependencies [a28a3c0]
1267
+ - Updated dependencies [daeaaf9]
1268
+ - Updated dependencies [c459da6]
1269
+ - Updated dependencies [e914733]
1270
+ - Updated dependencies [f887e52]
1271
+ - Updated dependencies [881f8d8]
1272
+ - Updated dependencies [3bfa1e6]
1273
+ - Updated dependencies [901355c]
1274
+ - Updated dependencies [34ce8e7]
1275
+ - Updated dependencies [33681ea]
1276
+ - Updated dependencies [bfe13c8]
1277
+ - Updated dependencies [0fb3044]
1278
+ - Updated dependencies [4635f3e]
1279
+ - Updated dependencies [ee3595c]
1280
+ - Updated dependencies [b2eab95]
1281
+ - Updated dependencies [93940d4]
1282
+ - Updated dependencies [3a04b01]
1283
+ - Updated dependencies [45b9051]
1284
+ - Updated dependencies [b9e9227]
1285
+ - Updated dependencies [d395692]
1286
+ - Updated dependencies [5894d30]
1287
+ - Updated dependencies [a3765f6]
1288
+ - Updated dependencies [e22158f]
1289
+ - Updated dependencies [7404925]
1290
+ - Updated dependencies [0c2334f]
1291
+ - Updated dependencies [778c59f]
1292
+ - Updated dependencies [d2619fd]
1293
+ - Updated dependencies [6acb11a]
1294
+ - Updated dependencies [33c5fd3]
1295
+ - Updated dependencies [20b0fdb]
1296
+ - Updated dependencies [905019b]
1297
+ - Updated dependencies [a286411]
1298
+ - Updated dependencies [98c0d33]
1299
+ - Updated dependencies [368a82e]
1300
+ - Updated dependencies [a3d5724]
1301
+ - Updated dependencies [93ea19b]
1302
+ - Updated dependencies [9ee2dcf]
1303
+ - Updated dependencies [8cb96ec]
1304
+ - Updated dependencies [8f10a79]
1305
+ - Updated dependencies [6269a55]
1306
+ - Updated dependencies [22e5236]
1307
+ - Updated dependencies [0fb8760]
1308
+ - Updated dependencies [e5ce2ed]
1309
+ - Updated dependencies [be21955]
1310
+ - Updated dependencies [bc56e18]
1311
+ - Updated dependencies [be21955]
1312
+ - Updated dependencies [a9ee989]
1313
+ - Updated dependencies [4d0d944]
1314
+ - Updated dependencies [15d58db]
1315
+ - Updated dependencies [d63b014]
1316
+ - Updated dependencies [9abe4e4]
1317
+ - Updated dependencies [2cc7122]
1318
+ - Updated dependencies [50d6c92]
1319
+ - Updated dependencies [9e0ba21]
1320
+ - Updated dependencies [311433f]
1321
+ - Updated dependencies [3e5ad08]
1322
+ - Updated dependencies [9abe4e4]
1323
+ - Updated dependencies [b7131f3]
1324
+ - Updated dependencies [e5812fa]
1325
+ - Updated dependencies [7085f90]
1326
+ - Updated dependencies [dee4dd4]
1327
+ - Updated dependencies [ce7e497]
1328
+ - Updated dependencies [51ecb2f]
1329
+ - Updated dependencies [9086761]
1330
+ - Updated dependencies [42a117b]
1331
+ - Updated dependencies [1401ae7]
1332
+ - Updated dependencies [4297fe7]
1333
+ - Updated dependencies [e398863]
1334
+ - Updated dependencies [d16df74]
1335
+ - Updated dependencies [f11fc61]
1336
+ - Updated dependencies [e808890]
1337
+ - Updated dependencies [8f79379]
1338
+ - Updated dependencies [e6ca40e]
1339
+ - Updated dependencies [0c77ea4]
1340
+ - Updated dependencies [52954c0]
1341
+ - Updated dependencies [89eb997]
1342
+ - Updated dependencies [aa5994e]
1343
+ - Updated dependencies [be93457]
1344
+ - Updated dependencies [a65db76]
1345
+ - Updated dependencies [2cf5a96]
1346
+ - Updated dependencies [15eb2c9]
1347
+ - Updated dependencies [5691b07]
1348
+ - Updated dependencies [2a6122b]
1349
+ - Updated dependencies [225e769]
1350
+ - Updated dependencies [8af88dd]
1351
+ - Updated dependencies [fb5fbb8]
1352
+ - Updated dependencies [d7b3963]
1353
+ - Updated dependencies [b72db01]
1354
+ - Updated dependencies [dce5cd4]
1355
+ - Updated dependencies [177ebdc]
1356
+ - Updated dependencies [8d237b4]
1357
+ - Updated dependencies [2d2e6f0]
1358
+ - Updated dependencies [2d8dd8d]
1359
+ - Updated dependencies [22d573e]
1360
+ - Updated dependencies [b5a2398]
1361
+ - Updated dependencies [348860c]
1362
+ - Updated dependencies [5383fa6]
1363
+ - Updated dependencies [5b3ff63]
1364
+ - Updated dependencies [1a6a19c]
1365
+ - Updated dependencies [527e050]
1366
+ - Updated dependencies [dd33bf9]
1367
+ - Updated dependencies [4cb2a90]
1368
+ - Updated dependencies [74a7804]
1369
+ - Updated dependencies [53d3689]
1370
+ - Updated dependencies [b3a63d3]
1371
+ - Updated dependencies [033a34c]
1372
+ - Updated dependencies [4d25d22]
1373
+ - Updated dependencies [1ffee51]
1374
+ - Updated dependencies [5ae4303]
1375
+ - Updated dependencies [ece4dad]
1376
+ - Updated dependencies [e9b377e]
1377
+ - Updated dependencies [146f448]
1378
+ - Updated dependencies [735f5c7]
1379
+ - Updated dependencies [a7e18de]
1380
+ - Updated dependencies [366f895]
1381
+ - Updated dependencies [dc75ba8]
1382
+ - Updated dependencies [cce0aa9]
1383
+ - Updated dependencies [e764507]
1384
+ - Updated dependencies [cff17af]
1385
+ - Updated dependencies [39404f3]
1386
+ - Updated dependencies [ca1965f]
1387
+ - Updated dependencies [8619f95]
1388
+ - Updated dependencies [b706af9]
1389
+ - Updated dependencies [db8c288]
1390
+ - Updated dependencies [0e5fe7f]
1391
+ - Updated dependencies [fc9ba76]
1392
+ - Updated dependencies [0f94cc7]
1393
+ - Updated dependencies [a11c1a5]
1394
+ - Updated dependencies [71f9cd1]
1395
+ - Updated dependencies [ee17d86]
1396
+ - Updated dependencies [cdbd920]
1397
+ - Updated dependencies [18c432e]
1398
+ - Updated dependencies [3c418c4]
1399
+ - Updated dependencies [fa8715a]
1400
+ - Updated dependencies [a933ed7]
1401
+ - Updated dependencies [b3ca463]
1402
+ - Updated dependencies [a933ed7]
1403
+ - Updated dependencies [0d4a6a8]
1404
+ - Updated dependencies [518d5e5]
1405
+ - Updated dependencies [6643ba1]
1406
+ - Updated dependencies [eeba2ef]
1407
+ - Updated dependencies [ec4c4d2]
1408
+ - Updated dependencies [424f73c]
1409
+ - Updated dependencies [cccbe51]
1410
+ - Updated dependencies [a8d6b1d]
1411
+ - Updated dependencies [e4a7695]
1412
+ - Updated dependencies [87075b1]
1413
+ - Updated dependencies [fc58a99]
1414
+ - Updated dependencies [14cfc00]
1415
+ - Updated dependencies [1c6f7b4]
1416
+ - Updated dependencies [e854a53]
1417
+ - Updated dependencies [dfebfc8]
1418
+ - Updated dependencies [d028b37]
1419
+ - Updated dependencies [122ef38]
1420
+ - Updated dependencies [4a37870]
1421
+ - Updated dependencies [428f9b2]
1422
+ - Updated dependencies [aa7ff56]
1423
+ - Updated dependencies [c41b42e]
1424
+ - Updated dependencies [c4db311]
1425
+ - Updated dependencies [750fff5]
1426
+ - Updated dependencies [c19035e]
1427
+ - Updated dependencies [ececf7a]
1428
+ - Updated dependencies [d173125]
1429
+ - Updated dependencies [8eeca27]
1430
+ - Updated dependencies [8425c17]
1431
+ - Updated dependencies [a5ef1d8]
1432
+ - Updated dependencies [87ad30c]
1433
+ - Updated dependencies [772d5de]
1434
+ - Updated dependencies [ce80ec2]
1435
+ - Updated dependencies [b372318]
1436
+ - Updated dependencies [97a2263]
1437
+ - Updated dependencies [29d0676]
1438
+ - Updated dependencies [0169d49]
1439
+ - Updated dependencies [6bd3231]
1440
+ - Updated dependencies [d2b5ba8]
1441
+ - Updated dependencies [b799ac5]
1442
+ - Updated dependencies [8f74307]
1443
+ - Updated dependencies [d23dc08]
1444
+ - Updated dependencies [644ad50]
1445
+ - Updated dependencies [9735662]
1446
+ - Updated dependencies [4d5b4f8]
1447
+ - Updated dependencies [0da7cd2]
1448
+ - Updated dependencies [28a5c3e]
1449
+ - Updated dependencies [4bc18e5]
1450
+ - @objectstack/spec@17.3.0
1451
+ - @objectstack/types@17.3.0
1452
+
1453
+ ## 17.2.0
1454
+
1455
+ ### Minor Changes
1456
+
1457
+ - 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.
1458
+
1459
+ 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`.
1460
+
1461
+ 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`).
1462
+
1463
+ ### Patch Changes
1464
+
1465
+ - 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.
1466
+ - 47cd3ec: The kernel's two `Promise.race` timeout guards — the startup guard around each
1467
+ plugin's `init`/`start`, and the shutdown guard around `performShutdown()` —
1468
+ now reclaim **both** halves of the guard when the race settles: the timer is
1469
+ cleared *and* the losing promise is settled (#10604).
1470
+
1471
+ Neither site settled its loser, so the timeout promise and the reaction
1472
+ `Promise.race` held on it were retained for the life of the process — four
1473
+ leaking promises per showcase test run under `vitest --detectAsyncLeaks`, now
1474
+ zero. The two hand-rolled copies had also drifted into doing opposite halves of
1475
+ the same cleanup: the startup site cleared its timer and never `unref`'d, the
1476
+ shutdown site `unref`'d and never cleared. Both now go through one internal
1477
+ `TimeoutGuard`, so they cannot drift apart again. No exported API changes.
1478
+
1479
+ **Behaviour change, at the shutdown guard:** the shutdown timer is no longer
1480
+ `unref()`d. Two consequences for an embedding host (CLI, auth-proxy, test
1481
+ runner):
1482
+
1483
+ - After a **successful** shutdown, no timer is left armed. Previously the guard
1484
+ survived its own race and stayed scheduled to fire against a kernel already
1485
+ `'stopped'`. That late rejection was *handled* — `Promise.race` had attached a
1486
+ rejection handler to it — so this was never an unhandled-rejection risk; it
1487
+ was retained work and a wakeup after teardown.
1488
+ - When teardown **hangs**, the guard now actually fires. An unref'd timer does
1489
+ not keep the event loop alive, so a process with nothing else to run could
1490
+ exit silently — status 0, teardown incomplete — before `shutdownTimeout`
1491
+ elapsed, leaving `Shutdown timed out — forcing exit` and its `exit(1)`
1492
+ unreachable in exactly the case they exist for. Reclaiming on settle keeps the
1493
+ guard ref'd exactly as long as the race is undecided, which is the guarantee
1494
+ the startup guard already had (#4813).
1495
+
1496
+ If your host relied on a hung `shutdown()` letting the process fall out of the
1497
+ event loop on its own, it will now wait up to `shutdownTimeout` (default 60s)
1498
+ and then hard-exit with status 1. Lower `shutdownTimeout` in the kernel config
1499
+ to shorten that window.
1500
+ - 9d7d2de: `resolveLocalizationContext` now memoizes a FAILED read's fallback per `(ql, tenantId, userId)` for 30s (#10221).
1501
+
1502
+ 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.
1503
+
1504
+ 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.
1505
+ - 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.
1506
+ - 795ea05: A lapsed `sys_member` row now confers no org role either — one row, one answer (#10982)
1507
+
1508
+ `resolveUserAuthzGrants` reads `sys_member` once and derives two facts from it:
1509
+ `accessible_org_ids` (the `group` posture's read reach, ADR-0105 D2) and the
1510
+ org-administration role projection into `positions` (ADR-0095 D3). Only the
1511
+ first applied the ADR-0091 validity window. A membership outside
1512
+ `[valid_from, valid_until)` was therefore excluded from org access while still
1513
+ projecting its better-auth role — two answers from one read, and with
1514
+ `role: 'owner'` the role reaches the `organization_admin` capability that
1515
+ `derivePosture` reads for `TENANT_ADMIN`.
1516
+
1517
+ The role projection now drops out-of-window rows **before** the derivation, the
1518
+ same shape `sys_user_permission_set` already had, so an expired membership can
1519
+ no more yield `org_owner` than an expired `admin_full_access` can yield
1520
+ `platform_admin`. Fail-closed per ADR-0091 D2. Maintainer ruling, 2026-08-22
1521
+ live session (item 2): a lapsed membership is *no membership*, not merely *no
1522
+ org access*.
1523
+
1524
+ **Why `patch` and not a breaking bump, argued in the open.** This is a real
1525
+ change of authorization semantics — a membership that used to confer a role
1526
+ stops conferring it — so the direction is a tightening, and tightenings are the
1527
+ kind of change that normally earns a major. It is nevertheless `patch` because
1528
+ the population it can affect is provably empty: `sys_member` declares neither
1529
+ `valid_from` nor `valid_until` (see `sys-member.object.ts`), and `isGrantActive`
1530
+ reads an absent bound as unbounded, so **no row any deployment can currently
1531
+ store is lapsed** and every existing membership resolves exactly as before. That
1532
+ is asserted directly rather than reasoned about, in
1533
+ `resolve-authz-context.test.ts` ("a membership with NO bounds is unbounded —
1534
+ every shipped row is unaffected"), alongside the load-bearing leg that an
1535
+ in-window membership still projects its role. Landing it now is the cheap
1536
+ moment: once the columns exist, the same change becomes a migration carrying
1537
+ live semantics.
1538
+
1539
+ **Not in scope, and deliberately so.** This does not add the validity columns to
1540
+ `sys_member`, and it does not reach into `sys_user_permission_set` rows that
1541
+ plugin-security's `reconcileOrgAdminGrant` provisioned from a membership role.
1542
+ Such a grant is standing authority in its own right with its own ADR-0091
1543
+ window; the role is only its provisioning source (ADR-0095 D3). The boundary is
1544
+ pinned as a measured fact rather than left as an assumption.
1545
+ - 504c8d5: Materialize the RBAC catalog **per organization**, so a walled deployment can
1546
+ administer positions, permission sets and sharing rules again (#10103).
1547
+
1548
+ On a walled deployment (`group` / `isolated`) every principal — an organization
1549
+ owner and a platform admin alike — listed **zero** positions, permission sets
1550
+ and sharing rules while the tables held rows. Nothing could be bound through
1551
+ Setup, and a declared `hierarchy-security` could never be armed by an operator
1552
+ however loudly an app declared it.
1553
+
1554
+ Every row in those three tables was organization-less. plugin-security's Layer 0
1555
+ composes a strict `organization_id = :tenant` for a walled posture and the
1556
+ middleware ANDs it into the read AST over the driver's
1557
+ `(organization_id = :tenant OR organization_id IS NULL)`; the conjunction of the
1558
+ two is the strict equality alone, so the driver's null arm was annihilated on
1559
+ every authenticated read.
1560
+
1561
+ **The wall is not changed, at either layer.** The rows get an owner instead:
1562
+
1563
+ - `bootstrapDeclaredPositions`, `bootstrapBuiltinRoles`,
1564
+ `bootstrapDeclaredPermissions` (plugin-security) and
1565
+ `bootstrapDeclaredSharingRules` (plugin-sharing) upsert by
1566
+ `(name, organization_id)` and run **one pass per organization** under a walled
1567
+ posture — the framework built-ins (`platform_admin`, `org_*`, `everyone`,
1568
+ `guest`) included, matching `sys_user_position`, which is already
1569
+ per-organization, and matching both objects' own `unique: 'organization'` name
1570
+ index.
1571
+ - Seeding also fires on **organization creation**, not only at `kernel:ready`, so
1572
+ a tenant created after startup does not administer an empty catalog until the
1573
+ next restart.
1574
+ - `single` posture is **unchanged**: exactly one organization-less pass, which is
1575
+ the correct shape there.
1576
+
1577
+ An organization-less row is now invalid state under a walled posture. Nothing is
1578
+ reaped — grants (`sys_user_position`, `sys_position_permission_set`,
1579
+ `sys_user_permission_set`, `sys_record_share`) point at these rows by id, so
1580
+ deleting them would revoke standing access with no signal at the moment of loss.
1581
+ Instead a per-organization pass that meets pre-fix organization-less rows for
1582
+ names it seeds **says so loudly**, naming the rows and the remedy, and still
1583
+ creates that organization's own copies. The failure this closes is the silent
1584
+ no-op: a tenant-threaded pass that sees the old row through the driver's
1585
+ compatibility arm, reads the name as already represented, and creates nothing
1586
+ while reporting success.
1587
+
1588
+ Two enforcement-plane reads are scoped in the same change, because the exposure
1589
+ they carry only exists once per-organization copies exist:
1590
+
1591
+ - `resolveUserAuthzContext`'s position name-sweep (`@objectstack/core`) resolved
1592
+ `sys_position` by name across **every** organization, so the junction read
1593
+ behind it collected another organization's `everyone` binding — a cross-organization
1594
+ grant bleed, and an O(organizations) read on the per-request path. It is now
1595
+ threaded through the driver's tenant chokepoint, keeping per-request resolution
1596
+ O(the caller's own organization's catalog).
1597
+ - plugin-security's permission-set `dbLoader` resolved sets by name unscoped,
1598
+ with a `limit` equal to the number of names — correct while one row existed per
1599
+ name, a truncation the moment copies exist. It is now scoped to the caller's
1600
+ organization and its bound widened.
1601
+
1602
+ Boot reconciliation is O(changed declarations): each pass reads what its
1603
+ organization already has and writes only where a declaration actually differs, so
1604
+ the common boot performs no writes at all. Steady state rides the
1605
+ organization-creation hook.
1606
+
1607
+ Cross-links #10119 / PR #10422, whose criteria-sweep scoping makes per-organization
1608
+ sharing rules cheaper than the unscoped sweep they replace.
1609
+ - Updated dependencies [6936d07]
1610
+ - Updated dependencies [59eb04d]
1611
+ - Updated dependencies [9f05b7d]
1612
+ - Updated dependencies [7d2d112]
1613
+ - Updated dependencies [5fa0d72]
1614
+ - Updated dependencies [02b3b07]
1615
+ - Updated dependencies [914c413]
1616
+ - Updated dependencies [55809a0]
1617
+ - Updated dependencies [52db1d1]
1618
+ - Updated dependencies [5649efb]
1619
+ - Updated dependencies [2306a76]
1620
+ - Updated dependencies [e5ea701]
1621
+ - Updated dependencies [a40dcc1]
1622
+ - Updated dependencies [def0d3e]
1623
+ - Updated dependencies [8d0bb79]
1624
+ - Updated dependencies [5acb58d]
1625
+ - Updated dependencies [2e3cf95]
1626
+ - Updated dependencies [4c93387]
1627
+ - Updated dependencies [a037f7c]
1628
+ - Updated dependencies [3ee8ddf]
1629
+ - Updated dependencies [16cef97]
1630
+ - Updated dependencies [a79bd35]
1631
+ - Updated dependencies [6ceaa4b]
1632
+ - Updated dependencies [15ea214]
1633
+ - Updated dependencies [de19489]
1634
+ - Updated dependencies [c684d00]
1635
+ - Updated dependencies [923c424]
1636
+ - Updated dependencies [1ec36b7]
1637
+ - Updated dependencies [5f2e54c]
1638
+ - Updated dependencies [189373b]
1639
+ - Updated dependencies [35ad101]
1640
+ - Updated dependencies [ceb33a9]
1641
+ - Updated dependencies [73d9795]
1642
+ - Updated dependencies [8012960]
1643
+ - Updated dependencies [f34f56b]
1644
+ - Updated dependencies [f399618]
1645
+ - Updated dependencies [75e9301]
1646
+ - Updated dependencies [2810695]
1647
+ - @objectstack/spec@17.2.0
1648
+
3
1649
  ## 17.1.0
4
1650
 
5
1651
  ### Minor Changes