@objectstack/core 17.2.0 → 17.4.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,2104 @@
1
1
  # @objectstack/core
2
2
 
3
+ ## 17.4.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 2ed6be6: Advisory validation rules no longer flood the startup log, and no longer count a row twice on a clean first boot.
8
+
9
+ A `severity: 'warning'` (or `'info'`) validation rule is advisory: it never blocks a write, and its message is written for a person filling in a form. Evaluated across a seed load it produced one `WARN` line per row, so a clean-database first boot opened with a wall of form hints re-cast as boot diagnostics — and an app could reach "zero warnings" only by bending its data or deleting the rule.
10
+
11
+ Two changes, and neither moves what a rule evaluates to:
12
+
13
+ - **Aggregated reporting on the seed/boot path.** `SeedLoaderService.load()` now runs inside an advisory aggregation scope, and reports one summary line per rule — the rule, the object, the row count, the rule's own message and example rows — instead of one line per row. Off that path (an ordinary interactive write) nothing changes: the same per-write line is emitted verbatim. The new scope is `runWithAdvisoryAggregation` / `recordAdvisoryHit` in `@objectstack/core`.
14
+ - **Advisory rules are counted by row, not by write.** An `update` whose payload touches only platform-injected system columns — the shape `claimSeedOwnership` writes when it hands seeded rows to the first admin, `{ owner_id }` — changes no business field, so it no longer re-evaluates the object's advisory rules. Previously a seeded row rang once on insert and again when the claim scan rewrote `owner_id`, so anyone counting startup warnings over-estimated by the number of claimed objects.
15
+
16
+ `error`-severity rules are untouched by both changes: an invariant is still enforced on every write, whoever issued it and however little it moved. Membership of the "system column" set is resolved per object by `resolveInjectedSystemColumns`, so an object that declares `ownership: 'org'` (no `owner_id`) or `systemFields: false` is judged on its own columns rather than a fixed list.
17
+ - b0529e1: fix(core): `ResolvedAuthzContext.authRefusal` is removed — a published member nothing ever read (#14273)
18
+
19
+ **BREAKING** published-type narrowing, shipped as `minor` under the repo's
20
+ launch-window convention for breaking changes. `ResolvedAuthzContext` — the
21
+ envelope `resolveAuthzContext` answers, exported from `@objectstack/core`'s
22
+ root entry — loses its optional `authRefusal?: { reason; message }` member.
23
+ Maintainer ruling 2026-09-02 (option A, ADR-0049 enforce-or-remove),
24
+ re-affirmed 2026-09-03 as A1 with the carriers a published narrowing owes
25
+ once the type was measured as public API: the member was written by the two
26
+ posture-conditional API-key refusals (`organization_required` at admission,
27
+ `organization_membership_ended` after grants) since #8287 and read by nothing
28
+ — zero runtime readers across every transport and consumer in the repo for
29
+ its whole life; only test assertions ever looked at it.
30
+
31
+ What changes:
32
+
33
+ - `ResolvedAuthzContext` no longer declares `authRefusal`. Code that reads
34
+ `ctx.authRefusal` stops compiling (`TS2339`); at runtime the property was
35
+ already absent from every resolved context except the two refused ones.
36
+ - The two refusals themselves are UNCHANGED: they still fire, still fail
37
+ closed (no `userId`, empty grants), and every transport still answers the
38
+ generic anonymous `401 UNAUTHENTICATED`. No status code, body or header
39
+ moves — a holder of someone else's key learns nothing, exactly as before.
40
+ - The refusal REASON is observable on exactly one surface, and it is not the
41
+ envelope: the server-side `[security] API key refused (reason) ...` `warn`
42
+ line at the decision point (#15256 / 2A), which names the key row id,
43
+ principal and organization for the operator. The pins that kept the two
44
+ reasons distinguishable through the field now read that line.
45
+ - `ApiKeyRefusalReason` and `ApiKeyAdmission` are unchanged — the reason
46
+ vocabulary still exists; it just no longer has a copy on the resolved
47
+ context.
48
+
49
+ **Migration.** A consumer that read `ctx.authRefusal` deletes the read; there
50
+ is no replacement on the envelope, by design — disclosing the reason to a
51
+ caller (option B) was ruled out as a security-boundary question, and the
52
+ recorded fallback if a reader ever appears is an audit-side outlet (option C),
53
+ never the wire. Fail-closed handling keys on the absent `userId`, as every
54
+ in-repo transport already did. An operator who needs the reason reads the
55
+ server log line.
56
+
57
+ <!-- adr-0087: not-required (runtime-interface-only packages/core/src/security/resolve-authz-context.ts#ResolvedAuthzContext) A published runtime TypeScript interface lost an optional member. No Zod schema, no `packages/spec` declaration, no object definition and no stored representation is touched — `ResolvedAuthzContext` is a plain interface in `packages/core`, projected from no schema and referenced by no metadata surface — so `objectstack migrate meta` has nothing to rewrite and there is no tombstone to mint. The channel that reaches an affected consumer is the compiler at the read site (`TS2339`), which is more precise than a ledger line. The in-repo census (zero runtime readers; the only readers were test assertions, relocated onto the `warnApiKeyRefusal` line) and the workspace typecheck are recorded on the PR. -->
58
+ - 66dc6ab: Plugin startup elapsed time is now reported as `durationMs` — the unit-bearing name the spec contract for the same result declares. `startTime`, which never held a start time, is deprecated and still populated.
59
+
60
+ `PluginStartupResult.startTime` (`packages/core/src/plugin-loader.ts`) has always been assigned `Date.now() - startTime`, an elapsed duration, on both the success and the failure path. The name therefore asserts the opposite of the value: a reader who correctly takes `startTime` for an instant and writes `Date.now() - result.startTime` gets an age near the epoch rather than a wait. That is the one failure mode a unit convention cannot rescue — an ambiguous name makes someone stop and check, this one lets them proceed confidently wrong.
61
+
62
+ This is not a naming preference but a divergence between what is declared and what is enforced. `packages/spec/src/kernel/startup-orchestrator.zod.ts` declares `durationMs: z.number().min(0)` — "Time taken to start the plugin in milliseconds" — for the same measure on the same result, the outcome of starting one plugin; the bare `duration` spelling is retired there with a `retiredKey()` tombstone whose prescription is "Rename the key to `durationMs`", because a duration-shaped number carries its unit in its key name, never only in describe prose. The contract surface was already correct and `packages/core` had drifted away from it. The same computation already has an honest name twelve lines above the defect in the same file: `PluginLoadResult.loadTime` carries the identical `Date.now() - startTime` under a name that does not lie.
63
+
64
+ Three sites move, and every one of them is additive — nothing is removed, so no consumer has to change anything on this release:
65
+
66
+ - `PluginStartupResult` gains `durationMs?: number`. `startTime?: number` stays, still carrying the same value, marked `@deprecated` with a doc comment that states plainly it is elapsed milliseconds and not an instant.
67
+ - `ObjectKernel.getPluginStartupDurations()` is added; `getPluginMetrics()` becomes a `@deprecated` delegating alias returning the same map.
68
+ - The private `pluginStartTimes` map is renamed `pluginStartupDurations` (private; no reader outside `kernel.ts` in this repo or in the pinned `objectui` sibling).
69
+
70
+ Migration, where you want it: read `result.durationMs` where you read `result.startTime`, and `kernel.getPluginStartupDurations()` where you called `kernel.getPluginMetrics()`. The values are identical, so the change can be made at leisure; both old spellings keep working until they are removed.
71
+
72
+ ADR-0087 disposition: no migration-ledger entry, and none is required. Nothing is retired by this release — the old member and the old method both remain, populated and callable, which is ADR-0087's L1 outcome (the old shape keeps loading while the fleet moves) rather than a retirement. There is also nothing for `objectstack migrate meta` to rewrite: `packages/core/src/plugin-loader.ts#PluginStartupResult` is a runtime TypeScript interface with no Zod schema, no `packages/spec` declaration and no stored representation — the `PluginStartupResult` in `packages/spec/src/kernel/startup-orchestrator.zod.ts` is a separate, differently-shaped declaration that this change does not touch, and that schema's own `duration` tombstone entry (`packages/spec/src/migrations/entries/retired-keys/18.kernel__PluginStartupResult__duration.ts`) records that core's interface is not a reader of it. Core simply does not adopt the retired spelling. When the deprecated spellings are removed, that removal is the change that carries the ledger disposition.
73
+ - 2025b1f: `kernel.use()` now enforces the declared plugin contract. A plugin object that `PluginSchema` (`@objectstack/spec`, `kernel/plugin.zod.ts`) refuses is refused at load instead of being stored and mounted.
74
+
75
+ **BREAKING** accept-set narrowing on a published runtime entry point, shipped as `minor` under the repo's launch-window convention for breaking changes (`scripts/check-changeset-no-major.mjs`). **This refuses input the runtime accepted before**, which is also why it is not a `patch`: `PluginSchema` had zero runtime callers, so every constraint it declared beyond `name`, `init` and semver was a declaration with nothing behind it. The sharpest reading of that gap, one input and two answers: `defineStack` accepted `type: 'ui-plugin'` while `PluginSchema.safeParse` refused it — and only one of those answers was on the path a real plugin takes. Maintainer ruling of 2026-09-06 (ADR-0049 enforce-or-remove): the protocol is the baseline, the runtime aligns to it.
76
+
77
+ **Exactly what is newly refused: all EIGHT declared keys, not three.** The schema declares nine optional keys; the loader excludes `version` (below), so enforcement reaches these eight, each refused with the offending key named in the message:
78
+
79
+ - **`id`** — a non-string, or the empty string (`z.string().min(1)`).
80
+ - **`type`** — any value outside the closed set `standard`, `ui`, `driver`, `server`, `app`, `theme`, `agent`, `objectql`.
81
+ - **`staticPath`** — a non-string.
82
+ - **`slug`** — a non-string, or a string that does not match `/^[a-z0-9-_]+$/`.
83
+ - **`default`** — a non-boolean.
84
+ - **`description`** — a non-string.
85
+ - **`author`** — a non-string. An object such as `{ name: 'x' }` is refused; the declared type is a plain string.
86
+ - **`homepage`** — a non-string, or a string that is not a URL.
87
+
88
+ **`null` is refused on every one of the eight.** These keys are `.optional()`, which admits absence and `undefined` — never an explicit `null`. A plugin object that spells "no value" as `null` on any of the eight loaded before and is refused now.
89
+
90
+ **What a refusal looks like.** It travels the loader's existing plugin-load error path — no new error channel — carrying the stable code `PLUGIN_CONTRACT_VIOLATION` at the head of the message and on the error's `code` property, and naming the plugin plus the first violated key:
91
+
92
+ ```
93
+ PLUGIN_CONTRACT_VIOLATION: plugin '@acme/console' is refused by the declared
94
+ plugin contract at 'type': Invalid option: expected one of "standard"|"ui"|…
95
+ ```
96
+
97
+ A wrong `type` is therefore diagnosable at boot rather than at route mount. The code is a **boot refusal**, not wire vocabulary: it is raised before any HTTP boundary exists, and no door answers with it.
98
+
99
+ **What is STILL ACCEPTED — the door is not narrowed past those eight keys.** Measured on this tree, not assumed:
100
+
101
+ - **Unknown keys still pass.** `PluginSchema` is a plain `z.object` with **no `.strict()`** — the strip posture — and the parse output is discarded, so a valid plugin carrying four keys the schema never declares loads, and is stored as the very object that was passed in with all of its keys intact. A plugin is refused for what it says about a **declared** key, never for saying something extra.
102
+ - **A version-less plugin still loads**, exactly as before.
103
+ - **A plugin declaring no `type` still loads and still stores no `type`**: `PluginSchema`'s `.default('standard')` is **not** written back.
104
+ - **A class-based plugin keeps its identity, its prototype and its prototype methods.** The plugin object is validated, never replaced: `safeParse` is read for `success` and its output discarded, because a copy destroys the prototype chain of class-based plugins — the reason `PluginLoader.toPluginMetadata` is a cast. That survival is pinned by test, not asserted in prose.
105
+ - **`version` is excluded from this enforcement entirely**, so `1.0.0-alpha.1` and `1.0.0+20230101` still load. The schema spells `version` as `/^\d+\.\d+\.\d+$/`, which refuses the prerelease and build-metadata forms SemVer 2.0.0 defines, while the loader's own `isValidSemanticVersion` implements the full grammar and accepts them — deliberately, pinned by `plugin-loader.test.ts`. Enforcing the narrower spelling would retire that capability silently, so the loader's check remains authoritative for `version`. Reconciling the two spellings is spec work, tracked separately.
106
+
107
+ **Blast radius, measured rather than assumed.** Every in-repo plugin object declares a `type` inside the closed set (`standard` ×62, `server` ×2, `driver` ×2, `objectql`, `app`), and the repo contains no producer of `slug` or `homepage` on a plugin object at all — so no in-repo plugin changes behaviour. Externally authored plugins are the population this reaches, and they are exactly the population that never met the compile-time `Plugin.type` union either.
108
+
109
+ <!-- adr-0087: not-required (no-migration-prescription) An accept-set narrowing performed entirely at the runtime boot path: `PluginSchema` is READ by `kernel.use()`, not changed. No metadata key, spec symbol, Zod schema, object definition or stored representation is added, removed or given a different name, so `objectstack migrate meta` has nothing to visit and there is no tombstone to mint. Stored metadata is untouched; what moves is which plugin OBJECTS a boot accepts. The channel that reaches an affected plugin author is the refusal itself, which names the offending key at `kernel.use()` and is more precise than a ledger line — and which value a formerly-refused key should carry is authoring intent no ledger entry can decide. -->
110
+ - 51ae731: `LiteKernel.use()` now enforces the declared plugin contract — the same check, the same refusal, as `ObjectKernel.use()`. A plugin object that `PluginSchema` (`@objectstack/spec`, `kernel/plugin.zod.ts`) refuses is refused at registration on **both** published kernels instead of on one.
111
+
112
+ **BREAKING** accept-set narrowing on a published runtime entry point, shipped as `minor` under the repo's launch-window convention for breaking changes (`scripts/check-changeset-no-major.mjs`). **A plugin object `LiteKernel` accepted before can be refused now.** Until this release `LiteKernel.use()` wrote the object straight into its registry: `PluginSchema` was run by `PluginLoader.validatePluginContract` only, and `PluginLoader` is reached from `ObjectKernel.use()` alone. So the same plugin was accepted by one kernel and refused by the other — a `type: 'ui'` plugin with no `slug` was refused by `ObjectKernel` with `PLUGIN_CONTRACT_VIOLATION` and mounted a route on `LiteKernel`. `AGENTS.md` names `LiteKernel` for tests, serverless and edge, so the lenient kernel was the one authors develop against and the strict one was production: a plugin could be green in vitest and refused at boot. Maintainer ruling of 2026-09-08 (option A, under the precedent that the two kernels converge rather than diverge): `LiteKernel` validates too.
113
+
114
+ **Exactly what `LiteKernel.use()` newly refuses** is exactly what `ObjectKernel.use()` has refused since the `kernel.use()` enforcement release: all EIGHT declared keys, each refused with the offending key named in the message —
115
+
116
+ - **`id`** — a non-string, or the empty string.
117
+ - **`type`** — any value outside the closed set `standard`, `ui`, `driver`, `server`, `app`, `theme`, `agent`, `objectql`.
118
+ - **`staticPath`** — a non-string.
119
+ - **`slug`** — a non-string, or a string that does not match `/^[a-z0-9-_]+$/`.
120
+ - **`default`** — a non-boolean.
121
+ - **`description`** — a non-string.
122
+ - **`author`** — a non-string.
123
+ - **`homepage`** — a non-string, or a string that is not a URL.
124
+
125
+ **`null` is refused on every one of the eight**, and a `type: 'ui'` plugin missing `staticPath` or `slug` is refused with `PLUGIN_UI_REQUIRED_KEY_MISSING` inside the same envelope.
126
+
127
+ **What a refusal looks like — one refusal, from either kernel.** The check is now one function (`assertPluginContract`, package-internal) that both kernels call, so the code and the message are produced once:
128
+
129
+ ```
130
+ PLUGIN_CONTRACT_VIOLATION: plugin '@acme/console' is refused by the declared
131
+ plugin contract at 'slug': PLUGIN_UI_REQUIRED_KEY_MISSING: a `type: 'ui'` plugin must declare `slug` — …
132
+ ```
133
+
134
+ `LiteKernel.use()` is synchronous and throws that error as-is, so the stable code is on the error's `code` property as well as at the head of the message. `ObjectKernel.use()` is unchanged: it still re-wraps a failed load as `Failed to load plugin: <name> - <that message>`, its existing wrapper for every load failure. The text after that prefix is byte-for-byte the `LiteKernel` message for the same input, pinned by test.
135
+
136
+ **What is STILL ACCEPTED on `LiteKernel` — the narrowing stops where `ObjectKernel`'s does.** Unknown keys still pass (`PluginSchema` carries no `.strict()`, and the parse output is discarded, so the stored object is the very object passed in). A version-less plugin still loads, and so do `1.0.0-alpha.1` and `1.0.0+20230101`: `version` is excluded from the schema check on both kernels, and `LiteKernel` — which has never judged `version` — still does not. A plugin declaring no `type` still loads and still stores no `type`. A class-based plugin keeps its identity, its prototype and its prototype methods. And `PluginLoader`'s structural checks (`name`, `init`, semver) stay the loader's own: the convergence is on the schema, not on the loader.
137
+
138
+ **Ordering, stated because it is observable.** `LiteKernel.use()` checks its state first (a kernel past bootstrap still says `Cannot register plugins after bootstrap has started`, never `PLUGIN_CONTRACT_VIOLATION`), then the contract, then registers — so a refused plugin never reaches the registry and cannot supersede an earlier registration under its name.
139
+
140
+ **Blast radius, measured before landing rather than assumed.** Across this repository's suites, 813 `LiteKernel.use()` calls were reachable; 807 were accepted by the schema unchanged and the six refusals came from three test-local fixture objects in two files — zero product or library code. Externally authored plugins registered on `LiteKernel` are the population this reaches, and they are exactly the plugins that would already have been refused by `ObjectKernel` at production boot.
141
+
142
+ **Migration.** There is nothing to rename. A plugin refused on `LiteKernel` now was already refused on `ObjectKernel`; fix the named key: give `type` a value from the closed set (or drop it — an absent `type` reads as `standard`), declare `staticPath` and `slug` on a `type: 'ui'` plugin, spell `slug` in `[a-z0-9-_]`, make `homepage` a URL, and never `null` a declared key. The refusal names the plugin and the first violated key.
143
+
144
+ <!-- adr-0087: not-required (no-migration-prescription) An accept-set narrowing performed entirely at the runtime registration path: `PluginSchema` is READ by `LiteKernel.use()` now, exactly as `ObjectKernel.use()` has read it since the `kernel.use()` enforcement release — the schema itself is not changed. No metadata key, spec symbol, Zod schema, object definition or stored representation is added, removed or given a different name, so `objectstack migrate meta` has nothing to visit and there is no tombstone to mint. Stored metadata is untouched; what moves is which plugin OBJECTS the second kernel accepts, and every object it newly refuses was already refused by the first. The channel that reaches an affected plugin author is the refusal itself, which names the offending key at `use()` and is more precise than a ledger line — and which value a refused key should carry is authoring intent no ledger entry can decide. -->
145
+ - cf9bda4: The kernel's in-memory i18n fallback learns the declared `i18n.fallbackLocale`, so one declaration stops answering two ways (#15694)
146
+
147
+ `i18n.fallbackLocale` is authorable on the stack artifact (`TranslationConfigSchema`), and `FileI18nAdapter` — the provider `I18nServicePlugin` installs — has always honoured it: both boot paths construct it with `fallbackLocale || defaultLocale || 'en'`, and its `t()` consults that locale, per key, after the requested one.
148
+
149
+ The kernel's in-memory fallback is constructed with nothing. `AppPlugin.loadTranslations` injected the declared `defaultLocale` and `supportedLocales` (#7679) into whichever `i18n` service was registered, but never `fallbackLocale`, and the provider had no setter to receive one. On every stack running that fallback — any stack that declares `translations` without `@objectstack/service-i18n` registered (not installed, or `tierEnabled('i18n')` false) — the declaration was inert. A stack declaring `defaultLocale: 'zh-CN'` with `fallbackLocale: 'en'` answered a missing `zh-CN` key from `en` under `I18nServicePlugin` and from `zh-CN`, i.e. not at all, under the fallback: one declaration, two providers, two answers. That the fallback self-declares `degraded` licenses fewer capabilities, not a different answer to the same declared key.
150
+
151
+ What changed:
152
+
153
+ - **`II18nService.setFallbackLocale?(locale)`** — a new OPTIONAL member, the injection counterpart of `getFallbackLocale`. It is the same shape `setDefaultLocale` and `setSupportedLocales` already have, and for the same reason: the declaration lives on the stack artifact, which only the runtime app-plugin layer can see. A provider constructed with its fallback (`FileI18nAdapter`) omits the method and keeps the value it was built with.
154
+ - **`createMemoryI18n` receives it and acts on it.** `t()` now consults the declared fallback per KEY after the requested locale — the same second leg `FileI18nAdapter.t()` has. Per key, not per bundle: the pre-existing `resolveTranslations(locale) ?? mergedLocale(defaultLocale)` line swaps whole bundles and only when the requested locale has none, so a `zh-CN` bundle that simply lacked the key never reached anything else. That older leg is unchanged.
155
+ - **`AppPlugin.loadTranslations` threads the declaration**, through the same `typeof … === 'function'` optional-capability probe as `setDefaultLocale`, and guarded on the app having declared something — several `AppPlugin`s can share one kernel, and an app that declares no `i18n` block must not clear a fallback another app declared.
156
+
157
+ A stack that declares no `fallbackLocale` gets exactly the behaviour it has today: the setter is never called, and `t()` walks the same chain it always did. A fallback nobody asked for would be a new chain, not a fix.
158
+
159
+ `getFallbackLocale()` is deliberately still absent from the memory fallback. The setter is what the provider is TOLD; the accessor is what the serving layer ASKS it when building the metadata-document translators' fallback chain (#14882). Answering the second from `defaultLocale` — the only value always available there — would settle the default-locale contract question #14882 leaves deliberately open, from a degraded provider. Those reads keep the resolvers' own default, which is known and intentional.
160
+ - 2a3decc: `PluginSchema` now REQUIRES `staticPath` and `slug` when `type` is `'ui'`, and core's `Plugin` interface inherits every `PluginSchema` key from `PluginDefinition` instead of restating two of them.
161
+
162
+ **BREAKING** accept-set narrowing on a published schema, shipped as `minor` under the repo's launch-window convention for breaking changes (`scripts/check-changeset-no-major.mjs`). `packages/spec/src/kernel/plugin.zod.ts` described `staticPath` and `slug` as *"Required for type=\"ui\""* while declaring both `.optional()`, with nothing behind the prose; since `kernel.use()` runs the schema on the boot path (#16049), that was a promise the runtime visibly did not keep. This is the spec half of #16049, split by director ruling (decision batch #58, 2026-09-06).
163
+
164
+ **Exactly what is newly refused.** A plugin object with `type: 'ui'` that omits `staticPath`, omits `slug`, or spells either as `undefined`. Nothing else: every other declared type (`standard`, `driver`, `server`, `app`, `theme`, `agent`, `objectql`), and a plugin declaring no `type` at all, still parses with neither key. A PRESENT value is judged exactly as before — `slug` keeps its `/^[a-z0-9-_]+$/` regex, `staticPath` stays any string, and the empty string is not refused by this change.
165
+
166
+ **What a refusal looks like.** One zod issue per missing key, `path` naming the key, the new stable code `PLUGIN_UI_REQUIRED_KEY_MISSING` (exported from `@objectstack/spec/kernel`) at the head of the issue `message` and on the issue's `params.code`. At `kernel.use()` it rides the existing `PLUGIN_CONTRACT_VIOLATION` envelope unchanged, because the loader surfaces the first issue's `path` and `message` and reads nothing else:
167
+
168
+ ```
169
+ PLUGIN_CONTRACT_VIOLATION: plugin '@acme/console' is refused by the declared
170
+ plugin contract at 'staticPath': PLUGIN_UI_REQUIRED_KEY_MISSING: a `type: 'ui'`
171
+ plugin must declare `staticPath` — the absolute path of the static assets it
172
+ serves. Declare it, or drop `type: 'ui'` if this plugin serves no assets.
173
+ ```
174
+
175
+ **The fix for an affected plugin** is the one the message names: declare both keys (`staticPath`: the absolute path of the assets it serves; `slug`: the URL segment it is mounted under), or drop `type: 'ui'` if the plugin serves no assets. There is no fallback to lean on: the Hono server's `slug || name.split('/').pop()` derivation is no longer reachable through the kernel, because the object is refused before it is stored.
176
+
177
+ **`@objectstack/core` — `Plugin` derives its metadata keys.** `Plugin` now `extends PluginDefinition` (`z.input<typeof PluginSchema>`), so `id`, `type`, `staticPath`, `slug`, `default`, `version`, `description`, `author` and `homepage` are ONE declaration shared with the schema the kernel enforces. Additive for every existing implementer: `type` and `version` keep the shapes they had (`type` is still `PluginType | undefined`, pinned type-equal in `packages/rest`; `version` still `string | undefined`), and the seven other keys are new optional members. A `ui` plugin can now carry `staticPath` / `slug` without widening its own type. Runtime-only members (`name`, `dependencies`, `optionalDependencies`, `requiresServices`, `providesServices`, `init`, `start`, `destroy`) stay declared on the interface.
178
+
179
+ **Blast radius, measured.** No in-repo plugin object outside test fixtures declares `type: 'ui'` (searched `packages/`, `apps/`, `examples/` non-dist sources for a `type` key or class field holding the literal `'ui'`: three test files, nothing shipped), so no in-repo composition changes behaviour. Externally authored `ui` plugins that relied on the slug derivation, or declared no assets, are the population this reaches — and they are refused at boot, by name, with the key to add.
180
+
181
+ <!-- adr-0087: not-required (no-migration-prescription) An accept-set narrowing on plugin OBJECTS, which are never stored metadata: `PluginSchema` gains a refinement and one exported constant; no metadata key, object definition or stored representation is added, removed or renamed, so `objectstack migrate meta` has nothing to visit and there is no tombstone to mint. The channel that reaches an affected plugin author is the refusal itself, which names the missing key at `kernel.use()`; which value that key should carry is authoring intent no ledger entry can decide. -->
182
+ - cc00df2: feat(core)!: retire `PluginSecurityScanner` — plugin security scanning is not a platform capability (#14919)
183
+
184
+ <!-- adr-0087: registered plugin-security-scanner-retired -->
185
+
186
+ **ADR-0087 disposition: registered**, as `plugin-security-scanner-retired` in
187
+ `MIGRATIONS_BY_MAJOR[18].semantic` — a **D3 semantic** entry, not a D2 conversion,
188
+ and so not the metadata migration the ruling excludes. The class has no spec schema
189
+ and never had one, so there is no authorable key to tombstone with `retiredKey()`
190
+ and no stored `sys_metadata` row a conversion could rewrite: a scanner was
191
+ constructed per call and every result lived in a per-instance Map discarded with the
192
+ object, so `applyConversionsToStoredItem` has no seam that would ever see one. An
193
+ entry is nevertheless owed rather than optional, because this changeset carries a
194
+ real consumer prescription — the enforced channel is tsc at the import site, and for
195
+ any consumer it does not reach, the ledger and the generated upgrade guide are the
196
+ only channel there is. Same disposition as `contracts.IDataDriver.findStream` and
197
+ `actor-user-roles-to-positions`.
198
+
199
+ **BREAKING** — `PluginSecurityScanner` is removed from `@objectstack/core`,
200
+ together with its two companion types `ScanTarget` and `SecurityIssue`. Landing
201
+ as `minor` under the repo's launch-window convention for breaking changes.
202
+ **There is no replacement**, and none is planned.
203
+
204
+ ⚠️ **The out-of-repo consumer population for these three exports is NOT
205
+ MEASURED.** This changeset can state only what was measured *inside* the
206
+ sources this repo can read: zero constructors in objectstack, zero in objectui
207
+ at the pinned sha, and zero in the deleted example itself. How many published
208
+ consumers of `@objectstack/core` import the class is unknown — no download,
209
+ dependent or source telemetry was consulted. Read the removal as breaking for
210
+ an unmeasured population, not as a removal proven to break nobody.
211
+
212
+ ## Why it was removed rather than repaired
213
+
214
+ The class was a shell that reported success. `scan()` composed five private
215
+ scanners: four of them (`scanCode`, `scanMalware`, `scanLicenses`,
216
+ `scanConfiguration`) allocated an empty issue array, logged, and returned it
217
+ with no code in between — none could report a finding for any input. The fifth,
218
+ `scanDependencies`, ran a real loop but matched only against an in-memory
219
+ vulnerability database whose sole writer, the public `addVulnerability`, had
220
+ zero callers; `updateVulnerabilityDatabase()` logged twice and fetched nothing.
221
+ The database was therefore empty on every code path that has ever executed, so
222
+ no issue was ever produced, the score stayed 100, and the result was
223
+ `status: 'passed'` for every plugin the scanner was ever handed — a malicious
224
+ one included.
225
+
226
+ A security control that cannot fail is worse than no security control, because
227
+ callers rely on it. Repair — writing a real vulnerability scanner — was refused
228
+ by name: it is a feature with a design surface and no demand, not a defect fix.
229
+
230
+ ## FROM → TO
231
+
232
+ ```ts
233
+ // FROM — compiles today, and passes every plugin it is given
234
+ import { PluginSecurityScanner } from '@objectstack/core';
235
+
236
+ const scanner = new PluginSecurityScanner(kernel.logger);
237
+ const result = await scanner.scan({ pluginId, version, dependencies });
238
+ if (result.status === 'passed') { await kernel.use(plugin); }
239
+
240
+ // TO — delete it. The condition above was always true.
241
+ await kernel.use(plugin);
242
+ ```
243
+
244
+ **The one-line fix:** delete the import and every call; no symbol replaces it.
245
+ If your code branched on `result.status`, take the `'passed'` branch — that is
246
+ the only branch it ever took.
247
+
248
+ **If you were relying on it for actual security**, you were not getting any.
249
+ Audit dependencies with the tools built for it (`npm audit` / `pnpm audit`,
250
+ Dependabot, the GitHub Advisory Database, OSV) and treat an unaudited
251
+ third-party plugin as untrusted code. What ObjectStack does still enforce is
252
+ artifact **integrity and signatures** (`verifyPluginArtifactIntegrity`, the
253
+ plugin signature verifier — "is this what the publisher signed?", never "is
254
+ this safe?"), explicit plugin **permissions**, and the sandbox **resource
255
+ limits**; all three are unchanged.
256
+
257
+ Removed under ADR-0049 enforce-or-remove, per the maintainer ruling of
258
+ 2026-09-05 (director summon #14, decision batch #42). The retirement is pinned
259
+ as an export-list assertion on both barrels in
260
+ `packages/core/src/security/security-scanner-retirement.pin.test.ts`.
261
+
262
+ ### Patch Changes
263
+
264
+ - 6f94458: fix(core): narrow the operation-private-keys pin's scanner to `.ts`, so it judges exactly the population turbo re-runs it for (#15090)
265
+
266
+ `packages/core/src/security/operation-private-keys.pin.test.ts` filtered its
267
+ candidate set with `/\.tsx?$/` — `.ts` **and** `.tsx` — while this package's
268
+ declared radius in the cross-package declaration table is a `packages/**`
269
+ subtree glob ending in `.ts`. So the pin judged a population **strictly wider**
270
+ than the one either scoping layer of `check:cross-package-test-inputs` knows
271
+ about: Layer A never unions this package into the test shard when a `.tsx` file
272
+ changes, and Layer B never moves the `test` task's cache hash for one. A `.tsx`
273
+ file under `packages/` declaring its own `OPERATION_PRIVATE_KEY_PREFIX` or
274
+ `withoutOperationPrivateKeys` was therefore scanned by the pin and invisible to
275
+ CI's scoping — landing on `main` with every PR green and then reddening whichever
276
+ unrelated PR next touched a `.ts` file. That is the #7802 shape the declaration
277
+ table exists to close, one extension wide.
278
+
279
+ Repaired by narrowing the **scanner**, not by widening the **glob** — and that
280
+ asymmetry is measured rather than assumed. On `b548e438d`, adding a `.tsx` glob
281
+ to this package's roster entry and re-deriving `check:cross-package-test-inputs`'
282
+ watch hints flips the dispatch-gates self-test case *"nor a .tsx test file inside
283
+ it"* from true to false, with the added glob itself as the covering hint. That
284
+ case is a live specimen for "a test class the hint route cannot reach", so the
285
+ red is real and re-pointing it is a decision in another lane, not a fixup.
286
+
287
+ What the boundary costs, measured on the pin's own surface (tracked **plus**
288
+ untracked, ignored paths excluded) at `b548e438d`: **5408** `.ts` files scanned,
289
+ 8 of them mentioning a guarded symbol; **8** `.tsx` files excluded, **0** of them
290
+ mentioning either symbol. The loss is empty today — and that reading is no longer
291
+ transcribed and trusted. A new case re-measures it on every run: it asserts the
292
+ excluded `.tsx` population is non-empty (so the boundary is an exclusion and not
293
+ an empty tree describing itself), that the filter really drops those files, and
294
+ that none of them declares either symbol. Ablation, with the restore proven by
295
+ blob hash rather than by exit code: re-widening the scanner reddens it while the
296
+ offender assertion stays green — which is precisely the failure mode, since a
297
+ wider scanner reads as coverage CI never runs — and planting a `.tsx`
298
+ redeclaration reddens it with a message that says the choice is a second-gate
299
+ trade, not a one-line widening.
300
+
301
+ The correspondence between scanner and glob is now stated at **both** ends: the
302
+ pin's header and the declaration table's entry for this package. No published
303
+ surface moves — the only source file edited is a test.
304
+ - 6e67b86: refactor(core): the authz context's time-zone probe is now the shared value-domain predicate, not a third copy of it
305
+
306
+ `resolve-authz-context.ts` carried a module-private `isValidTimeZone` — the
307
+ `Intl.DateTimeFormat` probe, re-stated. It was the third copy of one
308
+ definition, alongside `@objectstack/spec/shared`'s `isValueDomainMember` and
309
+ `service-settings`' own re-statement. `coerceTimeZone` now calls
310
+ `isValueDomainMember('iana_time_zone', …)` and the copy is gone.
311
+
312
+ **No behavioural change, measured rather than asserted.** The two predicates
313
+ were run over a shared 4,058-input corpus — the zones
314
+ `Intl.supportedValuesOf('timeZone')` omits (`UTC`, `Asia/Kolkata`,
315
+ `Europe/Kyiv`, `Asia/Ho_Chi_Minh`, `US/Eastern`, `GMT`), every member of that
316
+ enumeration plus its case- and space-padded variants, refusals, `Etc/` and
317
+ offset spellings, legacy aliases, and fuzz — with **zero disagreements**, and
318
+ the same zero at the `coerceTimeZone` level. The call site's own
319
+ pre-processing (trim, stringify a non-string, refuse blank) is unchanged.
320
+
321
+ What this buys is drift resistance, not a fix: core's time-zone acceptance now
322
+ sits under the shared pins, so a future "modernisation" to
323
+ `Intl.supportedValuesOf('timeZone')` — which would silently narrow what the
324
+ authz context accepts, since that enumeration omits this platform's own
325
+ default `UTC` — turns a test red instead of shipping.
326
+ - e9fcd6b: feat(spec)!: the fourteen `kernel/` duration keys carry their unit in the key name (#15678, ruling B on #14478)
327
+
328
+ <!-- adr-0087: registered kernel-event-bus-retention-unit-in-key, kernel-package-lifecycle-durations-unit-in-key, kernel-plugin-health-report-durations-unit-in-key, kernel-plugin-security-durations-unit-in-key, kernel-startup-orchestrator-durations-unit-in-key -->
329
+
330
+ **BREAKING** — fourteen published `kernel/` duration keys are renamed and
331
+ tombstoned. Shipped as `minor` under the repo's launch-window convention for
332
+ breaking changes; the hand-migration prescriptions are registered under protocol
333
+ major 18. Maintainer ruling B on #14478 (2026-09-02, decision batch #43,
334
+ 「同意」).
335
+
336
+ `check:duration-unit-keys` makes a duration-shaped `z.number()` carry its unit
337
+ in the key NAME, never only in its `.describe()` prose, and grandfathers no
338
+ existing offender. Stack card 1/6 (#15676) landed the rule's two structural
339
+ exemptions and card 2/6 (#15677) cleared `api/`; this card clears `kernel/`.
340
+ Measured with the gate itself: `src/kernel/**` goes from 14 offenders to **0**,
341
+ and the whole-tree count falls **36 → 22**.
342
+
343
+ ## FROM → TO
344
+
345
+ | key | replacement | unit |
346
+ |:--|:--|:--|
347
+ | `EventPersistence.retention` | `retentionDays` | days |
348
+ | `EventSourcingConfig.retention` | `retentionDays` | days |
349
+ | `UpgradePlan.estimatedDuration` | `estimatedDurationSeconds` | seconds |
350
+ | `PluginHealthReport.metrics.uptime` | `uptimeMs` | milliseconds |
351
+ | `PluginHealthReport.metrics.responseTime` | `responseTimeMs` | milliseconds |
352
+ | `SandboxConfig.process.timeout` | `timeoutMs` | milliseconds |
353
+ | `KernelSecurityPolicy.authentication.tokenExpiration` | `tokenExpirationSeconds` | seconds |
354
+ | `KernelSecurityPolicy.auditLog.retention` | `retentionDays` | days |
355
+ | `PluginSecurityManifest.vulnerabilityDisclosure.responseTime` | `responseTimeHours` | hours |
356
+ | `PackageDependencyResolutionResult.resolvedIn` | `resolvedInMs` | milliseconds |
357
+ | `MultiVersionSupport.rollout.duration` | `durationMs` | milliseconds |
358
+ | `StartupOptions.timeout` | `timeoutMs` | milliseconds |
359
+ | `PluginStartupResult.duration` | `durationMs` | milliseconds |
360
+ | `StartupOrchestrationResult.totalDuration` | `totalDurationMs` | milliseconds |
361
+
362
+ **Every value is unchanged** — only key names move, and every default moves with
363
+ its key (`StartupOptions` still defaults to 30000, `EventSourcingConfig` to
364
+ 365). Every old spelling is a `retiredKey()` tombstone, so it fails `tsc` at the
365
+ authoring site (input type `never`) and fails the parse with the rename
366
+ prescription rather than a bare unrecognized-key error.
367
+
368
+ ## ⚠️ Two collisions this rename removes — check these by hand, not by search-and-replace
369
+
370
+ **`responseTime` meant two different units on two kernel shapes.** On
371
+ `PluginSecurityManifest.vulnerabilityDisclosure` it is HOURS (how fast a
372
+ publisher promises to answer a vulnerability report); on
373
+ `PluginHealthReport.metrics` the identical bare name is MILLISECONDS. So
374
+ `responseTime: 24` was a day on one shape and a fortieth of a second on the
375
+ other, with nothing at the authoring site to tell them apart. They land on
376
+ `responseTimeHours` and `responseTimeMs` respectively — do not let one
377
+ find-and-replace rewrite both.
378
+
379
+ **`uptime` is milliseconds here and SECONDS on `GET /health`.** That collision
380
+ was already costing prose: the protocol lifecycle page carried a standing
381
+ paragraph whose only job was telling the two apart. `metrics.uptime` becomes
382
+ `metrics.uptimeMs`; the seconds-valued `uptime` of the HTTP health body is a
383
+ separate, unchanged surface and must not be renamed with it.
384
+
385
+ A third split worth reading before you migrate: `estimatedDurationSeconds: 120`
386
+ is two MINUTES while `durationMs: 3600000` is one HOUR. Three adjacent
387
+ measurements of the same package install carried two different units, and no
388
+ parse can catch a value moved between them — both bounds accept any
389
+ non-negative integer.
390
+
391
+ ## Dispositions — five semantic entries, no D2 conversion
392
+
393
+ Justified per key rather than defaulted, and this card's answer is uniform:
394
+ **none of the fourteen gets an ADR-0087 D2 conversion.** A D2 conversion runs
395
+ over a stack document, and `stack.zod.ts` declares no `eventBus`, `startup`,
396
+ `upgrade` or plugin-security root — none of these twelve defs is a stack
397
+ collection member or a registered metadata kind stored as a `sys_metadata` row,
398
+ so the conversion chain has no seam that would see one. They are host
399
+ construction arguments (`EventBusConfig`, `StartupOptions`, `SandboxConfig`,
400
+ `MultiVersionSupport`), package artifacts (`PluginSecurityManifest`) and
401
+ runtime-emitted measurements (`PluginHealthReport`, `PluginStartupResult`,
402
+ `StartupOrchestrationResult`, `UpgradePlan`,
403
+ `PackageDependencyResolutionResult`). Each therefore carries a **semantic**
404
+ entry, which is the disposition `kernel/HealthStatus:timestamp` already holds on
405
+ one of these very files (`epoch-instant-keys-renamed`, card 1/6) and what ruling
406
+ B prescribes for a key that is not authorable metadata. All fourteen are
407
+ registered by exact key in `RETIRED_KEYS_BY_MAJOR`.
408
+
409
+ ## Keys deliberately left alone
410
+
411
+ `EventSourcingConfig.snapshotRetention` is a COUNT of snapshots and
412
+ `MultiVersionSupport.rollout.percentage` is a proportion — neither is a
413
+ duration, so neither has a unit to carry and both keep their names.
414
+ `RuntimeConfig.resourceLimits.timeout` names its unit only in the JSDoc above
415
+ the key ("Execution timeout in milliseconds"), a channel
416
+ `check:duration-unit-keys` does not read: it reads `.describe()` and
417
+ `.meta({ description })`, and this key's describe ("Maximum execution time")
418
+ names none. The gate therefore lists it among the duration-shaped keys but
419
+ deliberately does not judge it — neither an offender nor an exemption — so it is
420
+ outside this rename; that JSDoc-channel gap is filed as #15939. A pin test
421
+ asserts the key still parses bare, so a later sweep cannot read the four
422
+ security renames as "every timeout on that file".
423
+
424
+ ## Readers moved in the same PR, at the same magnitude
425
+
426
+ `@objectstack/core`'s health monitor (`metrics.uptimeMs: Date.now() -
427
+ startTime`), the kernel and contracts test suites, and the hand-written
428
+ `content/docs/protocol/kernel/lifecycle.mdx`, whose `uptime` paragraph now
429
+ states the collision the rename removes.
430
+
431
+ ⚠️ `packages/core/src/plugin-loader.ts` declares its OWN local
432
+ `PluginStartupResult` interface — a different type, carrying `startTime` rather
433
+ than any duration key. It is not a reader of this schema, it is untouched by
434
+ this rename, and the divergence between the two shapes is tracked separately.
435
+ - c78c918: Documentation: the manifest surface no longer describes itself as an open object.
436
+
437
+ `ManifestSchema` became a `strictObject` when the manifest surface was closed against unknown keys, but five prose sites still described the earlier posture. They shipped, so an author (or an AI writing metadata) reading the declarations was told the manifest tolerates undeclared keys — while the runtime rejects them by name and offers the declared spelling for a near miss. Prose that contradicts a tightened contract teaches exactly the wrong reflex, so each site now states the current refusal rather than merely dropping the old claim:
438
+
439
+ - `AssembledPackageBodySchema`'s docblock no longer explains its lack of a `strictObject` spelling by calling `ManifestSchema` open. The posture is inherited: the schema is `ManifestSchema.extend(...)`, and `.extend()` carries the base's unknown-key handling, so an undeclared key on an assembled body is refused — measured, with the rename suggestion intact.
440
+ - The artifact-registration seam kept the half of its reasoning that still holds (the schema applies defaults, so a parsed clone would not be byte-identical) and retired the half that does not ("Zod strips undeclared keys") — the key is now refused at that parse rather than dropped from the clone.
441
+ - The `os compile` per-package rule pass explains why a body may be re-read as its own manifest: nothing parses that superset, and against `ManifestSchema` it would now be refused.
442
+
443
+ No schema, behaviour or export changed; `check:api-surface` and the generated reference pages are unmoved.
444
+ - 4771bd9: The `Server is ready` line now reports the degraded boot it is standing on, instead of printing a green `✓` over it.
445
+
446
+ `✓ Server is ready` and the kernel's `System started with degraded capabilities. Missing core services: …` were two statements about one boot, produced by two packages — the banner in `@objectstack/cli`, the conclusion in `@objectstack/core` — with **no data path between them**. So the ready signal did not depend on the thing that broke, and therefore could not report it. Measured twice within a day, from unrelated causes: an objectui CI boot where the auth plugin failed and not one `sys_*` table existed, and this repo's own weekly registry canary on the published `npx create-objectstack@latest` on-ramp, where the tick printed directly **above** four boot warnings. In the second case the ready line carried no weight in the job's verdict at all — it was present, green, wrong, and believed by nobody.
447
+
448
+ - **The data path.** `ObjectKernel.validateSystemRequirements()` now publishes the list it had already computed — the same array behind its own warning — on the kernel's service registry, which is the seam boot facts already cross to reach the banner (`serve` reads `auth` and `seed-summary` off it the same way). No member and no type is added to `@objectstack/core`'s public surface, and nothing re-derives which services count as `core`: that judgement stays in `ServiceRequirementDef` alone.
449
+ - **The line.** On a degraded boot the banner prints `⚠ Server is ready — DEGRADED: missing core services: <names>`, naming exactly what the kernel found missing. On a healthy boot the ready block is byte-for-byte unchanged, so an ordinary boot's output does not move.
450
+ - **Readiness is NOT made strict.** Nothing about what boots, binds, or exits changes. A machine deliberately running without auth still starts, still prints ready, and still exits 0 — the line just says what state it is ready in.
451
+ - d4f9b2a: A session whose active organization is no longer one the user belongs to now resolves with no active organization instead of that one's data.
452
+
453
+ Under a wall-enforcing tenancy posture (`isolated` / `group`), `resolveAuthzContext` took a browser session's stored `activeOrganizationId` as the request tenant without ever comparing it to the user's current memberships — the framework's only such comparison was gated on an API-key principal. A session whose owner had been removed from an organization therefore kept reading that organization's rows and writing into it until the session expired on its own (7 days by default), including when the removal went through the product's own offboarding path.
454
+
455
+ That claim is now vetted: if it is not in the caller's `accessible_org_ids`, it is dropped and the context resolves with no active organization at all, which the tenant wall already fails closed on (reads resolve to nothing; a tenant-scoped write is refused by ADR-0123 D2). The principal is **not** refused — a session is a person who may hold memberships elsewhere, so they stay signed in and can switch to an organization they are actually in. The API-key arm is unchanged: a key is its organization binding and is still refused outright. The wire is unchanged; the drop is reported to the operator as a single server-side `warn`.
456
+ - a727043: fix(rest,core): an organization-less or ex-member API key on a walled single-kernel deployment now answers 401 where it answered 200
457
+
458
+ Under a wall-enforcing tenancy posture (`isolated`), an API key stamped with an
459
+ organization its owner is no longer a member of **read and wrote that
460
+ organization's rows** on the wiring the open core actually builds. Not a silent
461
+ empty set — a GET that returned the other organization's records, and a POST
462
+ that landed a row read back from the store carrying that organization's id and
463
+ the ex-member as its creator. An organization-less key on the same deployment
464
+ read `200` with an empty set, which is the silent failure the wall exists to
465
+ replace.
466
+
467
+ The cause was a seam, not a predicate. `RestServer.computeExecCtx` derived the
468
+ effective tenancy posture from a per-request kernel, and on the single-kernel
469
+ wiring there is no per-request kernel — so the posture was `undefined` on every
470
+ request, and both posture-conditional API-key refusals are gated on it:
471
+ `organization_required` in `api-key.ts` and `organization_membership_ended` in
472
+ `resolve-authz-context.ts`. Neither ever ran. The Layer 0 wall itself was
473
+ active the whole time; it compares against the caller's active organization,
474
+ and an API key's tenant is `sys_api_key.active_organization_id` copied verbatim
475
+ — the holder's own stored claim. Enforcing the wall is what let the ex-member
476
+ through, because the one fact that would expose the ended membership was not an
477
+ input to the layer that could act on it.
478
+
479
+ The single-kernel branch now derives the posture from a provider `rest-api-plugin`
480
+ wires to the lone local kernel's `tenancy` service, in the same shape as the
481
+ auth-service provider beside it. A host that registers no `tenancy` service is
482
+ unchanged and still admits: there is no wall on such a deployment, so there is
483
+ nothing for an organization-less key to be walled out of. A `tenancy` service
484
+ that was registered and **failed to build** is an outage and answers `503`, not
485
+ an admission — a posture that could not be read is not a posture that is absent.
486
+
487
+ Refusals are now also said out loud on the server side, at `warn`, where each
488
+ one is decided: the key's row id (never the credential or its hash), the
489
+ principal, the organization and the reason. **The wire is unchanged** — both
490
+ refusals still answer the generic `401 UNAUTHENTICATED` with no reason in the
491
+ body, so a holder of someone else's key learns nothing a plain 401 does not
492
+ already tell them. The operator, who previously had a key that was neither
493
+ revoked nor expired and a 401 that said nothing, now has a line to find.
494
+
495
+ Behaviour that does not move: a current member's key on the same route still
496
+ returns its rows and still writes; a request with no credential still answers
497
+ 401; and an unknown, revoked or expired key is not a refusal at all, so a key
498
+ scanner produces no log volume.
499
+ - c5d6803: Published `.js.map` files no longer embed the complete original source text (`sourcesContent`) — comments included. `sourcemap: true` was esbuild shorthand, and esbuild's own default for `sourcesContent` is `true`; nobody had decided to publish every package's full source (including `@internal`/test-only comments) to npm inside its source maps, it fell out of a default nobody had looked at. Measured before this change: 55 of 57 publishable packages shipped embedded source text, and maps were roughly half of `@objectstack/spec`'s published bytes.
500
+
501
+ `sourcesContent: false` is now set at one shared place (`scripts/tsup-drop-sources-content.mjs`, wired into every `tsup.config.ts` via tsup's `esbuildOptions` hook — most packages build through the repo-root config directly and pick this up with no config change of their own). `mappings` are untouched, so stack-trace positions still resolve correctly to the original file/line/column; only the embedded source text is gone.
502
+
503
+ `@objectstack/cli` (built with `tsc`, not `tsup`) never embedded source text to begin with — its maps' `sources` entries point at `src/**` paths that are not part of the published tarball either way. That is not a defect unique to `cli`: every `tsup`-built package's `sources` entries are `../src/**`-relative paths that are equally outside `files: ["dist", …]`, and were merely masked by the embedded content that just stopped shipping. Shipping `src/**` in `files[]` to make `sources` resolve was rejected — it would put most of the removed bytes straight back. So `cli`'s maps are left exactly as `tsc` emits them: this is now the fleet-consistent shape (accurate `mappings`, non-resolving-but-honest `sources` labels, no embedded text), not an outlier.
504
+
505
+ A new gate, `pnpm check:sourcemap-no-sources-content`, sweeps every built, non-private package's `dist/**/*.map` and fails if any of them carries a non-empty `sourcesContent` array — so a future `tsup.config.ts` that skips the shared hook, or a toolchain upgrade that changes esbuild's default back, is caught rather than silently re-publishing source text.
506
+ - f89812e: Five source comments in `@objectstack/cli` and `@objectstack/core` stop attributing unpack-time `manifest.integrity` re-verification to the cloud control plane and name the owner this repo has already ruled: the **future runtime loader** (ADR-0025 §3.5 steps 4–7). The enforce leg stays tracked on #11331.
507
+
508
+ `packages/spec`'s `manifest.zod.ts` was corrected to that owner in an earlier change, and these five sites were left behind — so the repo stated both things at once. A comment that names the wrong owner costs nobody a build, but it teaches a reader (and a reading AI) to expect a verification that no component performs and that ADR-0025's own status line records as unimplemented.
509
+
510
+ - `packages/cli/src/utils/osplugin.ts` — the `.osplugin` packaging docblock, and the `sriDigest` TSDoc.
511
+ - `packages/cli/src/commands/plugin/publish.ts` — the integrity-preflight comment.
512
+ - `packages/core/src/security/index.ts` — the `verifyIntegrity` export comment.
513
+ - `packages/core/src/security/plugin-artifact-integrity.ts` — the verifier's own module docblock, which had explained the module's byte-for-byte portability *by* the wrong owner. It now explains it by the leg itself: the module stays portable to whatever runs unpack-time re-verification.
514
+
515
+ **What does NOT change.** The other half of every one of these comments — the digest map is computed by `os plugin build` and self-checked by the `os plugin publish` preflight — is true and is kept verbatim. No accept set, export, signature or runtime behaviour moves; the diff is comment prose only.
516
+
517
+ **What moves for consumers, measured on the built output.** `@objectstack/cli` ships `dist/`, and the `sriDigest` TSDoc rides into `dist/utils/osplugin.d.ts`, so an editor's hover on `sriDigest` stops naming the control plane. `@objectstack/core`'s two sites do **not** reach its published bundle — a module docblock and a line comment above an `export {}` are both dropped from `dist/index.d.ts` — so nothing in that package's shipped bytes moves. It is declared here anyway because the pre-correction attribution is quoted in `packages/core/CHANGELOG.md`, a generated record that may not be hand-edited; a changeset naming the package is the only way the correction reaches that published record.
518
+ - Updated dependencies [fe0d9a4]
519
+ - Updated dependencies [ecd2158]
520
+ - Updated dependencies [f2b5e46]
521
+ - Updated dependencies [ed7243d]
522
+ - Updated dependencies [6ba0db4]
523
+ - Updated dependencies [625b0c3]
524
+ - Updated dependencies [233222e]
525
+ - Updated dependencies [07f40e5]
526
+ - Updated dependencies [ceb4877]
527
+ - Updated dependencies [e9fcd6b]
528
+ - Updated dependencies [90e7e6d]
529
+ - Updated dependencies [2bdabe6]
530
+ - Updated dependencies [ca326b5]
531
+ - Updated dependencies [8f404a5]
532
+ - Updated dependencies [68437d4]
533
+ - Updated dependencies [abb140c]
534
+ - Updated dependencies [8333a6c]
535
+ - Updated dependencies [3e3ecb0]
536
+ - Updated dependencies [3030369]
537
+ - Updated dependencies [d5d8d50]
538
+ - Updated dependencies [e08892d]
539
+ - Updated dependencies [ae05f2e]
540
+ - Updated dependencies [b548e43]
541
+ - Updated dependencies [c463d03]
542
+ - Updated dependencies [64bd6a3]
543
+ - Updated dependencies [13c48c2]
544
+ - Updated dependencies [132742f]
545
+ - Updated dependencies [85a2459]
546
+ - Updated dependencies [50dc214]
547
+ - Updated dependencies [e89fa92]
548
+ - Updated dependencies [e9fcd6b]
549
+ - Updated dependencies [8976ea1]
550
+ - Updated dependencies [56fe8c2]
551
+ - Updated dependencies [acabd24]
552
+ - Updated dependencies [ab50c8f]
553
+ - Updated dependencies [6491463]
554
+ - Updated dependencies [89cf4d6]
555
+ - Updated dependencies [21c5dcb]
556
+ - Updated dependencies [6d4d5d3]
557
+ - Updated dependencies [ed5d557]
558
+ - Updated dependencies [bca21f7]
559
+ - Updated dependencies [e9fcd6b]
560
+ - Updated dependencies [1a7a7c9]
561
+ - Updated dependencies [e9fcd6b]
562
+ - Updated dependencies [ef3a138]
563
+ - Updated dependencies [68d5dfd]
564
+ - Updated dependencies [3e21cf0]
565
+ - Updated dependencies [4cfc93b]
566
+ - Updated dependencies [efd6b43]
567
+ - Updated dependencies [859ded3]
568
+ - Updated dependencies [fa125f3]
569
+ - Updated dependencies [74628d9]
570
+ - Updated dependencies [a646120]
571
+ - Updated dependencies [6f1ce7d]
572
+ - Updated dependencies [7778115]
573
+ - Updated dependencies [2c753fe]
574
+ - Updated dependencies [52804cd]
575
+ - Updated dependencies [3f89967]
576
+ - Updated dependencies [53cf263]
577
+ - Updated dependencies [21aabbc]
578
+ - Updated dependencies [9c270bb]
579
+ - Updated dependencies [76c8c5a]
580
+ - Updated dependencies [088f761]
581
+ - Updated dependencies [a84e1ce]
582
+ - Updated dependencies [bf1054a]
583
+ - Updated dependencies [d8d2776]
584
+ - Updated dependencies [222dc0f]
585
+ - Updated dependencies [e9fcd6b]
586
+ - Updated dependencies [32c917d]
587
+ - Updated dependencies [f9a3c32]
588
+ - Updated dependencies [f502898]
589
+ - Updated dependencies [af7edfe]
590
+ - Updated dependencies [b60f48b]
591
+ - Updated dependencies [c78c918]
592
+ - Updated dependencies [cf9bda4]
593
+ - Updated dependencies [784cb92]
594
+ - Updated dependencies [7629f4d]
595
+ - Updated dependencies [51df9fd]
596
+ - Updated dependencies [a7da4de]
597
+ - Updated dependencies [de0bcdd]
598
+ - Updated dependencies [70f7d6d]
599
+ - Updated dependencies [c677cda]
600
+ - Updated dependencies [554a160]
601
+ - Updated dependencies [f7da71e]
602
+ - Updated dependencies [7f745c3]
603
+ - Updated dependencies [5eb24f8]
604
+ - Updated dependencies [2a3decc]
605
+ - Updated dependencies [cc00df2]
606
+ - Updated dependencies [f4e6adf]
607
+ - Updated dependencies [ee4a59b]
608
+ - Updated dependencies [4db3c61]
609
+ - Updated dependencies [5ca314a]
610
+ - Updated dependencies [e0af1a8]
611
+ - Updated dependencies [414c1fc]
612
+ - Updated dependencies [22c0279]
613
+ - Updated dependencies [0db2947]
614
+ - Updated dependencies [92b5d7f]
615
+ - Updated dependencies [613bfbd]
616
+ - Updated dependencies [abae16a]
617
+ - Updated dependencies [094b8fd]
618
+ - Updated dependencies [c7aca0d]
619
+ - Updated dependencies [c1d8f98]
620
+ - Updated dependencies [8e0b297]
621
+ - Updated dependencies [5f7fa1d]
622
+ - Updated dependencies [87f0ccc]
623
+ - Updated dependencies [aedbaef]
624
+ - Updated dependencies [c5d6803]
625
+ - Updated dependencies [10d05bb]
626
+ - Updated dependencies [69602e5]
627
+ - Updated dependencies [c3ce76c]
628
+ - Updated dependencies [7936b29]
629
+ - Updated dependencies [46803fa]
630
+ - Updated dependencies [c2a336c]
631
+ - Updated dependencies [9f890d3]
632
+ - Updated dependencies [0bb2318]
633
+ - Updated dependencies [f7db8f4]
634
+ - Updated dependencies [1ecee3e]
635
+ - Updated dependencies [9408b7f]
636
+ - Updated dependencies [e9fcd6b]
637
+ - Updated dependencies [9bcd9be]
638
+ - Updated dependencies [b398ad2]
639
+ - Updated dependencies [99261a7]
640
+ - Updated dependencies [81b426f]
641
+ - Updated dependencies [001af1c]
642
+ - Updated dependencies [fb77aa5]
643
+ - Updated dependencies [3d3f60e]
644
+ - Updated dependencies [581d8f8]
645
+ - Updated dependencies [f81afe3]
646
+ - Updated dependencies [40a44b9]
647
+ - Updated dependencies [7a7fb03]
648
+ - Updated dependencies [8fd246d]
649
+ - @objectstack/spec@17.4.0
650
+ - @objectstack/types@17.4.0
651
+
652
+ ## 17.3.0
653
+
654
+ ### Minor Changes
655
+
656
+ - 655b106: fix(metadata): register a `packages[]` artifact per package at the metadata door so every object has one owner across every door (#14599)
657
+
658
+ A release artifact carrying `packages[]` (ADR-0130 D4) was read at the metadata
659
+ door as if it carried one package: `MetadataPlugin._parseAndRegisterArtifact`
660
+ iterated the **flattened top level** and stamped every item with the artifact's
661
+ own `manifest.id`. For an artifact composed with `composeStacks(…, { manifest:
662
+ 'preserve' })` that id is one arbitrary member's — `selectManifest`'s `'last'`
663
+ pick — so a two-package artifact registered the **module's** object under the
664
+ **App** package's identity, while the ObjectQL load path, reading the same
665
+ artifact's `packages[]`, owned it under the module's.
666
+
667
+ The platform then held two answers to "who owns this object", and which one a
668
+ consumer saw depended on the door it went through. Measured on a real boot of
669
+ `examples/app-multi-package`:
670
+
671
+ - `GET /api/v1/meta/object` served `crm_order` **twice** — the list merge keys
672
+ slots by `${packageId}${name}`, so the two differently-attributed copies
673
+ landed in two slots;
674
+ - `GET /api/v1/meta/object?package=<the App package>` returned the **module's**
675
+ object, because the App-stamped copy was re-ingested into the registry as that
676
+ package's contribution;
677
+ - the layers door named the App package while the item door and
678
+ `GET /api/v1/packages` named the module;
679
+ - Studio's Data pillar for the App package listed the module's object — ADR-0130
680
+ Consequences §1.3a ("Studio's scope is the package") did not hold.
681
+
682
+ **The door now reads both shapes, and attributes every item to the body it was
683
+ found in.** `packages` present → each assembled package body's collections are
684
+ registered stamped with **that body's** id; `packages` absent → the single
685
+ `manifest` branch runs exactly as before (D7). The owner is read off the body an
686
+ item was found in — never reverse-derived by matching a top-level item's name
687
+ against a name-to-package index, which would be the second metadata-identity
688
+ resolution path #14512's triage rejected by name.
689
+
690
+ **Ordering and the entry gate are reused, not re-derived (D5).** The door calls
691
+ the same `resolveArtifactPackageOrder` the ObjectQL load path calls, so the two
692
+ readers of one `packages[]` cannot disagree about the registration order **or**
693
+ about which artifacts are loadable at all.
694
+
695
+ ⚠️ **`resolveArtifactPackageOrder` / `artifactPackageId` moved to
696
+ `@objectstack/core`** — hence the `minor` there. They were in
697
+ `@objectstack/objectql`, which **depends on** `@objectstack/metadata`, so the
698
+ metadata door could not import them from where they lived; `@objectstack/core`
699
+ already owns `resolvePluginOrder` and is already a dependency of both readers,
700
+ so hosting them there adds **no edge** to the package graph. `@objectstack/objectql`
701
+ re-exports both under their existing names — its published surface is unchanged,
702
+ which is why it is graded `patch`. `@objectstack/runtime` is `patch` for the
703
+ dispatcher error vocabulary's `file:` anchors, repointed at the new path.
704
+
705
+ **Single-package artifacts are byte-for-byte unaffected (D7)**, measured rather
706
+ than asserted: the whole `manager.register` sequence for a single-`manifest`
707
+ artifact — every call, in order, with the id and version each item was stamped
708
+ with — is pinned as a literal in
709
+ `packages/metadata/src/plugin-artifact-packages-attribution.test.ts` and was
710
+ recorded identically on both legs of the ablation. A real boot of
711
+ `examples/app-todo` answers every door identically before and after.
712
+
713
+ **Nothing a booted instance can see today disappears.** Every live
714
+ `ARTIFACT_FIELD_TO_TYPE` key is a member of `AssembledPackageBodySchema`
715
+ (measured, not assumed), so iterating bodies loses no collection; and because
716
+ `packages` composes by `concat`, an artifact whose top level carries a
717
+ definition no package body repeats keeps it — registered once, attributed to the
718
+ artifact's own identity, and logged, because it means the artifact's two halves
719
+ disagree about what it ships.
720
+
721
+ ⛔ The **producer** half is untouched: `composeStacks` and `os build` keep
722
+ emitting the flattened top level alongside `packages[]`. Whether they should is
723
+ #14512's decision, not this door's.
724
+ - 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)
725
+
726
+ The substrate step (§10.3) of the accepted #11633 cross-request caching design
727
+ (maintainer acceptance 2026-08-25, Fork 2 → B). It ships the invalidation
728
+ machinery once, before the grants cache (#11967) that will consume it, so that
729
+ leg does not carry it. **Nothing here caches anything.**
730
+
731
+ - **`ObjectQL.writeEpoch`** — a monotonic counter advanced by the engine
732
+ middleware seam on every `insert` / `update` / `delete`, ahead of the whole
733
+ chain (and so ahead of any `isSystem` bypass a middleware applies). It
734
+ generalises the private counter `@objectstack/plugin-security` has carried
735
+ since #10757: the mechanism was always the engine's, and hoisting it lets a
736
+ second consumer share **one** signal instead of minting a parallel one that
737
+ watches a different set of writes. A seam rather than a list of call sites,
738
+ because a forgotten call site fails as silent over-permission and writing
739
+ through the engine is the only way to write at all — including better-auth's
740
+ own adapter.
741
+ - **`authz.invalidated`** — one new channel on the existing `IPubSub`, bridged
742
+ in the shape `MetadataClusterBridgePlugin` already uses. ⭐ **The TTL a
743
+ consuming cache carries is the correctness contract; this channel is not.** No
744
+ shipped driver delivers better than at-most-once (`cluster.mdx` §4.2), so a
745
+ missed message is *expected*, the bridge stays out of the write path (a
746
+ publish failure is logged and swallowed, never awaited by the writer), and the
747
+ channel only moves the *typical* convergence from one TTL to one network hop.
748
+ That statement lives in the code at the channel, where a consumer reads it.
749
+ - **The boot-time posture statement** — non-optional by the ruling. Whenever a
750
+ grants cache is enabled (`OS_AUTHZ_GRANTS_CACHE_TTL_MS` > 0) and there is no
751
+ cross-node invalidation bus, the deployment is told so at `warn`, every boot,
752
+ naming the window it accepted and the remedy. It is a statement, not a
753
+ refusal: a TTL-bounded per-process cache is a legitimate configuration. It is
754
+ said out loud because a silently-absent invalidation bridge is how a security
755
+ control gets disabled with nobody noticing (#4785). The in-process `memory`
756
+ driver counts as **no** bus — a cluster service exists on the shipped default
757
+ while fanning out to nobody, which is the case a "is a cluster service
758
+ registered?" check answers `yes` to and is wrong about.
759
+
760
+ **Runtime behaviour is unchanged.** With no cache consumer the epoch has zero
761
+ subscribers, so nothing is published and nothing is invalidated; with the
762
+ shipped default TTL of `0` the bridge attaches nothing and logs nothing above
763
+ `debug`. The one composition change worth naming: `Runtime` now registers
764
+ `AuthzClusterBridgePlugin` **unconditionally**, including under `cluster: false`
765
+ — that is not an oversight, it is the loudest case the posture check has, and
766
+ skipping it there would put the statement's absence exactly where the missing
767
+ bus is.
768
+
769
+ `@objectstack/plugin-security` is a `patch`: its permission-set memo now reads
770
+ the engine's epoch when the wired engine exposes one and keeps its private
771
+ counter otherwise (test doubles, embeddings). The covered set of writes is
772
+ identical — the plugin's own middleware was already global — and it is now
773
+ identical *by construction* rather than by two files agreeing on which
774
+ operations count.
775
+ - 86cbe37: feat(core): cross-request authorization grants cache — leg B of #11633 (#11971)
776
+
777
+ `resolveUserAuthzGrants` can now cache its resolved envelope across requests,
778
+ governed by `OS_AUTHZ_GRANTS_CACHE_TTL_MS`. **The default is `0` — the cache is
779
+ OFF and the shipped behaviour is unchanged** (Fork 4 of the accepted #11633
780
+ design): a deployment that enables it accepts the configured staleness window
781
+ explicitly, and the boot-time posture statement says so out loud when no
782
+ cross-node invalidation bus is attached.
783
+
784
+ With the cache on:
785
+
786
+ - **Coarse write-invalidation (Fork 1A).** Any engine write to a watched
787
+ authorization object (`sys_member`, `sys_user_position`,
788
+ `sys_user_permission_set`, `sys_position`, `sys_position_permission_set`,
789
+ `sys_permission_set`, `sys_user`) retires every entry on the writing node —
790
+ a grant/revoke/role change is observed by the very next request there, by
791
+ invalidation and not by TTL. `metadata.changed` and peer-node
792
+ `authz.invalidated` hints retire wholesale via the engine write epoch.
793
+ `sys_session` is deliberately not watched (its once-a-minute
794
+ `last_activity_at` cadence would turn the cache into a non-cache).
795
+ - **Expiry-boundary rule.** Entries expire at `min(ttl, nextBoundary)`, where
796
+ `nextBoundary` is the earliest upcoming ADR-0091 `valid_from`/`valid_until`
797
+ among the rows consulted — a validity window flipping is a permission change
798
+ with no write anywhere, so the timer is the only mechanism for that class.
799
+ - **Ruled bypass list.** The permission explainer
800
+ (`plugin-security` `buildContextForUser`) and `runAs:'user'` automation runs
801
+ (`service-automation`) always resolve fresh, and never populate the cache.
802
+ - The TTL remains the correctness contract; the `authz.invalidated` bus only
803
+ narrows the typical cross-node window (no shipped driver exceeds
804
+ at-most-once delivery).
805
+ - 6a180e4: fix(core,rest,services)!: a permission-store read failure now fails LOUD instead of resolving as an authenticated caller holding zero capabilities (#13279)
806
+
807
+ **BREAKING** runtime behaviour change on the shared authorization resolver,
808
+ shipped as `minor` under the repo's launch-window convention.
809
+
810
+ `resolveAuthzContext`'s per-read helper `tryFind` answered a THROWN read exactly
811
+ the way it answered an EMPTY one: `[]`. So an outage of the permission store
812
+ resolved as a well-formed context for an authenticated principal holding no
813
+ capabilities, and the package-management door answered
814
+ `403 FORBIDDEN` — "Reading packages requires the `studio.access` or
815
+ `setup.access` capability." That answer was measured byte-identical
816
+ (`JSON.stringify` equal, against a control that separates two answers which do
817
+ differ) to what a caller who genuinely holds nothing receives. An administrator
818
+ was told they lack a capability, during an outage of the store that holds the
819
+ capability.
820
+
821
+ Maintainer ruling 2026-08-30, verbatim 「第一批其余同意」: `tryFind` 区分「无行」
822
+ 与「读失败」,读失败 fail-loud —— 权限库不可达时不再解析为「已认证零能力」,而是
823
+ 响亮拒绝(与真实能力拒绝的 403 可区分)。
824
+
825
+ Second maintainer ruling the same day (第 5 场总监席决裁批 #9, verbatim 「同意」),
826
+ after implementing the first one showed that "the read failed" is two facts:
827
+ 采**选项 A** —— 把 `isMissingTableError` 从 `@objectstack/metadata` 迁至
828
+ `@objectstack/types`(core 已依赖),metadata 保留 re-export 兼容;`tryFind` 仅对
829
+ **未被判定为「表未 provision」**的读失败抛 `AuthzStoreUnavailableError`。
830
+
831
+ **What changed.** A permission-store read that is issued and throws now raises
832
+ `AuthzStoreUnavailableError`, which carries the EXISTING ADR-0112 wire code
833
+ `SERVICE_UNAVAILABLE` and status `503`. No code is added to the closed wire
834
+ vocabulary and no response envelope gains or loses a key — only which declared
835
+ code an outage selects. Doors that map thrown errors through
836
+ `resolveThrownHttpError` answer 503 with no per-door change.
837
+
838
+ **What did NOT change**, and is pinned:
839
+
840
+ - A reachable, genuinely EMPTY store (reads return no rows) still resolves to
841
+ zero capabilities.
842
+ - A genuine capability denial still answers `403 FORBIDDEN` with its message.
843
+ - An ABSENT engine (`ql` unwired, so no read is ever issued) still resolves to
844
+ an empty-but-valid envelope.
845
+ - Anonymous requests never reach the store, so an outage cannot make them loud.
846
+ - A REAL engine whose `sys_*` tables were never provisioned resolves to zero
847
+ capabilities, quietly — pinned to be byte-identical to the empty-store
848
+ envelope, in every dialect spelling and in the production wrapper shape where
849
+ the driver's phrase is on `cause` rather than the outer message.
850
+
851
+ **The boundary between the two kinds of read failure.** An earlier revision of
852
+ this changeset claimed "embedders without a data plane are unaffected". That
853
+ claim was too broad; it is retracted here, and the gap it named is now closed
854
+ rather than merely disclosed. A read also throws when the table was never
855
+ PROVISIONED — a real engine, wired and reachable, whose `sys_*` tables were
856
+ never created — and that is a supported deployment shape, not an outage. There
857
+ "zero capabilities" is the TRUE answer rather than a fabrication: nothing is
858
+ provisioned, so nothing was withheld. Only an UNREACHABLE store — the ruling's
859
+ own word 不可达 — leaves the capability set unknown, and only an unknown answer
860
+ may not be reported as a denial.
861
+
862
+ Treating the two alike was measured, not theorised: it turned four CI suites
863
+ red, all from `no such table` on `sys_user` / `sys_member` /
864
+ `sys_user_position` / `sys_user_permission_set`. Ordinary CRUD in
865
+ `@objectstack/client` answered `503`; batch validation errors that owe `400`
866
+ answered `503`, because authorization refused before validation ran; runtime
867
+ notifications answered `401` where authenticated callers must be served `200`;
868
+ and two `.integration.test.ts` noise guards reported that the driver and engine
869
+ diagnostics for `sys_position` stopped being emitted — the eager throw aborted
870
+ the resolution before that later read was ever issued, so a change made to stop
871
+ a failed read being silent had made two other channels silent.
872
+
873
+ `tryFind` therefore raises `AuthzStoreUnavailableError` only for a read failure
874
+ that is NOT positively identified as an unprovisioned table.
875
+
876
+ **`isMissingTableError` moved to `@objectstack/types`.** The classifier that
877
+ draws that boundary already existed and was already right — driver-code based
878
+ rather than prose-sniffing, documented so that "cannot say" never means "be
879
+ loud". It lived in `@objectstack/metadata`, which DEPENDS ON `@objectstack/core`,
880
+ so the resolver could not import it. Rather than keep a second copy of a
881
+ security-relevant predicate, the ruling relocated the one classifier to
882
+ `@objectstack/types` — the package core already depends on, and the repo's own
883
+ stated Home rule for a cross-package error predicate ("every consumer of the
884
+ question already depends on it, so adopting the predicate never adds an edge",
885
+ `packages/types/src/unique-violation.ts`). `@objectstack/metadata/errors` still
886
+ exports `isMissingTableError`, re-exported from the new home, so no consumer of
887
+ that published subpath changes.
888
+
889
+ Its sibling `isSchemaAlreadyExistsError` moved with it — the two are not two
890
+ modules but two signatures over one matcher, and separating them would have
891
+ meant re-rolling the matcher, which is the duplication the module exists to
892
+ prevent. Both are now exported from `@objectstack/types`; the metadata subpath
893
+ deliberately still publishes only `isMissingTableError`, which is the only one
894
+ anything imports through it.
895
+
896
+ ⚠️ **Signed-off risk, recorded because it is load-bearing.** Gating loudness on
897
+ a driver-error predicate was approved with its false-positive direction stated:
898
+ mis-reading a genuine outage as "table not provisioned" silently restores the
899
+ quiet 403 this change removes, with no thrown error and no other failing test.
900
+ That direction is accepted, not overlooked — the predicate keys on driver codes,
901
+ SQLSTATEs and errnos first, excludes the known superstring traps up front, and
902
+ returns `false` for anything it does not positively recognise, so an
903
+ unrecognised outage stays loud by default. The risk is written beside the
904
+ predicate in `resolve-authz-context.ts` and both directions are pinned by name
905
+ in `authz-store-unavailable.test.ts`. ⛔ Do not widen `isMissingTableError` to
906
+ make a first boot quieter: every widening moves outages into the quiet branch.
907
+
908
+ **All-transport, not just REST.** Every transport authorizing through
909
+ `resolveAuthzContext` inherits this. Six of the eight production transports
910
+ wrapped the call in a fail-closed `catch` that would have re-silenced the
911
+ outage — measured, not assumed: with the resolver loud but the nets untouched,
912
+ the package door answered `401`, i.e. the outage merely changed disguises. Those
913
+ `catch` blocks now re-raise via `isAuthzStoreUnavailableError` and keep their
914
+ previous behaviour for every other fault. The transport set is rebuilt from
915
+ source and audited for set equality on every test run, so a transport added
916
+ later cannot inherit the old silence unnoticed.
917
+
918
+ Callers that treat any throw from `resolveAuthzContext` as "anonymous" should
919
+ re-raise `isAuthzStoreUnavailableError(err)` instead: degrading it restores the
920
+ disguise this removes.
921
+
922
+ <!-- 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. -->
923
+ - d8024f0: feat(core): `Plugin.type` is the closed set the spec declares — a `PluginType` derived from `CORE_PLUGIN_TYPES` (#13925)
924
+
925
+ **BREAKING** accept-set narrowing on a published type, shipped as `minor`
926
+ under the repo's launch-window convention for breaking changes. `Plugin.type`
927
+ (and, through it, `PluginMetadata.type`) was declared `string`, so nothing
928
+ type-checked a plugin author against the eight values the platform accepts —
929
+ the TSDoc beside it carried the whole enumeration as prose, and prose drifted.
930
+ Maintainer ruling 2026-09-01: the Zod enum in `@objectstack/spec`
931
+ (`PluginSchema.type`, declared `z.enum(['standard', ...CORE_PLUGIN_TYPES])`)
932
+ is the authority and the contract was always a closed set; the `string` in
933
+ core was the mismatch, and narrowing it is core aligning to the declared
934
+ contract rather than a new restriction. Paid in one stroke — no warning window.
935
+
936
+ What changes:
937
+
938
+ - `@objectstack/core` now exports `PluginType`, derived from the spec's own
939
+ constant: `'standard' | (typeof CORE_PLUGIN_TYPES)[number]` — today
940
+ `standard`, `ui`, `driver`, `server`, `app`, `theme`, `agent`, `objectql`.
941
+ It is not re-spelled in core, so the compiler's accept set and the Zod gate's
942
+ cannot drift apart; a runtime parity test pins the two against each other.
943
+ - `Plugin.type` is typed `PluginType`. A literal outside the set, or a value
944
+ typed `string`, no longer compiles. Runtime behaviour is unchanged: the Zod
945
+ gate refused such a value before and still does (`invalid_value` at `type`).
946
+
947
+ **Migration.** A plugin that declares one of the eight members needs no change.
948
+ A plugin that assigned a computed or `string`-typed value narrows it at the
949
+ producer — declare the literal, or type the variable `PluginType` — rather than
950
+ casting at the assignment; a value that was never one of the eight was never a
951
+ valid plugin type and was already refused at parse time.
952
+
953
+ <!-- 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. -->
954
+ - 4635f3e: fix(spec,core): `HotReloadConfig.stateStrategy` refuses the two values it never implemented; `distributedConfig` retired (#12340, ADR-0049)
955
+
956
+ <!-- adr-0087: registered hot-reload-inert-state-strategies-retired -->
957
+
958
+ **BREAKING** accept-set narrowing + export removal, landing after the v17.0.0
959
+ cut (the lockstep launch-window convention ships it as `minor`; the
960
+ prescription is registered under protocol major 18 —
961
+ `RETIRED_DEFS_BY_MAJOR[18]` + the D3 semantic entry
962
+ `hot-reload-inert-state-strategies-retired` — where `os migrate meta` users
963
+ will look).
964
+
965
+ This is ADR-0049 applied one level INSIDE the library the 2026-08-25 #11825
966
+ ruling deliberately kept. That ruling retired the authorable lifecycle-config
967
+ container and kept `HotReloadConfigSchema` as a host-driven library parameter
968
+ type; this change measures the kept vocabulary's own remainder and finds the
969
+ same defect in it. The keep itself stands — `HotReloadConfigSchema`,
970
+ `PluginStateSnapshotSchema` and the health vocabularies still export, and
971
+ `HotReloadManager` / `PluginHealthMonitor` are untouched.
972
+
973
+ The `'disk'` and `'distributed'` arms of `PluginStateManager.saveState` both
974
+ wrote to the SAME in-memory `Map` as `'memory'` — the in-source comments said
975
+ "memory fallback" — and announced the substitution at DEBUG level only. A host
976
+ that asked for durable or cluster-replicated state got process-local memory
977
+ and no error: state that does not survive the restart it was configured to
978
+ survive. `distributedConfig` had ZERO readers anywhere, so an author could
979
+ name a Redis endpoint, a TTL and a replication factor and nothing ever opened
980
+ a connection.
981
+
982
+ FROM → TO:
983
+
984
+ - `stateStrategy: 'disk'` → `stateStrategy: 'memory'` — byte-identical runtime
985
+ behaviour, because `'disk'` already stored to memory. It is the spelling
986
+ that was false, not the behaviour.
987
+ - `stateStrategy: 'distributed'` → `stateStrategy: 'memory'` — same, or
988
+ `'none'` to disable state preservation outright.
989
+ - `distributedConfig: { … }` → *(removed)* — delete the key. It left with the
990
+ `'distributed'` value its own doc comment called it "required" for.
991
+ - `DistributedStateConfigSchema` / `DistributedStateConfig` /
992
+ `DistributedStateConfigParsed` → *(removed)* — the orphan value schema of
993
+ that one key.
994
+
995
+ One-line fix: replace `'disk'` or `'distributed'` with `'memory'` and delete
996
+ any `distributedConfig` — you were already getting in-memory state. There is
997
+ no in-tree replacement for durable or distributed plugin state; persist it in
998
+ the host, which owns the process lifetime these strategies pretended to
999
+ outlive. Real disk or distributed persistence returns only via the ENFORCE
1000
+ route of ADR-0049 — the implementation first, the declaration with it.
1001
+
1002
+ The retirement kit:
1003
+
1004
+ - **enum-value narrowing** (`['memory','disk','distributed','none']` →
1005
+ `['memory','none']`): invisible to all four ratchets by construction (the
1006
+ def still emits), so the prescription hangs on the enum's own `error` map
1007
+ dispatched by `issue.input` — the `crypto.hash` / `managedBy: 'system'`
1008
+ precedent. A value that was never legal still gets zod's own enum message,
1009
+ so a typo is not told it "was removed".
1010
+ - **whole-def deletion** (route 3 — `HotReloadConfig` is not an authorable
1011
+ surface: no metadata-type binding, stack collection or manifest embed ever
1012
+ carried it, and nothing in the tree parses `HotReloadConfigSchema` outside
1013
+ its own unit test, so there is no authored document to rewrite and nobody
1014
+ who could receive a parse-time tombstone): `kernel/DistributedStateConfig`
1015
+ in `RETIRED_DEFS_BY_MAJOR[18]` plus the D3 semantic entry. Ratchets moved as
1016
+ a def removal must — `api-surface` −3, `authorable-surface` −8,
1017
+ `json-schema.manifest` −1.
1018
+ - **runtime doors** in `@objectstack/core`, because route 3 leaves no
1019
+ parse-time prescription: `HotReloadManager.registerPlugin` now refuses an
1020
+ unhonoured `stateStrategy` and a leftover `distributedConfig` with an
1021
+ ADR-0112 envelope (`code: VALIDATION_ERROR`, `status: 400`) carrying the
1022
+ prescription. Refused BEFORE the `enabled` check, so a disabled config
1023
+ cannot smuggle the false declaration through. TypeScript hosts never reach
1024
+ it — `HotReloadConfigParsed['stateStrategy']` is now `'memory' | 'none'`, a
1025
+ compile error at the call site.
1026
+ - **pin move, declared**: `DistributedStateConfigSchema` was NAMED in the
1027
+ #11825 survivor list, so this reverses one line of that ruling on new
1028
+ evidence — #11825 measured the container's six groups, never this key's own
1029
+ readers. The pin in `kernel/plugin-lifecycle-advanced-retirement.test.ts`
1030
+ moves in the same commit with the reasoning recorded beside it, and asserts
1031
+ the surrounding keep is intact.
1032
+ - zero in-tree consumers passed `'disk'` or `'distributed'` (measured at
1033
+ cdbd9204b6 with a firing positive control; every live caller passes
1034
+ `'memory'` or `'none'`), so no in-repo source changes ride along.
1035
+ - ee3595c: fix(spec,core): `HotReloadManager.startWatching` refuses instead of reporting success; `HotReloadConfig.watchPatterns` retired (#12428, ADR-0049)
1036
+
1037
+ <!-- adr-0087: registered hot-reload-watch-placeholder-retired -->
1038
+
1039
+ **BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep
1040
+ launch-window convention ships it as `minor`; the prescription is registered
1041
+ under protocol major 18 — `RETIRED_KEYS_BY_MAJOR[18]` + the D3 semantic entry
1042
+ `hot-reload-watch-placeholder-retired` — where `os migrate meta` users will
1043
+ look). Graded `minor` rather than `major` for the same reason #12340 was one
1044
+ day earlier, in this same module.
1045
+
1046
+ ADR-0049 applied one symbol over from #12340, in the same file and on the same
1047
+ per-key test. The #11825 keep still stands: `HotReloadConfigSchema` and
1048
+ `PluginStateSnapshotSchema` still export, and `HotReloadManager` /
1049
+ `PluginHealthMonitor` are untouched apart from the two doors below.
1050
+
1051
+ `HotReloadManager.startWatching` contained **no watcher**. Its whole body was a
1052
+ guard plus `logger.info('File watching started', { patterns })`, above an
1053
+ in-source note saying real watching "would require chokidar or similar". Where
1054
+ #12340's inert fallback at least announced itself at DEBUG, this claimed
1055
+ success at **INFO**: an operator who set `enabled: true` with `watchPatterns`
1056
+ and read that line had been told the opposite of the truth. `watchHandles` was
1057
+ only ever read, deleted, iterated and cleared and **never set**, so
1058
+ `stopWatching`'s cleanup branch and the teardown loop over its keys were
1059
+ structurally unreachable rather than merely untaken. `watchPatterns` therefore
1060
+ had no reader that acted on it — its only two uses were log lines.
1061
+
1062
+ FROM → TO:
1063
+
1064
+ - `watchPatterns: ['src/**/*.ts']` → *(removed)* — delete the key. Declare your
1065
+ globs wherever your own watcher reads them.
1066
+ - `manager.startWatching(name)` → `manager.scheduleReload(name, reloadFn)`,
1067
+ called from your own watcher's change handler. That is the debounced
1068
+ integration point this class does implement, and it is unchanged.
1069
+
1070
+ One-line fix: delete `watchPatterns`, and call `scheduleReload` from your own
1071
+ file watcher instead of `startWatching` — nothing was ever watched, so nothing
1072
+ that used to happen stops happening. File watching is the host's job in this
1073
+ host-driven library; `chokidar` is already a dependency of
1074
+ `@objectstack/metadata`, `@objectstack/metadata-fs` and `@objectstack/cli` —
1075
+ never of `@objectstack/core` — so a host has a working model to copy.
1076
+
1077
+ The retirement kit:
1078
+
1079
+ - **key tombstone**, and the build is what chose it: the plain deletion was
1080
+ tried first and `gen:schema` gate (a) refused it, because
1081
+ `HotReloadConfigSchema` is not `.strict()` and a bare deletion would be a
1082
+ silent strip (#3733, ADR-0104) — the very defect being retired, one layer
1083
+ down. #12340 could take route 3 because what left there was a whole *def*; a
1084
+ key leaving a *surviving* def has no such exit. So `watchPatterns` is
1085
+ `retiredKey()`-tombstoned, its surface line carries `[RETIRED]`, and
1086
+ `kernel/HotReloadConfig:watchPatterns` is registered by exact key in
1087
+ `RETIRED_KEYS_BY_MAJOR[18]`. A key tombstone on a surviving def moves
1088
+ `authorable-surface` only — the def still emits, so `api-surface` and
1089
+ `json-schema.manifest` do not.
1090
+ - **no D2 conversion**, deliberately: the chain walks a normalized stack, and
1091
+ `HotReloadConfig` is not an authorable surface — no metadata-type binding,
1092
+ stack collection or manifest embed ever carried it — so a conversion would be
1093
+ a transform with no seam that ever runs. For the same reason the prescription
1094
+ carries no `os migrate meta` sentence, exactly as its `stateStrategy` sibling
1095
+ in this module does not.
1096
+ - **runtime doors** in `@objectstack/core`, because nothing in the tree parses
1097
+ `HotReloadConfigSchema` outside its own unit test, so the tombstone alone
1098
+ reaches nobody: `startWatching` now throws an ADR-0112 envelope
1099
+ (`code: VALIDATION_ERROR`, `status: 400`) carrying the prescription, and
1100
+ `registerPlugin` refuses a leftover `watchPatterns` the same way — before the
1101
+ `enabled` check, so a disabled config cannot smuggle the false declaration
1102
+ through. `startWatching` is kept as a throwing door rather than deleted so
1103
+ that caller meets a prescription instead of a bare `TypeError`.
1104
+ - **dead code removed with a firing positive control**: `watchHandles` and both
1105
+ of its unreachable readers are gone. The zero was pinned first —
1106
+ `reloadTimers.set` resolves a real writer in the same file and the same scan,
1107
+ while `watchHandles.set` resolves nothing anywhere. `stopWatching` keeps the
1108
+ half that always did something (it cancels a pending debounced reload), and
1109
+ `shutdown` is unchanged in effect: the loop it lost iterated `watchHandles`
1110
+ and therefore ran zero times.
1111
+ - **ENFORCE and EXPERIMENTAL were both unavailable**, which is why this is a
1112
+ removal: no runtime composes `HotReloadManager`, so enforcing would build for
1113
+ a caller that does not exist; and a scan of every planning doc returned zero
1114
+ mentions of hot-reload file watching against 145 control hits in the same
1115
+ files, so there is no roadmap for `experimental` to point at.
1116
+ - af56546: feat(platform-objects): packaged disable works without the automation service, and the activation ledger has one implementation (#12359, #12350)
1117
+
1118
+ Two halves of ADR-0126's "ledger convergence", bundled by maintainer ruling
1119
+ (2026-08-26, verbatim and untranslated: 「同意」).
1120
+
1121
+ ## The registration follows the declaration (#12359)
1122
+
1123
+ `sys_metadata_activation` is declared in `@objectstack/platform-objects`, but
1124
+ the only thing that REGISTERED it was the automation service's manifest —
1125
+ because flows were the ledger's first and, until packaged actions landed, only
1126
+ consumer. Packaged actions are a second consumer with a different owner: their
1127
+ consult and write path live on the ObjectQL engine, present in every
1128
+ composition that can execute an action.
1129
+
1130
+ So a deployment with actions and no automation service had no ledger table, and
1131
+ the activation door answered **503 SERVICE_UNAVAILABLE** on every flip —
1132
+ correctly (ADR-0126 §6 wall 3: a flip that cannot be made durable must not be
1133
+ reported as one) and permanently. Measured on a real boot; it is now this
1134
+ change's positive test, measured on the same boot:
1135
+
1136
+ ```
1137
+ POST /api/v1/actions/_activation/showcase_task/showcase_mark_done {"enabled":false}
1138
+ before -> 503 SERVICE_UNAVAILABLE after -> 200, and dispatch refuses 409 ACTION_DISABLED
1139
+ ```
1140
+
1141
+ `PlatformObjectsPlugin` registers it now, so every composition carrying
1142
+ platform-objects has the ledger and each future ADR-0126 §8 consumer (`tool`,
1143
+ `skill`, `position`) inherits it. **MOVE, not add** — the automation service no
1144
+ longer names the object. That was not a style choice: a second code package
1145
+ claiming one object throws `Object "…" is already owned by package "…"`
1146
+ (ADR-0029 D3/D7), measured, so adding a registrant would have been a boot
1147
+ failure rather than a duplicate.
1148
+
1149
+ **Upgrade is a no-op for existing data, and that is measured rather than
1150
+ asserted.** A manifest is also a ROUTING decision — `resolveDatasourceBinding`
1151
+ step 4 routes an object by its owning package's `defaultDatasource` — so the
1152
+ registrar carries the table's datasource with it:
1153
+
1154
+ ```
1155
+ owner com.objectstack.service-automation (defaultDatasource:'cloud') -> 'cloud'
1156
+ owner com.objectstack.platform-objects (none) -> undefined (global default driver)
1157
+ ```
1158
+
1159
+ The ledger table already exists in live databases, so on any deployment
1160
+ carrying a `cloud` datasource that difference would leave the rows in one
1161
+ database and read another — every disabled artifact silently re-arming. The
1162
+ ledger therefore rides its own manifest from the same plugin, carrying the
1163
+ automation manifest's `scope` / `namespace` / `defaultDatasource` triple
1164
+ verbatim. The three siblings (`sys_migration`, `sys_migration_journal`,
1165
+ `sys_secret`) deliberately do not get it and keep riding the project database.
1166
+
1167
+ ## One implementation of the §4 row contract (#12350)
1168
+
1169
+ ADR-0126 §4 declares one activation ledger; it had two independent
1170
+ implementations of that one row contract — `ObjectStoreFlowActivationStore`
1171
+ (service-automation) and `ObjectStoreActionActivationStore` (objectql). They
1172
+ agreed because the second was written from the first, and nothing structurally
1173
+ held them together; §8 pre-charts `tool`, `skill` and `position`, and a third
1174
+ and fourth copy is where the org-row skip and the `0`-is-false read get lost
1175
+ quietly, in the direction (an artifact re-arming) nothing else measures.
1176
+
1177
+ Neither consumer could import the other, so the contract now lives once in
1178
+ `@objectstack/core` — the package both already depend on — as
1179
+ `ObjectStoreMetadataActivationStore(engine, metadataType)`, exported alongside
1180
+ `InMemoryMetadataActivationStore`, `MetadataActivationRow`,
1181
+ `MetadataActivationStore`, `MetadataActivationStoreEngine` and
1182
+ `METADATA_ACTIVATION_TABLE`. Each consumer keeps its own name, its own
1183
+ one-argument constructor and its own docs, and fixes the discriminator.
1184
+
1185
+ **No behaviour change and no API break.** `ObjectStoreFlowActivationStore` /
1186
+ `InMemoryFlowActivationStore` / `FlowActivationStoreEngine` and
1187
+ `ObjectStoreActionActivationStore` / `InMemoryActionActivationStore` /
1188
+ `ActionActivationRow` / `ActionActivationStore` / `ActionActivationStoreEngine`
1189
+ / `ACTION_ACTIVATION_TABLE` are exported from the same modules with the same
1190
+ shapes. Row semantics are byte-equivalent: deployment-level rows scoped only by
1191
+ the `metadata_type` discriminator, a driver `0` read as false, read-then-write
1192
+ rather than a blind upsert, and no `delete` in the engine slice because
1193
+ re-enabling rewrites the row.
1194
+
1195
+ Both existing pin suites stay green **unchanged**, which is what makes them the
1196
+ proof the consolidation lost nothing — verified by ablation: mutating the one
1197
+ shared implementation turns both of them red on their own assertions, so both
1198
+ really reach it.
1199
+ - a8c00e2: feat(core): cache successful `sys_setting` localization reads, invalidated synchronously on write (#11966)
1200
+
1201
+ Leg C (ship-first) of the accepted #11633 cross-request caching design
1202
+ (maintainer acceptance 2026-08-25, forks 1A / 2B / 3A / TTL-0).
1203
+ `resolveLocalizationContext` re-read `sys_setting` on **every** authenticated
1204
+ request to answer the same three keys — `timezone` / `locale` / `currency` —
1205
+ for a workspace whose values change roughly never. That read is now cached.
1206
+
1207
+ **Grade: `minor`, not `patch`.** It adds a deployment variable
1208
+ (`OS_LOCALIZATION_CACHE_TTL_MS`) and changes the query pattern of a shipped code
1209
+ path. Not `major`: the observable contract callers actually depend on — a
1210
+ settings write is visible to the very next read — is preserved, and pinned.
1211
+
1212
+ Caching this read was tried once before and reverted. #10221's first version
1213
+ memoized every outcome for 30s and CI went red on
1214
+ `analytics-timezone.dogfood.test.ts`, which writes a new org timezone and
1215
+ expects the very next analytics query to bucket under it; the cache was narrowed
1216
+ to memoize **failures** only. That verdict was on **TTL-only** caching and it
1217
+ still stands unamended. What changed is that the process now has invalidation
1218
+ seams it did not have then:
1219
+
1220
+ - **Primary — the settings change seam.** `SettingsService.subscribe(ns, handler)`
1221
+ dispatches synchronously and in-process from the write path, after the row is
1222
+ persisted. (⚠️ #11633 calls this a "settings change bus"; no such module
1223
+ exists — `subscribe()` is the seam. No change was needed in
1224
+ `@objectstack/service-settings`: the seam was already public and already does
1225
+ exactly this.)
1226
+ - **Backstop — the engine write epoch** from #11968's substrate. Needed because
1227
+ this resolver's own fallback reads `sys_setting` *directly*, so a seeder or
1228
+ any other direct engine write emits no settings event at all. It is read
1229
+ structurally rather than imported, because `@objectstack/objectql` depends on
1230
+ `@objectstack/core` and the substrate declared `WriteEpochLike` separately for
1231
+ exactly this consumer. A peer node's hint arrives as a local bump, so an
1232
+ attached `authz.invalidated` bridge narrows cross-node convergence for free.
1233
+ - **TTL** — the residual bound, for what neither seam can see. Default 30s,
1234
+ `0` disables the cache on a real path rather than a degenerate one.
1235
+
1236
+ Two rules carry the change and are pinned rather than merely documented:
1237
+
1238
+ 1. **A success is cached only when the engine exposes the write epoch.** A `ql`
1239
+ with no seam is a `ql` whose writes the cache cannot observe, so rather than
1240
+ degrade to the TTL-only shape that was already reverted here once, the cache
1241
+ declines. A partial `{ current }` shape is not a seam either — a counter
1242
+ nothing can bump would read as a live invalidation source and pin the answer
1243
+ for a whole TTL.
1244
+ 2. **Invalidation retires success entries only.** #10221's failure memo exists
1245
+ for an environment where `sys_setting` is missing; retiring it on a write
1246
+ would restart precisely the per-request driver log spam that memo removed,
1247
+ and no write can create a missing table. It stays TTL-bound and behaves
1248
+ exactly as #10221/#11877 shipped it.
1249
+
1250
+ `analytics-timezone.dogfood.test.ts` is unchanged and unweakened — it is this
1251
+ leg's acceptance test, and an ablation that reduces the cache to its TTL turns
1252
+ it red on the same assertion the original revert was recorded against.
1253
+ - 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).
1254
+
1255
+ 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.
1256
+
1257
+ **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:
1258
+
1259
+ - an **expired** platform-admin grant no longer authorizes `/sso/register` or `/admin/impersonate-user`, and no longer appears in the session payload;
1260
+ - a **deactivated** `admin_full_access` permission set no longer confers platform standing anywhere — the deactivation dialog's promise now holds on these gates too;
1261
+ - 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.
1262
+
1263
+ **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.
1264
+
1265
+ **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.
1266
+
1267
+ 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.
1268
+ - 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).
1269
+
1270
+ **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.
1271
+
1272
+ **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.
1273
+
1274
+ **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.
1275
+
1276
+ 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`).
1277
+
1278
+ `@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.
1279
+ - b72db01: fix(spec,core): `PluginHealthMonitor` stops claiming a restart it never performed; the three `PluginHealthCheck` restart keys retired (#12032, ADR-0049)
1280
+
1281
+ <!-- adr-0087: registered plugin-auto-restart-never-reinitialised -->
1282
+
1283
+ **BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep
1284
+ launch-window convention ships it as `minor`; the prescriptions are registered
1285
+ under protocol major 18 — three `RETIRED_KEYS_BY_MAJOR[18]` entries plus the D3
1286
+ semantic entry `plugin-auto-restart-never-reinitialised` — where
1287
+ `os migrate meta` users will look). Graded `minor` rather than `major` for the
1288
+ same reason #12340 and #12428 were, the day before, in this same module.
1289
+
1290
+ ## What was measured
1291
+
1292
+ `PluginHealthMonitor.attemptRestart` called `plugin.destroy()` and stopped
1293
+ there. The comment above the call read *"Call destroy and init to restart"*,
1294
+ and `init` appeared in `health-monitor.ts` **only inside that comment**. So a
1295
+ plugin whose health checks crossed `failureThreshold` with `autoRestart: true`
1296
+ got: `destroy()`, a log line reading `Plugin restarted`, status `recovering`,
1297
+ and periodic health checks that carried on running against the destroyed
1298
+ instance. The default check when no `checkMethod` resolves is
1299
+ `{ name: 'plugin-loaded', status: 'passed' }`, which a destroyed object passes
1300
+ indefinitely — so the **terminal** report on a torn-down, never-re-initialised
1301
+ plugin was `healthy`.
1302
+
1303
+ Reproduced at `ee3595cefd` before anything was changed, with
1304
+ `successThreshold: 3`:
1305
+
1306
+ ```
1307
+ round 1 (failing): status=failed destroyed=0 alive=true
1308
+ after backoff: status=recovering destroyed=1 alive=false
1309
+ recovery round 1: status=recovering destroyed=1 alive=false
1310
+ recovery round 2: status=recovering destroyed=1 alive=false
1311
+ recovery round 3: status=healthy destroyed=1 alive=false
1312
+ ```
1313
+
1314
+ #11955 made that report *more* convincing rather than less: reaching `healthy`
1315
+ now costs `successThreshold` consecutive passing rounds, so a destroyed plugin
1316
+ has to earn a declared number of passes before it is misreported.
1317
+ `restartAttempts` was incremented as though a restart had occurred, and
1318
+ `maxRestartAttempts` / `restartBackoff` scheduled further "restarts" of a plugin
1319
+ that was never brought back up.
1320
+
1321
+ ## Why REMOVE and not the other two ADR-0049 states
1322
+
1323
+ **ENFORCE** would have to build the restart, and the class cannot host one.
1324
+ `Plugin.init(ctx)` needs a `PluginContext`; the only two `plugin.init(...)` call
1325
+ sites in the tree are the kernel's own boot loops (`kernel-base.ts:202`,
1326
+ `kernel.ts:607`), both over the full plugin list, with a context that is
1327
+ `private` on `ObjectKernel` and `protected` on `KernelBase`. No host can obtain
1328
+ one, so a host-provided re-init hook would have had nothing to call. (Positive
1329
+ control for that scan: the same pass resolves five real non-test
1330
+ `plugin.destroy()` call sites, so it does see lifecycle drivers.) Building a
1331
+ per-plugin re-init API for a caller that does not exist — no runtime constructs
1332
+ `PluginHealthMonitor` (#11825) — is the speculation ADR-0049's staged decision
1333
+ names as the wrong default at this milestone, where the shippable liability is
1334
+ the false promise and not the missing feature.
1335
+
1336
+ **EXPERIMENTAL** requires a roadmap. A scan of the whole `docs/` planning + ADR
1337
+ corpus returned **zero** mentions of plugin auto-restart, against 118 control
1338
+ hits for "health" and 13 for "hot reload" in the same corpus.
1339
+
1340
+ `maxRestartAttempts` and `restartBackoff` leave with `autoRestart` rather than
1341
+ as a tidy-up: with no restart, *"Maximum restart attempts before giving up"* and
1342
+ *"Backoff strategy for restart delays"* have nothing left to be the vocabulary
1343
+ **of** — the test that took `distributedConfig` out with the `stateStrategy`
1344
+ value it was documented as requiring (#12340).
1345
+
1346
+ ## What changes for a host
1347
+
1348
+ All three keys are **tombstoned**, not deleted: `PluginHealthCheckSchema` is not
1349
+ `.strict()`, so a bare deletion would be a silent strip (#3733, ADR-0104) — a
1350
+ milder form of the defect being retired. A TypeScript host gets a `tsc` error
1351
+ (the keys are typed `never`); a parse raises the prescription; and
1352
+ `PluginHealthMonitor.registerPlugin` refuses a hand-built config carrying any of
1353
+ them with an ADR-0112 envelope (`code: VALIDATION_ERROR`, `status: 400`), thrown
1354
+ before any state is stored so a refused config leaves no half-registered plugin
1355
+ behind.
1356
+
1357
+ `PluginHealthMonitor` no longer calls `plugin.destroy()` at all. A plugin that
1358
+ crosses `failureThreshold` is reported `degraded` / `unhealthy` / `failed` and
1359
+ left running; acting on that is the host's job in this host-driven library
1360
+ (#11825 route 2). Poll `getHealthStatus(pluginName)` / `getHealthReport(pluginName)`
1361
+ and restart at the level that owns the plugin's lifetime.
1362
+
1363
+ Everything else in the monitor is unchanged: registration, periodic checks, the
1364
+ `timeout` race and its refd-timer guard (#4875), both failure routes sharing the
1365
+ counters (#11852), and `successThreshold` binding from every status that records
1366
+ a failure (#11955). `recovering` is now written only by the success branch —
1367
+ the one writer that ever meant it.
1368
+ - 49f0dcf: feat(core): retire the inert `PluginMetadata` surfaces — `configSchema` with `PluginConfigValidator`, and `hotReloadable` (#11982, #12587, ADR-0049)
1369
+
1370
+ <!-- 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. -->
1371
+
1372
+ **BREAKING**: removes a published-but-inert capability from the `.` entry of
1373
+ `@objectstack/core`. Shipped as `minor` under the lockstep launch-window
1374
+ convention (a `major` bump is refused repo-wide by `check:changeset-no-major`).
1375
+
1376
+ Removed, each measured at zero live consumers with positive controls (the
1377
+ sibling `startupTimeout` is read live by the kernel's startup timeout guard);
1378
+ maintainer ruled retire under ADR-0049 enforce-or-remove, 2026-08-27,
1379
+ decision-inbox batch 5; recorded in ADR-0025 §3.7:
1380
+
1381
+ - `PluginMetadata.configSchema` — declared "Configuration schema for
1382
+ validation", but the mechanism could never run: the loader's only call
1383
+ passed no config, and no caller could — plugin factories close over their
1384
+ config, so the kernel never receives it. Every one of ~40 production
1385
+ `kernel.use()` compositions already passes config as constructor arguments
1386
+ and works.
1387
+ - `PluginConfigValidator` / `createPluginConfigValidator` — the validator
1388
+ behind that field: real code with zero reachable invocations, deleted along
1389
+ with its unit test and its export from the security barrel.
1390
+ - `PluginMetadata.hotReloadable` — declared "Whether plugin supports hot
1391
+ reload" with zero reads and zero declarations: `HotReloadManager.reloadPlugin`
1392
+ gates only on its own registered reload configs, so `hotReloadable: false`
1393
+ was hot-reloaded identically to `true`.
1394
+ - The `packages/core/ADVANCED_FEATURES.md` example whose inline comment
1395
+ promised "Config is validated before init is called" — false on the
1396
+ retired ref, and the retired surface's only in-repo declaration site.
1397
+
1398
+ One-line fixes, per symbol. If you declared `configSchema` on a plugin:
1399
+ delete the field and parse your config at the plugin's own seam —
1400
+ `MyConfigSchema.parse(options)` in the plugin factory or constructor, the
1401
+ pattern `packages/rest` uses. If you imported `PluginConfigValidator` or
1402
+ `createPluginConfigValidator`: delete the import and hold your own
1403
+ `schema.parse` call; the compiler (TS2305) locates every such site. If you
1404
+ declared `hotReloadable`: delete the field — it never gated anything, and
1405
+ hot-reload participation remains governed solely by
1406
+ `HotReloadManager.registerReloadConfig`.
1407
+
1408
+ Re-declaring a kernel-owned config-validation surface is a fresh decision for
1409
+ the day ADR-0025's plugin distribution layer lands, with #11982's zero-caller
1410
+ measurement as its starting evidence.
1411
+ - add4360: fix(core): tell "service never registered" apart from "service failed to construct" on the async path (#13905)
1412
+
1413
+ `PluginLoader.getService` — reached through `Kernel.getServiceAsync` — answered two
1414
+ different facts with the same bare `Error`. "Nothing ever registered this service" and
1415
+ "the service is registered and could not be built" arrived at a caller as one
1416
+ indistinguishable rejection, separated only by message text.
1417
+
1418
+ That was load-bearing one layer out. `RestServer.computeExecCtx`'s kernel branch absorbs a
1419
+ failed `getServiceAsync('objectql')` and degrades to "no engine is wired", and it must keep
1420
+ doing so — a kernel with no data plane is a supported configuration, declared by
1421
+ `rest-api-plugin.ts` as `optionalDependencies: ['com.objectstack.engine.objectql']`. So a
1422
+ multi-tenant host whose engine *failed to construct* reached the same resolver as "no
1423
+ engine is wired", degrading silently where it should have refused loudly. The branch could
1424
+ not be repaired from outside, because the fact it needed had been collapsed before it
1425
+ arrived.
1426
+
1427
+ The asynchronous path now carries the distinction the **synchronous** context accessor in
1428
+ `kernel.ts` has always drawn from the registry. `@objectstack/core` publishes exactly two
1429
+ new symbols for it:
1430
+
1431
+ - `isServiceNotRegisteredError(err)` — true only when nothing was ever registered under
1432
+ that name;
1433
+ - `SERVICE_NOT_REGISTERED_CODE` — the code the rejection carries.
1434
+
1435
+ The test is closed and its default is loud: exactly one rejection in `getService` means
1436
+ "never registered" and only that one is branded, so every other way it can fail — a factory
1437
+ that threw, a missing scope id, an unset loader context, a circular service dependency —
1438
+ stays unbranded, and a consumer that absorbs only the branded rejection is loud about
1439
+ everything else, including rejections added later.
1440
+
1441
+ ⛔ Not message matching. Adding a second text classifier on a resolution path is the failure
1442
+ mode this change removes: reading "not found" off the async path once reported every
1443
+ missing service as `is async - use await` — the wrong fix, pointing at the wrong layer.
1444
+
1445
+ Nothing existing moves. The rejection keeps a byte-identical message and `name: 'Error'`;
1446
+ the only observable change is the two added own-properties.
1447
+
1448
+ ### Patch Changes
1449
+
1450
+ - efb3513: fix(platform-objects,core): `sys_metadata_activation` ships tenant-less — drop the reserved organization column (#15024)
1451
+
1452
+ The ADR-0126 activation ledger records that **this environment** switched a
1453
+ packaged artifact off. That is deployment-level state, owned by no
1454
+ organization — so the table ships with no tenant column at all.
1455
+
1456
+ It briefly declared one: an `organization_id` marked "RESERVED", nullable, and
1457
+ written by nobody, held for a per-organization dimension ADR-0126 §5
1458
+ pre-charted. A reserved nullable tenant column is exactly the shape the
1459
+ total-organization-ownership record proposed in PR #14976 rules out, and this
1460
+ one had no reader either. **This is a plain removal, not a migration:** the
1461
+ table landed after the 17.2.0 tag, so no released version ever carried the
1462
+ column and no deployment has data in it. Should a per-organization dimension
1463
+ ever be wanted, it returns as a separate org-owned object — never as a column
1464
+ on this ledger.
1465
+
1466
+ What changed:
1467
+
1468
+ - **`sys_metadata_activation` declares `systemFields: { tenant: false }`** and
1469
+ no longer declares the column. Both halves are needed: the tenant anchor is
1470
+ INJECTED at registration, so deleting the field alone would have left the
1471
+ column exactly where it was. ⚠️ Deliberately NOT `tenancy: { enabled: false }`
1472
+ — that key is the ADR-0066 D2 platform-global *posture*, which the sibling
1473
+ `sys_sso_provider` uses for the opposite shape (a table that KEEPS its tenant
1474
+ column and needs the wall over it stood down). Here there is no column to
1475
+ wall. Both spellings reach `plugin-security`'s `tenancyDisabled`, which is
1476
+ required rather than incidental: a Layer 0 wall composing an equality on a
1477
+ column the table does not have denies every row.
1478
+ - **The declared unique index states `unique: 'global'`** over
1479
+ `(metadata_type, name)` instead of `'organization'`. ⚠️ The materialized DDL
1480
+ is unchanged: `normalizeDeclaredIndex` prepends the NULL-safe tenant key part
1481
+ only when the table HAS a tenant column, so `'organization'` already degraded
1482
+ to exactly these two columns. What changes is that the declaration now states
1483
+ the boundary it actually gets, rather than claiming a per-organization one
1484
+ that does not exist. Still explicit rather than bare `unique: true`, which
1485
+ lint `unique/unscoped-declared-index` warns on and protocol 18 rejects.
1486
+ - **`ObjectStoreMetadataActivationStore` drops its NULL filter and its
1487
+ org-row skip.** `list()` is now every activation row of its type, scoped by
1488
+ the `metadata_type` discriminator alone, and `setActive` takes the single row
1489
+ its keyed read returns instead of picking the NULL-organization one out of
1490
+ the result. Both guarded a column that no longer exists; the declared unique
1491
+ index over the two columns the lookup keys on is what makes that read
1492
+ single-valued. `ObjectStoreFlowActivationStore` and
1493
+ `ObjectStoreActionActivationStore` inherit the change.
1494
+
1495
+ Unchanged, and pinned: the operator gate on activation writes under walled
1496
+ postures (ADR-0126 D3), the `execute()`-time flow consult and the dispatch-time
1497
+ action consult, "absence of a row means ACTIVE", re-enabling UPDATES the row
1498
+ rather than deleting it, and a driver `0` reading as false. The pins that
1499
+ asserted the reserved column and the org-row skip are rewritten to pin the
1500
+ column's ABSENCE rather than deleted — including at the injection authority
1501
+ (`resolveInjectedSystemColumns`, which decides whether the column exists) and
1502
+ in a real booted stack, where the row's key set is a reading of the physical
1503
+ table.
1504
+ - e27583e: docs(core): the `AuthzStoreUnavailableError` brand doc states the measured `structuredClone` behaviour instead of claiming survival (#14006)
1505
+
1506
+ Documentation only — no runtime change, no type change, no accept/reject
1507
+ behaviour moves. It ships as a patch because the docblock is a **published
1508
+ byte**: `tsup`'s declaration rollup carries it into `dist/index.d.ts` and
1509
+ `dist/index.d.cts`, so it is what a consumer reads on hover.
1510
+
1511
+ The brand's docblock justified the string-keyed own property with two reasons
1512
+ joined by an `and`, of which only the second was true:
1513
+
1514
+ > A string-keyed own property (not a `Symbol.for` registry key) so it survives
1515
+ > `structuredClone`, and so a duplicated copy of this module still brands
1516
+ > identically.
1517
+
1518
+ Measured on Node 22.22.2: the structured-clone algorithm gives `Error` a
1519
+ dedicated serialization carrying `message`, `stack` and `cause` only, and drops
1520
+ every other own property — the brand, the ADR-0112 `code`, `status` and
1521
+ `object` alike (a subclass's own `name` returns as `'Error'`). The
1522
+ plain-object control is the half that proves it: `{ __brand: true, code: 'C' }`
1523
+ keeps **both** keys through the same call, so the loss is specific to `Error`,
1524
+ not general to `structuredClone`.
1525
+
1526
+ The property and the reason that actually earns it are kept — a duplicated copy
1527
+ of the module still brands identically, which is exactly what `instanceof`
1528
+ cannot do across two installed copies of `@objectstack/core`. The false half is
1529
+ replaced by the measured behaviour, carrying the reproducible script and the
1530
+ Node version rather than a second unsourced assertion, and phrased to match
1531
+ what `service-not-registered.ts` already records for its own brand (one
1532
+ phrasing across the two modules, not two).
1533
+
1534
+ ⛔ The clone gap is deliberately NOT "fixed" with a `toJSON` or a custom
1535
+ serialization: no call site crosses a clone boundary today
1536
+ (`rethrowAuthzStoreUnavailable` on the rest rethrow paths,
1537
+ `isAuthzStoreUnavailableError` inside service `catch` blocks — all in-process),
1538
+ and adding one would widen the module's surface with nothing pulling on it. The
1539
+ docblock instead names the trap the false claim invited: branching on the brand
1540
+ across a worker or `postMessage` boundary would answer `false` and fail OPEN on
1541
+ a security path.
1542
+ - 983edf1: fix(core): `autoRestart` now fires for a health check that throws or times out, not only for one that returns a failure (#11852)
1543
+
1544
+ `PluginHealthMonitor.performHealthCheck` reaches its failure handling by two
1545
+ disjoint routes, and only one of them could ever restart the plugin.
1546
+
1547
+ A check that **returned** a failure (`false` or `{ status: 'unhealthy' }`)
1548
+ incremented `failureCounters`, cleared `successCounters`, and — once
1549
+ `failureThreshold` consecutive failures accumulated — consulted `autoRestart`
1550
+ and restarted the plugin. A check that **threw** took a separate `catch` block
1551
+ that incremented `failureCounters` and stopped there: it never cleared
1552
+ `successCounters` and never read `autoRestart`. Because `raceCheckTimeout`
1553
+ rejects rather than resolving, every `timeout` overrun lands in that `catch`,
1554
+ so a plugin that hung was marked `failed` and never restarted no matter how
1555
+ many rounds passed or what `autoRestart` said. The severer of the two failure
1556
+ modes was the one that could not trigger recovery.
1557
+
1558
+ Both routes now funnel into one `recordFailedRound` step that owns the
1559
+ counters, the `failureThreshold` comparison and the `autoRestart` decision, so
1560
+ a thrown or timed-out check is restart-eligible on exactly the same terms as a
1561
+ returned failure.
1562
+
1563
+ The per-route *status* label is deliberately unchanged: a throw is still the
1564
+ separate `failed` status applied immediately with no threshold, as
1565
+ `content/docs/protocol/kernel/lifecycle.mdx` documents. Only the counters and
1566
+ the restart decision are shared — those are what `failureThreshold` and
1567
+ `autoRestart` declare, and neither names a route.
1568
+ - 8120808: feat(tooling): `@objectstack/core` declares a `typecheck` script, and its test and examples layers enter the ratchet (#14613)
1569
+
1570
+ `packages/core/package.json` declared exactly `build`, `test` and `test:watch`.
1571
+ Around twenty sibling packages declare `typecheck`, and `turbo run typecheck`
1572
+ selects only packages that declare the task — so the lint workflow's typecheck
1573
+ job had no way to reach this package, and `pnpm --filter @objectstack/core
1574
+ typecheck` failed with `ERR_PNPM_RECURSIVE_RUN_NO_SCRIPT` for anyone who tried
1575
+ it. The package's types ship anyway: `build` emits a 233 KB `dist/index.d.ts`,
1576
+ and rest, runtime, mcp, services and plugins all import it.
1577
+
1578
+ The state was tracked but not runnable. `check:type-check-coverage` carried
1579
+ `@objectstack/core` as a DEBT entry of 98 and had already re-measured it once
1580
+ (91 to 98), so nothing was invisible — but a ledger only the gate can read is
1581
+ not something a contributor working in the package can run, which is how a
1582
+ dispatched task came to assume the script existed.
1583
+
1584
+ **Measured at `84b8190ae`, dependency closure built first.** The undivided
1585
+ program (`tsc --noEmit -p tsconfig.json`, exactly as the DEBT entry measured it)
1586
+ reports 98 errors across 12 files, and every one of the 12 is a `.test.ts`. The
1587
+ same program restricted to the 63 non-test source files reports **zero**. So the
1588
+ build layer graduated as it stood, and the 98 did not have to be repaired before
1589
+ the script could exist.
1590
+
1591
+ **94 of the 98 were the check, not the code.** The repair is the split this
1592
+ repo already runs for `spec`, `rest`, `objectql` and `client`: `tsconfig.json`
1593
+ stays the build config and excludes the test layer; a new `tsconfig.test.json`
1594
+ compiles that layer under the module semantics vitest actually executes it with
1595
+ (`module: esnext`, `moduleResolution: bundler`), which retires 22 x TS2835, the
1596
+ TS2347 beside them and the share of 71 x TS7006 they cascade into — an import
1597
+ that does not resolve makes every symbol it names `any`. **No test file was
1598
+ edited.** Strictness is inherited and untouched. The residue is 4 errors over 4
1599
+ files, held per file and per signature in `test-typecheck-debt.json`, EXACT and
1600
+ shrink-only.
1601
+
1602
+ **The `examples/` half was found by the new script, not by the card.** Declaring
1603
+ `typecheck` flips the package from COVERED-BY-LEDGER to COVERED-BY-SCRIPT, and
1604
+ `check:type-check-coverage`'s SOURCES_COVERED invariant immediately reported
1605
+ `packages/core/examples` — 2 non-test source files in no tsc program at all.
1606
+ Neither had ever compiled: `kernel-features-example.ts` imported `../index.js`
1607
+ (above the package root, never existed) and `phase2-integration.ts` imported
1608
+ `@objectstack/core`, i.e. this package self-referencing by a name it declares in
1609
+ no dependency block. Collapsing that cascade exposed rather than removed errors,
1610
+ 12 to 29, all of them real and none of them new: 20 reads of `ObjectKernel`'s
1611
+ **private** `logger`; four members of the security scan result that do not exist
1612
+ (`passed`, `score`, `summary.critical`, `summary.high`, where the type carries
1613
+ `status` and per-severity counts); and two config literals passing the unparsed
1614
+ shapes where `PluginHealthMonitor.registerPlugin` and
1615
+ `HotReloadManager.registerPlugin` are declared over the `Parsed` ones. That last
1616
+ pair is retirement drift — this file was edited by two retirements (restart keys,
1617
+ `watchPatterns`) while no tsc program could check the result. Every correction is
1618
+ pinned to this package's own signatures; `packages/spec` was not touched.
1619
+
1620
+ `packages/core` therefore leaves the DEBT ledger: the coverage gate now reads
1621
+ 70/79 packages type-checked with 9 ledgered, where it read 68/78 with 10.
1622
+ - f658793: Restore the #10096 standing invariant (「浏览器可达的 spec 导出面必须
1623
+ schema-free」) for `@objectstack/core`'s plural→singular store-key fold.
1624
+
1625
+ `@objectstack/spec`: the `defineStack()` manifest-collection vocabulary
1626
+ (`PLURAL_TO_SINGULAR`, `SINGULAR_TO_PLURAL`, `pluralToSingular`,
1627
+ `singularToPlural`) moved to a schema-free module and is now ALSO exported
1628
+ from the sanctioned schema-free entry `@objectstack/spec/meta-spelling`
1629
+ (widened per the #10096 ruling's reference pattern). `@objectstack/spec/shared`
1630
+ keeps the same four symbols as re-exports — no consumer-visible removal. The
1631
+ manifest map and `META_URL_TO_SINGULAR` remain deliberately distinct contracts
1632
+ (#8424).
1633
+
1634
+ `@objectstack/core`: `canonicalMetadataServiceType`'s one value import moves
1635
+ from `@objectstack/spec/shared` to `@objectstack/spec/meta-spelling`, so
1636
+ browser consumers of `@objectstack/core` (every `@objectstack/client` bundle)
1637
+ no longer link the zod schema closure through the store-key fold.
1638
+ - 0a8ebf3: Scope the legacy platform-admin deprecation pointer to walled tenancy postures
1639
+
1640
+ The request-side notice that tells an operator their unscoped `admin_full_access`
1641
+ grant row is the OLD anchor — "it is removed in a later release", "re-anchor this
1642
+ deployment by declaring its administrators in configuration" — was emitted without
1643
+ regard to the deployment's tenancy posture, so it fired on `single` rigs too.
1644
+
1645
+ `single` is the DEFAULT posture, and on a `single` rig that row is not legacy at
1646
+ all: the boot-time `bootstrapPlatformAdmin` mints it to promote the first human
1647
+ user, and that promotion is ruled correct and unchanged. Such a deployment was
1648
+ therefore being told, once per process, to migrate off an anchor that is not
1649
+ scheduled to go away, toward a variable its own promotion is pinned never to read.
1650
+
1651
+ The pointer is now gated on `postureEnforcesWall(resolveTenancyPosture())`, the
1652
+ same predicate and the same source the boot-side detector already reads, so the
1653
+ migration window's loudness is scoped to the walled postures actually in it.
1654
+ Walled rigs are unaffected and still receive the notice.
1655
+
1656
+ ⛔ Standing is not touched: this is a log-line trigger, not access control. Every
1657
+ deployment resolves exactly the `PLATFORM_ADMIN` it resolved before.
1658
+ - 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.
1659
+ - 2d5cee3: docs(core,service-cluster): retire the two docblocks left stale by `IPubSub`'s corrected delivery guarantee (#12836)
1660
+
1661
+ #12651 corrected `IPubSub`'s contract docblock: delivery is whatever the
1662
+ configured driver declares, no shipped driver exceeds at-most-once, a missed
1663
+ message is EXPECTED, and handlers must be idempotent **and** tolerate loss.
1664
+ Two docblocks elsewhere still described the world before that correction.
1665
+
1666
+ **`@objectstack/core` — `security/authz-invalidation-channel.ts`.** It carried a
1667
+ paragraph asserting, in the present tense, that the interface docblock "still
1668
+ says" *At-least-once delivery*, and that repairing it was a `packages/spec`
1669
+ change filed separately. That filing was #12651 and it has landed, so the
1670
+ paragraph is now false rather than merely stale — it sends the next reader
1671
+ looking for a live disagreement between the interface and the drivers that no
1672
+ longer exists. Replaced with a plain pointer to the interface docblock.
1673
+ Everything else in that docblock is unchanged: the at-most-once reasoning, the
1674
+ TTL-is-the-bound rule, and the best-effort-at-the-publish-site note all still
1675
+ hold.
1676
+
1677
+ **`@objectstack/service-cluster` — `memory/pubsub.ts`.** The line "At-least-once
1678
+ semantics held vacuously (a single in-process delivery)" was wrong on its own
1679
+ terms even before #12651: the same docblock states that handler errors are
1680
+ swallowed and logged via `onError`, so a handler that throws loses the message
1681
+ with no retry and no persistence. That is not at-least-once in any sense, and
1682
+ "vacuously" does not save it. Replaced with the honest statement — one
1683
+ synchronous in-process delivery attempt per subscriber, no persistence, no
1684
+ retry, no replay.
1685
+
1686
+ Prose only. No behaviour change, and no test changed.
1687
+ - a17da05: fix(core): only a backend fault populates `resolveLocalizationContext`'s failure memo (#11877)
1688
+
1689
+ `resolveLocalizationContext` memoizes an outcome for 30s whenever the read
1690
+ "failed" (#10221 — so a repeatedly-failing `sys_setting` query does not re-run,
1691
+ and the driver does not re-log it, on every request). The write condition was
1692
+ wider than the cache's own docblock: six legs set the flag and only **one** of
1693
+ them is the backend fault the docblock describes (the direct `ql.find` throw).
1694
+ The other five are the **settings service refusing** — a thrown `getMany`, each
1695
+ of the three older per-key `get`s, and the whole-block "service unavailable"
1696
+ handler.
1697
+
1698
+ Those five legs are reachable inside the settings engine's **bind window**
1699
+ (`SettingsService.getMany` refuses all-or-nothing for a `localization`
1700
+ namespace whose manifest is not yet registered), so:
1701
+
1702
+ - A caller that deliberately re-reads **after** the bind — the #11580 stdio
1703
+ repair re-resolves at `kernel:bootstrapped` for exactly this reason — was
1704
+ answered from the memo taken **inside** the window for up to 30s. The
1705
+ correction silently did not happen, with nothing in the output saying so.
1706
+ - A settings refusal standing alongside a perfectly **successful** direct read
1707
+ memoized that successful value — the staleness the docblock forbids outright
1708
+ and that `analytics-timezone.dogfood.test.ts` (#1982/#2018) exists to catch.
1709
+
1710
+ The memo is now written only for the direct-read fault. **#10221's protection
1711
+ is unchanged for the legs it was built for**: its environment (table not
1712
+ migrated yet) still memoizes, because the direct read throws there whether or
1713
+ not a settings refusal stands in front of it — pinned in both directions. And
1714
+ nothing is lost on the narrowed legs: those refusals throw out of an in-memory
1715
+ registry check *before* any query and *before* any log line, so memoizing them
1716
+ suppressed neither.
1717
+
1718
+ No signature, export or accepted-input change — the flag is internal to the
1719
+ module.
1720
+ - 7c41693: fix(core,plugin-auth,plugin-security): every `OS_PLATFORM_OWNER_EMAIL` reader asks the ONE list-aware parser (#13147)
1721
+
1722
+ `OS_PLATFORM_OWNER_EMAIL` accepts one address **or a comma-separated list** of
1723
+ them (#11663 Choice 2B). The list parse landed in a single home
1724
+ (`@objectstack/core`'s `platform-admin.ts`) and the authorization derivation
1725
+ consumed it — but every other reader kept calling `resolvePlatformOwnerEmail()`,
1726
+ which returns the operator's value trimmed and otherwise verbatim, and kept
1727
+ treating that whole string as ONE address.
1728
+
1729
+ An operator who configured a list therefore entered a self-contradictory state:
1730
+ authorization recognised them as a platform administrator, while four separate
1731
+ capabilities silently did nothing. Every direction failed **closed** — no
1732
+ privilege escalation existed at any point — but a declared capability vanished
1733
+ with no error anywhere:
1734
+
1735
+ - `bootstrap-platform-admin` promoted **nobody**, logging "will be promoted when
1736
+ that account registers" on every boot forever;
1737
+ - the walled operator stamp (`plugin-auth`) stamped **no** list member verified,
1738
+ so the account it should have provisioned was then refused elevation as
1739
+ `walled_owner_not_verified`;
1740
+ - `isVerifiedPlatformOwnerSession` / `platform-owner-wall-bypass` let **nobody**
1741
+ across the Layer 0 organization wall — the largest of the affected surfaces;
1742
+ - the walled boot diagnostic printed the raw list in the slot where an operator
1743
+ reads one address, and its dev-seed silence clause never matched.
1744
+
1745
+ All six readers now ask the same parser. `@objectstack/core` gains
1746
+ `isConfiguredPlatformAdminEmail(email, config)` — the membership half of
1747
+ `matchesConfiguredPlatformAdmin`, spelled once and shared, for the readers that
1748
+ hold a bare address rather than a `sys_user` row (the elevation gate keeps its
1749
+ two halves apart so `walled_owner_not_registered` and `walled_owner_not_verified`
1750
+ stay distinct answers; the stamp is handed an email before any row exists; the
1751
+ wall takes a fast negative before spending a row read). `PlatformAdminEmailConfig`
1752
+ gains `declaredSpellings`, the entries as the operator typed them, so the by-email
1753
+ `sys_user` lookup and the boot diagnostic get the as-typed form **from the one
1754
+ parse** instead of splitting the raw value a second time.
1755
+
1756
+ Behaviour for a single declared address is unchanged, including the
1757
+ case-insensitive match and the verbatim-spelling store lookup. A **refused**
1758
+ list (Choice 2B fails the whole variable closed on one unparseable entry) now
1759
+ reaches these readers as "zero administrators", which is the same answer they
1760
+ already gave for an unset variable — never a silently narrower set.
1761
+
1762
+ Two readers deliberately keep reading the raw value: the walled-boot refusal and
1763
+ the verification-path probe guard in `auth-plugin.ts` both use it as a pure
1764
+ truthiness test ("did the operator declare anything at all?"), which is
1765
+ grammar-independent. A census pin now enumerates the raw readers across both
1766
+ plugin packages and fails on a seventh.
1767
+ - 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.
1768
+ - 556ebc1: docs(core): correct `Plugin.type`'s TSDoc enumeration — it omitted `objectql` (#13762)
1769
+
1770
+ `Plugin.type` is typed `string` in `@objectstack/core`, so its TSDoc is the only
1771
+ enumeration a plugin author reading the interface ever sees; nothing type-checks
1772
+ them against it. That comment listed seven values while the declared set is
1773
+ eight: `PluginSchema.type` in `@objectstack/spec` is
1774
+ `z.enum(['standard', ...CORE_PLUGIN_TYPES])`, and `CORE_PLUGIN_TYPES` carries
1775
+ `objectql` — the type `packages/objectql/src/plugin.ts` declares on the engine
1776
+ plugin essentially every runtime loads first.
1777
+
1778
+ The comment now lists all eight and names `CORE_PLUGIN_TYPES` as the
1779
+ authoritative set. The same omission in the hand-written
1780
+ `content/docs/plugins/anatomy.mdx` transcription of this interface is corrected
1781
+ in the same change.
1782
+ - f7b25c5: fix(core): `successThreshold` now binds from every status that records a failure, so a declared count above 2 stops being unreachable (#11955)
1783
+
1784
+ `PluginHealthMonitor` consulted `successThreshold` only while a plugin's status
1785
+ was `unhealthy` or `degraded`. The first success in a recovery wrote
1786
+ `recovering` — a status that gate did not name — so the **second** success took
1787
+ the outer `else` and went straight to `healthy` without the counter being read
1788
+ at all. `failed` was in neither set either, so a plugin whose check threw
1789
+ recovered on its **first** success.
1790
+
1791
+ The declared value was therefore capped in practice:
1792
+
1793
+ | Status when the successes start | Consecutive successes actually required |
1794
+ | :--- | :--- |
1795
+ | `unhealthy` / `degraded` | 2, whatever `successThreshold` said |
1796
+ | `failed` / `recovering` | 1, whatever `successThreshold` said |
1797
+
1798
+ A declared `successThreshold: 5` was indistinguishable from `2`. The default is
1799
+ `1`, which is exactly the value at which the defect is invisible — every
1800
+ declared value above it was the one that misbehaved.
1801
+
1802
+ The counter is now consulted on the way out of every status that records an
1803
+ observed failure — `degraded`, `unhealthy`, `failed` and `recovering` — so
1804
+ `successThreshold: N` requires N consecutive successes from each of them, as
1805
+ its declaration says ("Consecutive successes needed to mark healthy"). The gate
1806
+ is a map that is exhaustive over `PluginHealthStatus`, so a status added to the
1807
+ spec fails to compile until it is placed on one side or the other; that is what
1808
+ `recovering` slipped through before.
1809
+
1810
+ `healthy` and `unknown` still promote on the first success, deliberately: the
1811
+ count is declared as a **recovery** criterion ("Number of consecutive successes
1812
+ to recover from unhealthy state") and neither of those records a failure to
1813
+ recover from — `unknown` is the status `registerPlugin` writes before any check
1814
+ has run.
1815
+
1816
+ **Behaviour change, only for configs that declare `successThreshold` above 1.**
1817
+ At the default `1` every route is byte-for-byte what it was: one success has
1818
+ always been enough and still is. A plugin declaring a higher count now takes
1819
+ the number of consecutive successes it asked for before it is reported
1820
+ `healthy`, including after a `failed` round and after an `autoRestart`.
1821
+
1822
+ This also makes #11852's `successCounters` reset load-bearing. That fix cleared
1823
+ the counter on the thrown failure route, and could not be pinned: the counter's
1824
+ only read site was unreachable with a stale non-zero value, so any test would
1825
+ have passed for the wrong reason. With `failed` gated on the counter, a throw
1826
+ that interrupts a recovery now demonstrably starts the count over.
1827
+ - Updated dependencies [809d417]
1828
+ - Updated dependencies [387e231]
1829
+ - Updated dependencies [f794e4e]
1830
+ - Updated dependencies [cae2169]
1831
+ - Updated dependencies [b812a54]
1832
+ - Updated dependencies [2d4fa75]
1833
+ - Updated dependencies [0e4e51b]
1834
+ - Updated dependencies [e84bbf6]
1835
+ - Updated dependencies [effae80]
1836
+ - Updated dependencies [d62f990]
1837
+ - Updated dependencies [c45d8e6]
1838
+ - Updated dependencies [2e3e8c7]
1839
+ - Updated dependencies [e621291]
1840
+ - Updated dependencies [40a93b5]
1841
+ - Updated dependencies [101ad2c]
1842
+ - Updated dependencies [d5b330d]
1843
+ - Updated dependencies [dda969c]
1844
+ - Updated dependencies [1f45690]
1845
+ - Updated dependencies [277948f]
1846
+ - Updated dependencies [8bdd955]
1847
+ - Updated dependencies [f3bbbef]
1848
+ - Updated dependencies [4f24e9d]
1849
+ - Updated dependencies [6a180e4]
1850
+ - Updated dependencies [474242f]
1851
+ - Updated dependencies [63cd487]
1852
+ - Updated dependencies [bd4aa4e]
1853
+ - Updated dependencies [803eaab]
1854
+ - Updated dependencies [f8e8f03]
1855
+ - Updated dependencies [eae824e]
1856
+ - Updated dependencies [f6fa22c]
1857
+ - Updated dependencies [8a483b3]
1858
+ - Updated dependencies [97bcd99]
1859
+ - Updated dependencies [df59de0]
1860
+ - Updated dependencies [96e25a8]
1861
+ - Updated dependencies [f75a38a]
1862
+ - Updated dependencies [7a25e7d]
1863
+ - Updated dependencies [1fa05a6]
1864
+ - Updated dependencies [c85a265]
1865
+ - Updated dependencies [dcb10a5]
1866
+ - Updated dependencies [773a999]
1867
+ - Updated dependencies [35dffea]
1868
+ - Updated dependencies [776a098]
1869
+ - Updated dependencies [5060877]
1870
+ - Updated dependencies [4f6325d]
1871
+ - Updated dependencies [52954c0]
1872
+ - Updated dependencies [2aa8456]
1873
+ - Updated dependencies [93809a3]
1874
+ - Updated dependencies [7c0d0c3]
1875
+ - Updated dependencies [daae7aa]
1876
+ - Updated dependencies [8dc22d6]
1877
+ - Updated dependencies [279431e]
1878
+ - Updated dependencies [948dd6b]
1879
+ - Updated dependencies [3b4c56c]
1880
+ - Updated dependencies [ae8edd2]
1881
+ - Updated dependencies [e25403c]
1882
+ - Updated dependencies [a81aa9d]
1883
+ - Updated dependencies [64baa68]
1884
+ - Updated dependencies [9fa70d7]
1885
+ - Updated dependencies [09db64a]
1886
+ - Updated dependencies [92916e7]
1887
+ - Updated dependencies [a84f3ea]
1888
+ - Updated dependencies [f2eaae8]
1889
+ - Updated dependencies [56c093c]
1890
+ - Updated dependencies [c09451b]
1891
+ - Updated dependencies [ba64877]
1892
+ - Updated dependencies [7345308]
1893
+ - Updated dependencies [79b6a22]
1894
+ - Updated dependencies [30d96ab]
1895
+ - Updated dependencies [f658793]
1896
+ - Updated dependencies [c95ad19]
1897
+ - Updated dependencies [e58ea8b]
1898
+ - Updated dependencies [4a17645]
1899
+ - Updated dependencies [3795c5f]
1900
+ - Updated dependencies [8ab926b]
1901
+ - Updated dependencies [7317cf2]
1902
+ - Updated dependencies [e25e839]
1903
+ - Updated dependencies [5997207]
1904
+ - Updated dependencies [8b13cc8]
1905
+ - Updated dependencies [4a4a35d]
1906
+ - Updated dependencies [86e765a]
1907
+ - Updated dependencies [1d7e76a]
1908
+ - Updated dependencies [53dc739]
1909
+ - Updated dependencies [fd289be]
1910
+ - Updated dependencies [03bf7b1]
1911
+ - Updated dependencies [f90e820]
1912
+ - Updated dependencies [18d816a]
1913
+ - Updated dependencies [e8bd715]
1914
+ - Updated dependencies [b91c351]
1915
+ - Updated dependencies [a28a3c0]
1916
+ - Updated dependencies [daeaaf9]
1917
+ - Updated dependencies [c459da6]
1918
+ - Updated dependencies [e914733]
1919
+ - Updated dependencies [f887e52]
1920
+ - Updated dependencies [881f8d8]
1921
+ - Updated dependencies [3bfa1e6]
1922
+ - Updated dependencies [901355c]
1923
+ - Updated dependencies [34ce8e7]
1924
+ - Updated dependencies [33681ea]
1925
+ - Updated dependencies [bfe13c8]
1926
+ - Updated dependencies [0fb3044]
1927
+ - Updated dependencies [4635f3e]
1928
+ - Updated dependencies [ee3595c]
1929
+ - Updated dependencies [b2eab95]
1930
+ - Updated dependencies [93940d4]
1931
+ - Updated dependencies [3a04b01]
1932
+ - Updated dependencies [45b9051]
1933
+ - Updated dependencies [b9e9227]
1934
+ - Updated dependencies [d395692]
1935
+ - Updated dependencies [5894d30]
1936
+ - Updated dependencies [a3765f6]
1937
+ - Updated dependencies [e22158f]
1938
+ - Updated dependencies [7404925]
1939
+ - Updated dependencies [0c2334f]
1940
+ - Updated dependencies [778c59f]
1941
+ - Updated dependencies [d2619fd]
1942
+ - Updated dependencies [6acb11a]
1943
+ - Updated dependencies [33c5fd3]
1944
+ - Updated dependencies [20b0fdb]
1945
+ - Updated dependencies [905019b]
1946
+ - Updated dependencies [a286411]
1947
+ - Updated dependencies [98c0d33]
1948
+ - Updated dependencies [368a82e]
1949
+ - Updated dependencies [a3d5724]
1950
+ - Updated dependencies [93ea19b]
1951
+ - Updated dependencies [9ee2dcf]
1952
+ - Updated dependencies [8cb96ec]
1953
+ - Updated dependencies [8f10a79]
1954
+ - Updated dependencies [6269a55]
1955
+ - Updated dependencies [22e5236]
1956
+ - Updated dependencies [0fb8760]
1957
+ - Updated dependencies [e5ce2ed]
1958
+ - Updated dependencies [be21955]
1959
+ - Updated dependencies [bc56e18]
1960
+ - Updated dependencies [be21955]
1961
+ - Updated dependencies [a9ee989]
1962
+ - Updated dependencies [4d0d944]
1963
+ - Updated dependencies [15d58db]
1964
+ - Updated dependencies [d63b014]
1965
+ - Updated dependencies [9abe4e4]
1966
+ - Updated dependencies [2cc7122]
1967
+ - Updated dependencies [50d6c92]
1968
+ - Updated dependencies [9e0ba21]
1969
+ - Updated dependencies [311433f]
1970
+ - Updated dependencies [3e5ad08]
1971
+ - Updated dependencies [9abe4e4]
1972
+ - Updated dependencies [b7131f3]
1973
+ - Updated dependencies [e5812fa]
1974
+ - Updated dependencies [7085f90]
1975
+ - Updated dependencies [dee4dd4]
1976
+ - Updated dependencies [ce7e497]
1977
+ - Updated dependencies [51ecb2f]
1978
+ - Updated dependencies [9086761]
1979
+ - Updated dependencies [42a117b]
1980
+ - Updated dependencies [1401ae7]
1981
+ - Updated dependencies [4297fe7]
1982
+ - Updated dependencies [e398863]
1983
+ - Updated dependencies [d16df74]
1984
+ - Updated dependencies [f11fc61]
1985
+ - Updated dependencies [e808890]
1986
+ - Updated dependencies [8f79379]
1987
+ - Updated dependencies [e6ca40e]
1988
+ - Updated dependencies [0c77ea4]
1989
+ - Updated dependencies [52954c0]
1990
+ - Updated dependencies [89eb997]
1991
+ - Updated dependencies [aa5994e]
1992
+ - Updated dependencies [be93457]
1993
+ - Updated dependencies [a65db76]
1994
+ - Updated dependencies [2cf5a96]
1995
+ - Updated dependencies [15eb2c9]
1996
+ - Updated dependencies [5691b07]
1997
+ - Updated dependencies [2a6122b]
1998
+ - Updated dependencies [225e769]
1999
+ - Updated dependencies [8af88dd]
2000
+ - Updated dependencies [fb5fbb8]
2001
+ - Updated dependencies [d7b3963]
2002
+ - Updated dependencies [b72db01]
2003
+ - Updated dependencies [dce5cd4]
2004
+ - Updated dependencies [177ebdc]
2005
+ - Updated dependencies [8d237b4]
2006
+ - Updated dependencies [2d2e6f0]
2007
+ - Updated dependencies [2d8dd8d]
2008
+ - Updated dependencies [22d573e]
2009
+ - Updated dependencies [b5a2398]
2010
+ - Updated dependencies [348860c]
2011
+ - Updated dependencies [5383fa6]
2012
+ - Updated dependencies [5b3ff63]
2013
+ - Updated dependencies [1a6a19c]
2014
+ - Updated dependencies [527e050]
2015
+ - Updated dependencies [dd33bf9]
2016
+ - Updated dependencies [4cb2a90]
2017
+ - Updated dependencies [74a7804]
2018
+ - Updated dependencies [53d3689]
2019
+ - Updated dependencies [b3a63d3]
2020
+ - Updated dependencies [033a34c]
2021
+ - Updated dependencies [4d25d22]
2022
+ - Updated dependencies [1ffee51]
2023
+ - Updated dependencies [5ae4303]
2024
+ - Updated dependencies [ece4dad]
2025
+ - Updated dependencies [e9b377e]
2026
+ - Updated dependencies [146f448]
2027
+ - Updated dependencies [735f5c7]
2028
+ - Updated dependencies [a7e18de]
2029
+ - Updated dependencies [366f895]
2030
+ - Updated dependencies [dc75ba8]
2031
+ - Updated dependencies [cce0aa9]
2032
+ - Updated dependencies [e764507]
2033
+ - Updated dependencies [cff17af]
2034
+ - Updated dependencies [39404f3]
2035
+ - Updated dependencies [ca1965f]
2036
+ - Updated dependencies [8619f95]
2037
+ - Updated dependencies [b706af9]
2038
+ - Updated dependencies [db8c288]
2039
+ - Updated dependencies [0e5fe7f]
2040
+ - Updated dependencies [fc9ba76]
2041
+ - Updated dependencies [0f94cc7]
2042
+ - Updated dependencies [a11c1a5]
2043
+ - Updated dependencies [71f9cd1]
2044
+ - Updated dependencies [ee17d86]
2045
+ - Updated dependencies [cdbd920]
2046
+ - Updated dependencies [18c432e]
2047
+ - Updated dependencies [3c418c4]
2048
+ - Updated dependencies [fa8715a]
2049
+ - Updated dependencies [a933ed7]
2050
+ - Updated dependencies [b3ca463]
2051
+ - Updated dependencies [a933ed7]
2052
+ - Updated dependencies [0d4a6a8]
2053
+ - Updated dependencies [518d5e5]
2054
+ - Updated dependencies [6643ba1]
2055
+ - Updated dependencies [eeba2ef]
2056
+ - Updated dependencies [ec4c4d2]
2057
+ - Updated dependencies [424f73c]
2058
+ - Updated dependencies [cccbe51]
2059
+ - Updated dependencies [a8d6b1d]
2060
+ - Updated dependencies [e4a7695]
2061
+ - Updated dependencies [87075b1]
2062
+ - Updated dependencies [fc58a99]
2063
+ - Updated dependencies [14cfc00]
2064
+ - Updated dependencies [1c6f7b4]
2065
+ - Updated dependencies [e854a53]
2066
+ - Updated dependencies [dfebfc8]
2067
+ - Updated dependencies [d028b37]
2068
+ - Updated dependencies [122ef38]
2069
+ - Updated dependencies [4a37870]
2070
+ - Updated dependencies [428f9b2]
2071
+ - Updated dependencies [aa7ff56]
2072
+ - Updated dependencies [c41b42e]
2073
+ - Updated dependencies [c4db311]
2074
+ - Updated dependencies [750fff5]
2075
+ - Updated dependencies [c19035e]
2076
+ - Updated dependencies [ececf7a]
2077
+ - Updated dependencies [d173125]
2078
+ - Updated dependencies [8eeca27]
2079
+ - Updated dependencies [8425c17]
2080
+ - Updated dependencies [a5ef1d8]
2081
+ - Updated dependencies [87ad30c]
2082
+ - Updated dependencies [772d5de]
2083
+ - Updated dependencies [ce80ec2]
2084
+ - Updated dependencies [b372318]
2085
+ - Updated dependencies [97a2263]
2086
+ - Updated dependencies [29d0676]
2087
+ - Updated dependencies [0169d49]
2088
+ - Updated dependencies [6bd3231]
2089
+ - Updated dependencies [d2b5ba8]
2090
+ - Updated dependencies [b799ac5]
2091
+ - Updated dependencies [8f74307]
2092
+ - Updated dependencies [d23dc08]
2093
+ - Updated dependencies [644ad50]
2094
+ - Updated dependencies [9735662]
2095
+ - Updated dependencies [4d5b4f8]
2096
+ - Updated dependencies [0da7cd2]
2097
+ - Updated dependencies [28a5c3e]
2098
+ - Updated dependencies [4bc18e5]
2099
+ - @objectstack/spec@17.3.0
2100
+ - @objectstack/types@17.3.0
2101
+
3
2102
  ## 17.2.0
4
2103
 
5
2104
  ### Minor Changes