Skip to content

Commit 54fba06

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-14646-discovery-stops-advertising-unmounted-realtime
2 parents 3c22ddd + f50c394 commit 54fba06

80 files changed

Lines changed: 4796 additions & 1136 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
Documentation: the analytics `where` contract and the `element:number` D3 entry now name the hop an array filter is lowered at.
6+
7+
Text only — no schema, accept-set, runtime or test behaviour changes. `AnalyticsQuerySchema.where` is still `FilterConditionSchema` and still refuses an array, which is the protocol working as `FilterArray`'s docblock (#5158 ruling C) declares it: a `FilterArray` is input-only authoring sugar, lowered to a `FilterCondition` at the single sink `parseFilterAST` (`@objectstack/spec/data`) the moment it arrives, and only the lowered `FilterCondition` travels any further.
8+
9+
- `AnalyticsQuerySchema.where`'s `.describe()` gains one sentence pointing array authors at that lowering: an authored `FilterArray` is lowered by `parseFilterAST` on the client before the wire, and this field admits only the lowered `FilterCondition`. It lands in the generated `content/docs/references/{api,data}/analytics.mdx` prop tables, which is where an author reads it.
10+
- The `element-number-filter-rule-array` semantic migration entry recorded its runtime prerequisite one hop too late: "authored array → adapter lowering → filter AST → accepted by `lowerAnalyticsWhere`". `lowerAnalyticsWhere` (`service-analytics`) is the in-process door (#5334) for callers reaching `analyticsService.query` directly. The wire's door is the runtime route `POST /analytics/query`, which parses `where` with `AnalyticsQueryRequestSchema` before any service code runs, so an un-lowered array is refused there. The entry's reason clause now names that route hop and the `parseFilterAST` lowering the adapter owes before the wire (#15828; the adapter-side fix is objectui#7752).
11+
12+
The sibling entry `element-record-picker-filter-rule-array` was read for the same claim and does not make it — its measured path is `find()` / `convertQueryParams`, not the analytics wire — so it is unchanged.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
A flow can now REFUSE with per-record text: the `end` node gains `outcome` and an interpolated `message`, and the run vocabulary gains `refused`.
6+
7+
Until now every terminal of a flow was "completed". A flow could say *do this* but not *refuse this, and say why, for which record* — the only channel that interpolated per-record text was a `screen` node's `description`, and a message-only screen renders Submit and, on submit, resumes to `end`, whose runner toasts `Flow "…" completed` at a user who was just told "this is refused". Maintainer ruling (2026-09-05, option 2′): the refusal is a first-class outcome of the existing terminal node, not a second node type.
8+
9+
The contract, declared here first (the engine and runner halves follow in their own packages):
10+
11+
- **`end` node config**`EndConfigSchema` (`@objectstack/spec/automation`): `outcome?: 'completed' | 'refused'` (default `completed`) and `message?: string`, a `{token}` template interpolated at run time exactly like a screen `description` (`{record.name}` etc.). `outcome: 'refused'` without a `message` is refused at parse (a refusal without text is the shape this exists to replace); `message` on a completed end is refused too (nothing would ever render it). The shape is strict: an undeclared key is a parse error naming the intended key. Because `end` is structural (no executor, no descriptor), `FlowNodeSchema` applies the contract itself to every `type: 'end'` node it parses and writes the parsed (defaulted) config back; a node with no `config` is left without one. Every other node type's `config` stays the open, executor-owned slot it was.
12+
- **Run row**`ExecutionStatus` gains `refused` (appended last: a terminal state distinct from `failed` — a refusal is a successful evaluation that says no; never resumed) and `ExecutionLogSchema` gains `refusalMessage`, the rendered per-record text, set only on a refused run.
13+
- **Result / wire**`AutomationResult.status` and `TriggerFlowResponseSchema.data.status` gain `'refused'`, and both carry `refusalMessage`; on a refusal `success` is `true` and `successMessage` is absent, so a runner shows the message with Close only — no Submit, no completion toast.
14+
15+
Additive throughout: nothing renamed or retired, so no ADR-0087 conversion-layer entry (disposition: not-required). Flows that never set `config` on an `end` node parse exactly as before.
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
ADR-0087 semantic-migration ledger: register the retirement of `@objectstack/core`'s `PluginSecurityScanner` (#14919)
6+
7+
`PluginSecurityScanner`, `ScanTarget` and `SecurityIssue` are removed from
8+
`@objectstack/core` in the same PR, under ADR-0049 enforce-or-remove (maintainer
9+
ruling 2026-09-05, director summon #14, decision batch #42). This is the ledger
10+
half: a D3 semantic entry
11+
(`src/migrations/entries/semantic/18.plugin-security-scanner-retired.ts`,
12+
concatenated into `MIGRATIONS_BY_MAJOR[18].semantic` by `gen:migration-registry`)
13+
so the retirement reaches `spec-changes.json` and the generated upgrade guide
14+
rather than being invisible to every upgrade channel.
15+
16+
FROM `new PluginSecurityScanner(kernel.logger)` → TO nothing: delete the import
17+
and every call. There is no replacement export, and a caller that branched on
18+
`result.status === 'passed'` takes that branch unconditionally — it is the only
19+
branch the scanner ever produced, because four of its five scan methods returned
20+
an empty issue list on every input and the fifth read a vulnerability database
21+
whose only writer had zero callers.
22+
23+
Why an entry is owed at all, and why D3 rather than a D2 conversion: the class
24+
has no spec schema and never had one. It is a runtime TS class, so there is no
25+
authorable key to tombstone with `retiredKey()` and no stored `sys_metadata` row
26+
a conversion could rewrite — a scanner was constructed per call and every result
27+
lived in a per-instance Map discarded with the object, so
28+
`applyConversionsToStoredItem` has no seam that would ever see one. The enforced
29+
channel is tsc at the consumer's own import site; for anyone it does not reach,
30+
this entry and the upgrade guide are the only channel. That is the
31+
`contracts.IDataDriver.findStream` and `actor-user-roles-to-positions`
32+
disposition, applied to a surface one layer further out than either — those are
33+
declared in `packages/spec`, this one only in `packages/core`.
34+
35+
Measured, and worth recording because the entries README warns of a regeneration
36+
lap that did not materialise here: `check:generated` reports all 15 artifacts up
37+
to date after the entry landed, and running `gen:spec-changes` and
38+
`gen:upgrade-guide` explicitly moved neither file — a major-18 semantic entry is
39+
not yet projected into either. `registry.ts` is the whole generated diff.
40+
41+
No behaviour in `@objectstack/spec` changes; this adds a ledger row and the
42+
regenerated region that carries it.
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
---
2+
"@objectstack/core": minor
3+
---
4+
5+
feat(core)!: retire `PluginSecurityScanner` — plugin security scanning is not a platform capability (#14919)
6+
7+
<!-- adr-0087: registered plugin-security-scanner-retired -->
8+
9+
**ADR-0087 disposition: registered**, as `plugin-security-scanner-retired` in
10+
`MIGRATIONS_BY_MAJOR[18].semantic` — a **D3 semantic** entry, not a D2 conversion,
11+
and so not the metadata migration the ruling excludes. The class has no spec schema
12+
and never had one, so there is no authorable key to tombstone with `retiredKey()`
13+
and no stored `sys_metadata` row a conversion could rewrite: a scanner was
14+
constructed per call and every result lived in a per-instance Map discarded with the
15+
object, so `applyConversionsToStoredItem` has no seam that would ever see one. An
16+
entry is nevertheless owed rather than optional, because this changeset carries a
17+
real consumer prescription — the enforced channel is tsc at the import site, and for
18+
any consumer it does not reach, the ledger and the generated upgrade guide are the
19+
only channel there is. Same disposition as `contracts.IDataDriver.findStream` and
20+
`actor-user-roles-to-positions`.
21+
22+
**BREAKING**`PluginSecurityScanner` is removed from `@objectstack/core`,
23+
together with its two companion types `ScanTarget` and `SecurityIssue`. Landing
24+
as `minor` under the repo's launch-window convention for breaking changes.
25+
**There is no replacement**, and none is planned.
26+
27+
⚠️ **The out-of-repo consumer population for these three exports is NOT
28+
MEASURED.** This changeset can state only what was measured *inside* the
29+
sources this repo can read: zero constructors in objectstack, zero in objectui
30+
at the pinned sha, and zero in the deleted example itself. How many published
31+
consumers of `@objectstack/core` import the class is unknown — no download,
32+
dependent or source telemetry was consulted. Read the removal as breaking for
33+
an unmeasured population, not as a removal proven to break nobody.
34+
35+
## Why it was removed rather than repaired
36+
37+
The class was a shell that reported success. `scan()` composed five private
38+
scanners: four of them (`scanCode`, `scanMalware`, `scanLicenses`,
39+
`scanConfiguration`) allocated an empty issue array, logged, and returned it
40+
with no code in between — none could report a finding for any input. The fifth,
41+
`scanDependencies`, ran a real loop but matched only against an in-memory
42+
vulnerability database whose sole writer, the public `addVulnerability`, had
43+
zero callers; `updateVulnerabilityDatabase()` logged twice and fetched nothing.
44+
The database was therefore empty on every code path that has ever executed, so
45+
no issue was ever produced, the score stayed 100, and the result was
46+
`status: 'passed'` for every plugin the scanner was ever handed — a malicious
47+
one included.
48+
49+
A security control that cannot fail is worse than no security control, because
50+
callers rely on it. Repair — writing a real vulnerability scanner — was refused
51+
by name: it is a feature with a design surface and no demand, not a defect fix.
52+
53+
## FROM → TO
54+
55+
```ts
56+
// FROM — compiles today, and passes every plugin it is given
57+
import { PluginSecurityScanner } from '@objectstack/core';
58+
59+
const scanner = new PluginSecurityScanner(kernel.logger);
60+
const result = await scanner.scan({ pluginId, version, dependencies });
61+
if (result.status === 'passed') { await kernel.use(plugin); }
62+
63+
// TO — delete it. The condition above was always true.
64+
await kernel.use(plugin);
65+
```
66+
67+
**The one-line fix:** delete the import and every call; no symbol replaces it.
68+
If your code branched on `result.status`, take the `'passed'` branch — that is
69+
the only branch it ever took.
70+
71+
**If you were relying on it for actual security**, you were not getting any.
72+
Audit dependencies with the tools built for it (`npm audit` / `pnpm audit`,
73+
Dependabot, the GitHub Advisory Database, OSV) and treat an unaudited
74+
third-party plugin as untrusted code. What ObjectStack does still enforce is
75+
artifact **integrity and signatures** (`verifyPluginArtifactIntegrity`, the
76+
plugin signature verifier — "is this what the publisher signed?", never "is
77+
this safe?"), explicit plugin **permissions**, and the sandbox **resource
78+
limits**; all three are unchanged.
79+
80+
Removed under ADR-0049 enforce-or-remove, per the maintainer ruling of
81+
2026-09-05 (director summon #14, decision batch #42). The retirement is pinned
82+
as an export-list assertion on both barrels in
83+
`packages/core/src/security/security-scanner-retirement.pin.test.ts`.

.changeset/quiet-pans-repair.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@objectstack/plugin-security': patch
3+
---
4+
5+
Remove seven dead `{ records }` union-normalizer limbs on engine `find()` results, and repair the one that was silently dropping instead of gapping.
6+
7+
Six seams in this plugin normalized an engine read as `Array.isArray(x) ? x : x.records`. The envelope limb was unreachable: `ObjectQL.find` resolves a bare array of row objects, measured by booting a real engine over a real `SqlDriver` and driving each seam through the shipped function that owns it, rather than inferred from `IDataEngine.find`'s declared `Promise<any[]>` (a declared type is not proof — this repo also has a `find()` that resolves an envelope). Each seam keeps its existing disposition for a non-array; only the dead limb is gone.
8+
9+
The seventh is repaired in the opposite direction. `SecurityPlugin`'s `sys_permission_set` loader mapped three different facts onto one value: a read that succeeded on an empty catalog, a read that threw, and a read that resolved something it could not read all left as `[]`. On the enforcement plane that silently withdraws grants that exist while every request still looks normal, and it made `PermissionEvaluator`'s existing "db lookup failed" warning unreachable — so a transient database error and an empty catalog produced identical, undiagnosable 403s. The loader now lets the read fault propagate and refuses an unreadable result with `DATABASE_ERROR`. Enforcement is unchanged for every result the shipped engine produces; an envelope or a non-row element now refuses (fail-closed) where the old code read through it. An unanswered read still grants nothing; what changes is that it is now reported instead of silent.
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@objectstack/rest": patch
3+
---
4+
5+
fix(rest): the metadata reads pass the declared default locale to the label resolvers, so a request for it answers with the authored label (#15711)
6+
7+
`translateOptionsFor` — the single seam every metadata-document translation in the REST server goes through — now threads `i18n.getDefaultLocale()` into `ResolveOptions.defaultLocale` beside the declared fallback chain it has passed since #14882. Both accessors are optional on `II18nService` and both are feature-detected: a provider that declares no default gets no default, one that declares no fallback gets no chain, and the seam never answers `'en'` on a provider's behalf.
8+
9+
Measured on the reporter's stack shape (`defaultLocale: 'zh-CN'`, `fallbackLocale: 'en'`, an `en` bundle and no `zh-CN` bundle): `GET /api/v1/meta/object/kpi_entry_sheet` with `Accept-Language: zh-CN` — or with no header at all, which resolves to the default — now serves the authored `填报单`, not the `en` bundle's `Entry Sheet`; a `fr` request still walks the declared `en` bundle; an `en` request still gets the `en` bundle. Pinned in `meta-i18n-declared-fallback-chain.test.ts` §4 and §5.
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): the authored label is the default locale's text — `ResolveOptions.defaultLocale` skips the fallback chain for a default-locale request, and a chain-less caller no longer falls to a literal `en` (#15711)
6+
7+
<!-- adr-0087: not-required (no-migration-prescription) A published RUNTIME DEFAULT moves: the label resolvers' chain-less fallback, a literal `['en']` before this change, is now `[]`; and one OPTIONAL key is added to a plain TS options interface (`ResolveOptions.defaultLocale` in `packages/spec/src/system/i18n-resolver.ts`). No Zod schema, no authorable metadata key and no stored row changes shape, so the meta conversion tooling has nothing to touch and a registry entry would be false data in the one ledger this gate keeps true. Which TEXT answers a chain-less non-default request moves; nothing starts failing, and no source edit is prescribed: a host that wants `en` consulted declares `fallbackChain: ['en']`, which it always could. -->
8+
9+
**BREAKING** (launch-window convention: ships as `minor`; this entry is the signal) — the second facet of the #15711 ruling moves a published default of the `@objectstack/spec/system` label resolvers. A caller that passes no `fallbackChain` used to get a literal `['en']`; it now gets `[]`, "requested locale, then the authored label". Nothing silently falls to `en` because a literal said so: a chain is consulted only when someone declared it. In this repo the blast radius is zero production callers (the REST serving layer has declared its chain since #14882; one pin flips); out-of-repo hosts unmeasured. A host that relied on the implicit `en` declares it as `fallbackChain: ['en']`.
10+
11+
## The ruling (#15711, recorded 2026-09-05)
12+
13+
A workspace that authors its metadata labels in its default locale (`i18n.defaultLocale: 'zh-CN'`, inline `label: '填报单'`) and ships a courtesy `en` bundle used to serve `Entry Sheet` to a `zh-CN` request whenever its declared chain named `en` — a reflexive `fallbackLocale: 'en'` in an AI-authored config was enough. `os i18n check` already counted the authored text as the default locale's coverage; the runtime did not. Ruled A: **the authored label IS the default locale's text**.
14+
15+
- `ResolveOptions` gains an optional `defaultLocale?: string` — the deployment's default locale, the language its labels are authored in. When the requested locale names it (BCP-47 tags compare case-insensitively, the same rule `resolveBundleLocale` applies), the resolvers consult the requested locale's own bundle and then answer with the authored label; the fallback chain is not walked.
16+
- `fallbackChain` keeps its full meaning for every non-default request: a `fr` request still walks the `fr` bundle, then the declared `en` bundle, then the authored label.
17+
- A bundle entry for the default locale still wins when one is shipped, so `os i18n extract --locales=zh-CN` keeps working — optional now, not required.
18+
- `II18nService.getDefaultLocale()` documents that it is also what the serving layer threads into `ResolveOptions.defaultLocale`; `@objectstack/rest` passes it through its single `translateOptionsFor` seam (that package's own changeset).
19+
20+
Unchanged: `os i18n check`; both boot paths (`os serve` and the dev plugin still collapse the declaration to `fallbackLocale || defaultLocale || 'en'` before constructing the service); every request whose locale is not the default.
21+
22+
Not taken, ruled out on the card: the rule living only in `packages/rest` (every other host would re-implement it and spec could not pin it); requiring every supported locale to ship a bundle (a generated bundle that duplicates the app's own source text, the stale-translation class already closed); documenting the divergence.

0 commit comments

Comments
 (0)