@objectstack/core 17.3.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,654 @@
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
+
3
652
  ## 17.3.0
4
653
 
5
654
  ### Minor Changes