Skip to content

Commit 10de381

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-5696-transaction-contract
2 parents a5a11f4 + 36fc938 commit 10de381

207 files changed

Lines changed: 7769 additions & 1233 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: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
---
2+
"@objectstack/types": minor
3+
"@objectstack/runtime": minor
4+
"@objectstack/rest": patch
5+
---
6+
7+
fix(runtime,types)!: `/analytics/query` no longer echoes RLS policy field names — the declared-server-fault withhold is shared by both HTTP boundaries (#5811)
8+
9+
**Observable behaviour change — read this if you read, log, or assert on
10+
`error.message` from a dispatcher-plugin route.** An error that **declares a
11+
server fault** in the ADR-0112 envelope (`status >= 500` *and* a non-empty
12+
`code`) now leaves `dispatcher-plugin.errorResponseBase` with its message
13+
replaced by `"Internal server error"`. It previously reached the caller verbatim
14+
unless it happened to *sound* like a SQL/driver dump. This applies to every route
15+
that plugin mounts — `/analytics`, `/packages`, `/i18n`, `/automation`, `/auth`,
16+
`/notifications`, `/mcp`, … — not only the one that motivated it. Nothing a
17+
machine reads changed: the producer's `code` still arrives in the response
18+
(`error.code`, promoted there from `details` by the shared envelope builder,
19+
#3842), the status is untouched, and the full original text still goes to the
20+
server log and `errorReporter` via `__obsRecordedError`.
21+
22+
## What was wrong
23+
24+
#5367 (maintainer ruling 2026-08-06) made `read-scope-sql.ts`'s ten fail-closed
25+
RLS lowering refusals `READ_SCOPE_COMPILE_FAILED` / 500 and taught
26+
`POST /analytics/dataset/query` to withhold their message, because those messages
27+
name the field names and comparands of an **administrator's** sharing rule:
28+
29+
```
30+
[read-scope-sql] unsafe field identifier "secret_policy_field" — refusing to
31+
build read scope (fail-closed).
32+
```
33+
34+
The caller never wrote that field name and must not be able to read it out of an
35+
error body. But the **sibling** analytics face was never closed.
36+
`compileScopedFilterToSql` runs on both `NativeSQLStrategy.applyReadScope` and
37+
`ObjectQLStrategy`'s echoed SQL, both of which serve `POST /analytics/query`,
38+
which exits through `dispatcher-plugin.errorResponseBase`. That exit's only
39+
message guard was `looksLikeInternalErrorLeak` — a heuristic over SQL/driver
40+
*phrasing* — and all eleven read-scope message shapes return `false` from it.
41+
Measured at that boundary: **11 of 11 echoed verbatim**, at 500, with the policy
42+
content in `error.message`. A real reachable disclosure, not a theoretical one.
43+
44+
## What changed
45+
46+
- **`@objectstack/types` gains `declaresServerFault(err)`**, exported from
47+
`error-leak.ts` beside `looksLikeInternalErrorLeak`. The heuristic asks whether
48+
a message *sounds* internal; the declaration asks whether the producer *said
49+
so*. `error-leak.ts`'s own file header already states the principle — "do not
50+
ship driver internals to clients" is a property of the HTTP boundary, not of
51+
one router — and this is the second predicate that principle asks for.
52+
- **Both boundaries read it.** `dispatcher-plugin.errorResponseBase` gains the
53+
withhold (the fix); `rest-server.ts`'s `/analytics/dataset/query` catch drops
54+
its in-line copy of the same test in favour of the shared one. #5808 wrote that
55+
rule in-line on purpose — promoting a rule with one consumer is a speculative
56+
surface — and this is the second consumer, so it was promoted rather than
57+
duplicated (`#3843`/`#3867` paid for the two-implementations shape twice).
58+
The REST face's verdict is unchanged in every case: same `status >= 500` plus
59+
non-empty `code` test, over the same two fields.
60+
61+
## What deliberately did NOT change
62+
63+
-**This is not "withhold every 5xx".** #5667 kept **undeclared** 5xx errors
64+
legible on purpose: a bare `Error` from our own code ("no strategy can handle
65+
query …") is the operator's own bug report, names nothing tenant-sensitive, and
66+
still falls to `looksLikeInternalErrorLeak` alone. A 5xx carrying only half an
67+
envelope (a status with no code) is likewise still readable — inventing the
68+
withhold for it would be the consumer-side leniency Prime Directive #12 removes.
69+
- **4xx is untouched.** `declaresServerFault` requires `status >= 500`, so a
70+
deliberate business/validation answer can never be swallowed by it.
71+
- **`statusCode` is not accepted as a substitute for `status`.** `status` is the
72+
channel ADR-0112 declares; making a disclosure rule depend on which spelling a
73+
producer reached for would be the same leniency in a different place.
74+
- **The heuristic was not taught to recognise `[read-scope-sql]`.** That would be
75+
more prose sniffing — the mechanism #5352/#5367 exist to remove — and would only
76+
ever cover the family someone remembered to add.
77+
78+
Coverage: `analytics-query-read-scope-withhold.test.ts` (runtime) drives six RLS
79+
policy shapes end-to-end through a **real** `AnalyticsService` on the real
80+
native-SQL path and the real mounted route, asserting the 500, that the whole
81+
serialized body contains no policy detail, that `error.code` still carries
82+
`READ_SCOPE_COMPILE_FAILED`, and that the full text is still on the
83+
`__obsRecordedError` side-channel — plus a positive control and both sides of the
84+
declared-vs-undeclared tiering. `error-leak.test.ts` (types) pins the predicate
85+
directly, including that all eleven read-scope shapes stay invisible to the
86+
heuristic. The REST face's existing `analytics-read-scope-refusal-envelope.test.ts`
87+
is green before and after, unchanged, which is the pin on the refactor.
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/platform-objects': patch
3+
'@objectstack/runtime': patch
4+
'@objectstack/objectql': patch
5+
'@objectstack/spec': patch
6+
---
7+
8+
fix(platform-objects): 超预算后台 seed 期间不再空库自证 —— 一次启动不再跑两套契约
9+
10+
#4769 已把 ADR-0104 的空库自证从 `kernel:ready` 挪到 `app:seeded`(本次启动自身数据的结算点),但保留 `kernel:ready` 作为「从不 seed 的内核」的兜底。剩下的窗口是这两个钩子**到达顺序可以颠倒**:`AppPlugin` 的 inline seed 超出软预算(`OS_INLINE_SEED_BUDGET_MS`,默认 8s)后转入后台,于是 `kernel:ready` 先到、兜底自证在 seed 仍在写的时候签发证书并把闸门翻到 strict——同一次 seed 运行的后半段撞上前半段从未见过的契约。showcase 冷启(`OS_INLINE_SEED_BUDGET_MS=1`)实测:自证发生在 +0.470s,seed 结算在 +3.617s,窗口 3.147s。
11+
12+
现在两个钩子都先问一句「本次启动自己的 seed 落定了吗」,任一处报告仍有未结算的 seed 源就不签发。`app:seeded` 同样受这道检查约束——多 config app 的 bundle 会每个 app 触发一次,第一次并不是本次启动的结算点。
13+
14+
新增 `seed-settlement` 契约(`@objectstack/spec/contracts`)承载这个信号,而不是让 platform-objects 去嗅 runtime 内部的 `seed-datasets` 服务:那个数组的存在只能说明「seed 源存在」,永远说明不了「已经落定」,而这两件事之间的差正是本 bug 的整个窗口。runtime 在选择分支之前先声明 seed 源,并在写入真正结束的同一刻结算它。
15+
16+
**multi-tenant 与 `skipSeedData` 的 ADR-0104 姿态(2026-08-06 裁定,#4795)**:这两种部署会注册 seed 数据但在启动时并不写入(前者按 org 在 `sys_organization` insert 时重放,后者是 `os migrate` 的只读规划启动,#3917),`app:seeded` 永不触发。它们的姿态是**启动时不自证,等 `os migrate … --apply` 在真实扫描的证据上落笔**——由同一个判据自然得出,不需要单独分支。这是答案而不是缺口:在启动那一刻断言一次尚未发生的 per-org 重放不含违规值,正是 #4769 的同一个错误、只是引信更长;而停在 warn-first 是可恢复的方向,随时可由 `os migrate value-shapes --apply` / `os migrate files-to-references --apply` 关闭。
17+
18+
`@objectstack/objectql` 侧只更新了 #4769 撤销机制的注释:「后台 seed 收尾晚于签发」不再是它要兜的场景(已在源头关闭),它对 `os dev` 热重载 seeder、运行期 marketplace 安装以及 lax 开关仍然有效。
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
---
2+
"@objectstack/cli": patch
3+
---
4+
5+
fix(cli): `objectstack dev` restarts the server after each rebuild — the running server can no longer silently disagree with `dist/objectstack.json` (#5148)
6+
7+
The dev watcher rebuilt `dist/objectstack.json` on every source change and
8+
printed `✓ recompiled — server will auto-reload`, but the running serve child
9+
only **partially** received the rebuilt artifact: MetadataPlugin's own
10+
artifact watcher re-ingests the metadata registry, syncs DDL + seeds for
11+
newly-appearing objects and broadcasts the SSE HMR event — while hook bodies
12+
and already-registered view metadata from the compiled bundle are applied at
13+
**boot only**. #5148 measured both staying stale: the old hook kept executing
14+
and `/api/v1/meta/views` kept serving the old view after a confirmed on-disk
15+
rebuild, with no error and no warning. That made every dev edit/verify loop
16+
capable of a false conclusion in either direction ("my fix doesn't work" /
17+
"this code isn't load-bearing").
18+
19+
`objectstack dev` now supervises its serve child nodemon-style:
20+
21+
- **Auto-restart (default-on).** After a rebuild lands on disk, the serve
22+
child is SIGTERMed (the kernel shuts down gracefully), and a replacement is
23+
spawned once it exits — boot-time load is the one path that applies the
24+
whole artifact. Restarts coalesce (rapid saves produce one restart), a
25+
child that ignores SIGTERM is force-killed after 8s, a replacement that
26+
fails to come up is loud and exits dev, and parent SIGINT/SIGTERM is
27+
forwarded to the child so no orphan server outlives dev.
28+
- **`--no-restart` opt-out.** The watcher then only rebuilds the artifact —
29+
and every rebuild now says explicitly that the running server keeps the
30+
build it booted with. The watch banner states the active mode instead of
31+
implying a hot reload the runtime only partially performs.
32+
- **Boot-time staleness warning (#5148 startup variant).** When
33+
`objectstack.config.ts` / `src/**` are newer than the artifact at boot
34+
(edited while the server was down), dev warns loudly that the boot serves
35+
the stale build, names the newest source file and the remedy
36+
(`objectstack build`, `--compile`, or save a watched file). The boot is
37+
never gated — the silence is removed, not the start.
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
fix(spec): a reference page's opening paragraph is the module's own doc block, never a symbol's (#5059)
6+
7+
`getFileDescription()` took the **first** doc block anywhere in a `*.zod.ts`
8+
file, verbatim, and published it as the page's opening paragraph. That is not a
9+
rule about descriptions — it is a rule about *ordering*: whichever declaration
10+
happened to sit at the top of the file donated its comment to a public
11+
document. Adding a helper above the first schema silently rewrote a published
12+
page, and no gate could see it. `check:docs` compares the generated page against
13+
the source and the page reproduced the wrong block faithfully, so there was no
14+
drift to report; the trap was written down when the generator was built and
15+
still landed on `main` twice.
16+
17+
The measured victim surface was **six pages**. The Translation protocol
18+
reference opened with `Shared history sentence for every shape in this file
19+
(#4001).` — this repo's internal tightening-campaign narrative — and the Mapping
20+
page with its sibling. Four more had no history constant anywhere near them:
21+
`api/contract` published the doc of `ApiErrorSchema.code` (a comment *nested
22+
inside* an object literal), `api/realtime` published `Transport Protocol Enum`
23+
on a page documenting fourteen schemas, and `api/protocol` / `kernel/plugin` the
24+
same shape. Any future "move a helper to the top of the file" makes another.
25+
26+
The selection now follows **TSDoc's own rule, read back**: a doc block belongs
27+
to the declaration it immediately precedes — which is exactly the text an editor
28+
shows when you hover that symbol. So a module description must be a block that
29+
30+
- starts at column 0 (a block indented inside a declaration body documents a
31+
property, never a module),
32+
- appears before the first declaration (imports and re-exports introduce no
33+
symbol of their own and do not close the header zone), and
34+
- is not immediately followed by a declaration.
35+
36+
When no block qualifies the page prints no description at all — 宁可缺,不要错.
37+
A confidently rendered internal note is a page that lies about its subject,
38+
which is worse for a reader (and for an AI author working from these pages) than
39+
a page that opens with its `Source:` pointer.
40+
41+
**Twenty reference pages lost an opening paragraph**, each of which was a
42+
symbol's JSDoc rather than the module's: the six above plus `ai/solution-blueprint`,
43+
`ai/tool`, `api/error-code-ledger`, `api/router`, `automation/approval`,
44+
`cloud/template-manifest`, `data/driver-mysql`, `data/driver-postgres`,
45+
`data/driver-sqlite`, `kernel/manifest`, `shared/enums`, `system/doc`,
46+
`system/notification`, `ui/responsive`. **No page lost a real module header**
47+
the other 178 pages with a `Source:` line keep their description byte for byte.
48+
A module that wants its opening paragraph back writes one block that documents
49+
no symbol; 178 sources already do.
50+
51+
The rule is also the gate. The issue proposed failing on first sentences
52+
matching `#\d{3,}` / `Shared history`, but that recognises only the
53+
history-constant subclass, and only after publication — it would have caught two
54+
of the six. A selection rule that cannot pick a symbol's comment makes the whole
55+
class impossible instead. `scripts/lib/file-description.ts` (extracted from
56+
`build-docs.ts`, following `format-type.ts` #4912 and `escape-mdx.ts` #5452) and
57+
its pin suite `scripts/file-description.test.ts` carry it, corpus check included.
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
fix(spec): the reference generator quotes a literal by its `typeof`, so numeric literals stop being written down as strings (#5729)
6+
7+
`formatType()`'s two literal branches wrapped every value in `'…'` without
8+
looking at its type — `prop.enum.map(e => `'${e}'`)` and
9+
`return `'${prop.const}'`` — so a **numeric** literal union was printed as a
10+
**string** one. `FormSection.columns` is the specimen the issue was filed from:
11+
it is declared
12+
13+
```ts
14+
z.union([z.enum(['1', '2', '3', '4']), z.literal(1), z.literal(2), z.literal(3), z.literal(4)])
15+
```
16+
17+
and `content/docs/references/ui/view.mdx` rendered it as
18+
`Enum<'1' | '2' | '3' | '4'> | '1' | '2' | '3' | '4'`, where the second half is
19+
those four `z.literal(<number>)` variants. The two halves came out
20+
indistinguishable, so the page claimed the key takes strings only — while the
21+
schema accepts both `2` and `'2'`.
22+
23+
For these pages that is a contract error, not a typo. They are the authoritative
24+
input for an AI author (ADR-0033), and a literal union is a copy-the-spelling
25+
surface: whatever quoting the page shows is what gets written. #5611 paid for it
26+
directly — its `RecordDetailsProps.sections[].columns` was meant to be a numeric
27+
literal union, the generated reference said strings, and the PR abandoned the
28+
shape for `z.number().int().min(1).max(4)` to escape the contradiction. The
29+
generator was defining the contract backwards.
30+
31+
Quoting is now decided per **value**, not per node, because JSON Schema states
32+
member types per value and an `enum` may mix them (`z.nativeEnum({ A: 1 })`
33+
emits a numeric `enum`): strings stay quoted, `number`/`boolean` render bare,
34+
and `null` renders as the keyword.
35+
36+
Eight reference pages change, 11 lines, and in one direction only — quotes are
37+
removed from 18 non-string literals and no string literal loses its:
38+
`api/dispatcher`, `api/errors` (`success: false`), `data/data-engine`
39+
(`sort: Record< string, 1 | -1 >`), `data/driver-nosql` (`projection:
40+
Record< string, 0 | 1 >`), `data/object` (`systemFields` / `stageField: false`),
41+
`security/explain` (`version: 1`), `ui/dashboard` (`filterBindings`) and
42+
`ui/view` (`columns: … | 1 | 2 | 3 | 4`). Every `Enum<'a' | 'b'>` on those
43+
pages is untouched.

.changeset/eighty-months-obey.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
ADR-0087 semantic-migration ledger: register the immediate retirement of the `ctx.user.roles` alias
6+
7+
The `roles` alias on `ActorUser` — the `user` envelope an action body reads as `ctx.user` and an
8+
AI route handler reads as `req.user` — was removed outright in protocol 17 (#6011, runtime half in
9+
PR #6048): no deprecation window, no dual-emit. The retirement had shipped, but the ADR-0087
10+
migration ledger carried no entry for it, so it was invisible to `objectstack migrate meta`,
11+
`spec-changes.json` and the generated upgrade guide — while all three sibling faces of the same
12+
ADR-0090 rename (`data.hookContext.session.roles`, `ui.actionSession.roles`,
13+
`CEL/formula: current_user.roles`) were registered. This adds the missing entry
14+
(`actor-user-roles-to-positions`) and regenerates the artifacts projected from it.
15+
16+
FROM `ctx.user.roles` / `req.user.roles` → TO `ctx.user.positions` / `req.user.positions`. The
17+
value is unchanged: both keys were filled from one assignment (`roles: core.positions`), so this is
18+
a pure key rename. Fix: rewrite the read. `roles.includes('admin')` used as an ACCESS CHECK is not
19+
renamed to `positions.includes('admin')` — ask the security service instead (ADR-0095); renaming
20+
that read migrates the defect rather than the code.
21+
22+
Note this face has **no window**, unlike its neighbour `ctx.session.roles` (#5613), which still
23+
dual-emits for one release. `ctx.user.roles` is already absent in 17: a typed body fails `tsc` at
24+
the read, an untyped or sandboxed one silently sees `undefined` — so move the read as you upgrade,
25+
not after. `ctx.user` has never had a spec schema (it is a runtime TS interface), so no
26+
`retiredKey()` tombstone can carry the prescription; this ledger entry and the generated upgrade
27+
guide are the channel.
28+
29+
Spec source change is the registry entry only — no schema, no export, and no authorable key moved.

0 commit comments

Comments
 (0)