Skip to content

Commit a00bdbb

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-6243-description-truth-sweep
2 parents 367a39b + 2598216 commit a00bdbb

108 files changed

Lines changed: 2284 additions & 657 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: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
---
2+
"@objectstack/spec": major
3+
---
4+
5+
feat(spec): action param 的 `options[]` 讲得出逐选项 `visibleWhen` —— 一个接好线却被门挡着的门控能力 (#5016)
6+
7+
`ActionParamSchema.options[]` 的契约一直是 `{ label, value }`,#4001 批 14 把它从「靠删除来执行」改成了说出口。批 14 同时记下一个它不打算猜的能力问题:这个选项列表该不该讲 `SelectOptionSchema`(`data/field.zod.ts`)已经声明的那套逐选项词汇?#5016 逐键量了一遍,答案**不是整套照搬,而是一个键**
8+
9+
## 只开 `visibleWhen`,因为只有它有读者
10+
11+
|| 声明在 | action param 选项这条路上的消费者 | 本次 |
12+
|:--|:--|:--|:--|
13+
| `visibleWhen` | `SelectOptionSchema` | **** —— 四个选项控件全都经 `useCascadingOptions``resolveCascadingOptions` 按它过滤(ADR-0058 / objectui#2284) | **开放** |
14+
| `color` | `SelectOptionSchema` | 无 —— 只有**已存值**的展示渲染器读(网格单元格 / 详情徽章);对话框只拿列表建输入控件,提交完就丢 | 继续拒绝,附指路 |
15+
| `default` | `SelectOptionSchema` | 无,且是**层级写错** —— 对话框参数的默认值走参数自己的 `defaultValue`,高一层 | 继续拒绝,附改法 |
16+
| `icon` / `disabled` | 仓里任何 spec 形状都没有 | 无 —— 只活在 objectui 内部 `SelectOptionMetadata` 接口里,四个选项控件里每一个 `disabled` 都是**字段级**`props.disabled` | 继续拒绝(#5016 的 C 选项未采纳) |
17+
18+
挡在作者和一个**能工作**的逐选项门控之间的,此前就只有 spec 这道门:内联参数的 `options` 是逐字下沉的(objectui `resolveActionParam` 内联分支 `options: param.options``ActionParamDialog` 逐条 spread 只翻译 `label``paramToField` 原样交给控件),而 `ExpressionInputSchema` 产出的 `{ dialect, source }` 信封正是 `evalFieldPredicate` 接受的形状。
19+
20+
## 行为激活面 —— FROM → TO
21+
22+
**这是本次最需要注意的一行:同一份元数据,以前写了等于没写,现在真的生效。**
23+
24+
```diff
25+
params: [{
26+
name: 'severity', type: 'select',
27+
options: [
28+
{ label: 'Normal', value: 'normal' },
29+
{ label: 'Overload', value: 'overload',
30+
visibleWhen: "record.status == 'open'" },
31+
],
32+
}]
33+
```
34+
35+
| 版本 | 上面这份 metadata 的下场 |
36+
|:--|:--|
37+
| 16.x | parse **成功**,出来的是 `{"label":"Overload","value":"overload"}` —— `visibleWhen` 在任何渲染器看到它之前就被静默剥掉,选项**永远可选** |
38+
| 17.0.0-rc(#4001 批 14 起) | parse **失败**,`unrecognized_keys` 明确报错 |
39+
| 17.0.0(本次) | parse 成功,键**保留并生效** —— `record.status != 'open'` 时该选项**不再出现在下拉里** |
40+
41+
所以从 16.x 升上来的应用,如果曾经推测性地写过逐选项 `visibleWhen`(当时无害,因为它被丢掉了),升级后选项集会**变窄**。请复查这些谓词是否是你今天真正想要的:不想要就删掉键,想要就确认表达式对 `record` / `current_user` 求值的结果符合预期。`color` / `icon` / `disabled` / `default` 在 16.x 同样被静默剥掉,本次**不会**突然生效 —— 它们改为在 publish 时响亮拒绝,并各自指向该词汇真正生效的地方。
42+
43+
⚠️ **客户端隐藏是 UX,不是授权。** `enforceActionParams` 按声明的选项****校验提交(ADR-0104 D2),它不求值逐选项 `visibleWhen`;因访问控制而屏蔽的选项必须由 action 自身的 body 或权限检查再拒一次,只把它从下拉里藏掉是可绕过的。
44+
45+
## 本次**没有**修的一件事(objectui 侧,已另行记录)
46+
47+
**字段回退那条路仍然丢键**:`resolveActionParam` 走的是 `param.options ?? normaliseOptions(field.options, …)`,而 `normaliseOptions` 把每个**继承来的**条目重建成 `{ label, value }`。这条丢弃早于本次改动、也不受本次影响(作者显式写的 `options` 数组优先级更高,压根不经过它),修复归属 objectui。因此本次的拒绝文案仍然刻意****开「把参数改成 field-backed 去继承」这张药方 —— 那是一条不存在的路(账本 finding 18:错误里的文案是行为,自信而错的处方比没有更糟)。
48+
49+
`bulk-action.zod.ts``.passthrough()` 特例维持不动:#4909 那两条理由(逐字到达 grid、objectui `BulkActionParam` 有显式 `[key: string]: unknown` 兜底)在这条路上都不成立,而这里的目标词汇是封闭的 —— 目标词汇封闭,正是「声明」胜过「容忍」的场合。
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
---
2+
"@objectstack/runtime": patch
3+
---
4+
5+
fix(runtime): `callData` no longer has a `batch` arm that answers a silent, empty success (#5856)
6+
7+
`callData`'s `action === 'batch'` arm returned `{ object, results: [] }` — an
8+
HTTP 200 whose body a consumer cannot tell apart from "the batch ran and matched
9+
nothing" — while opening no transaction and writing nothing. It was the only arm
10+
in that function answering an unimplemented action with **success**: every other
11+
unhandled action throws `400 Unknown data action: …`, and `aggregate` throws
12+
`503` when the engine cannot serve it. Retry, idempotency and audit logic all
13+
read a 200 + empty result set as one successful empty operation.
14+
15+
Nothing could reach it, and that is the point: its safety lived **upstream**, in
16+
a route table that happens not to spell `batch`, not in any guard of its own —
17+
the ADR-0115 Evidence 5 / #4451 shape, where one route-table extension silently
18+
turns a dormant branch into a live "successfully did nothing". Every entry point
19+
was enumerated before removal (`/data` compares `parts[1]` against the literal
20+
`'query'` and otherwise reads it as a record id; the MCP bridge, the actions
21+
domain and `invokeBusinessAction` pass literals; the declarative endpoint
22+
executor is bounded by `ApiEndpointSchema.objectParams.operation`, a closed enum
23+
of find/get/create/update/delete; and `callData` is not part of this package's
24+
export surface), so the arm is removed under ADR-0049 enforce-or-remove rather
25+
than converted to a 501 nobody would ever receive.
26+
27+
**Behaviour on every live path is unchanged** — no reachable request produced
28+
that response. What changed is the answer waiting for the first caller who ever
29+
does spell `batch`: a loud `400 Unknown data action: batch`, identical to any
30+
other unknown action, instead of a silent success. Batching itself is untouched
31+
and keeps its single owner: `@objectstack/rest`'s `registerBatchEndpoints`
32+
mounts both `POST /batch` (atomic, cross-object) and `POST /data/:object/batch`
33+
(per-object, ADR-0119) — which is exactly why a host serving only the
34+
dispatcher reports `capabilities.transactionalBatch: false` (#5672).
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
---
2+
"@objectstack/spec": major
3+
---
4+
5+
fix(spec)!: `composeStacks``i18n` 退出 last-wins —— 同值放行、冲突报错带处方 (#5051)
6+
7+
#5005`composeStacks` 的非数组顶层键统一成「同值放行 / 冲突报错」,并由维护者
8+
2026-08-04 裁决点名否掉了 last-wins(**先声明的栈被后组合者无声覆盖**)。`i18n`
9+
唯一逃过那一轮的键 —— 因为 #5005 的主题是**被丢掉**的键,而 `i18n` 本来就有一个能
10+
工作(只是无声)的策略。于是它成了整个顶层键面上**仅剩的一个 last-wins**
11+
2026-08-06 裁决取 A(对齐),2026-08-07 窗口拉入 v17。
12+
13+
## FROM → TO(行为激活面)
14+
15+
| 场景 | FROM(v17 之前) | TO(本次) |
16+
|:--|:--|:--|
17+
| 只有一个栈声明 `i18n` | 保留该声明 | **不变** |
18+
| 多个栈声明**相同**`i18n` | 保留(数组里最后那个,值相同) | **不变**(同值放行) |
19+
| 多个栈声明**不同**`i18n` | 数组里靠后的栈胜出,**靠前那个栈的声明被静默丢弃** —— 不报错、不告警,作者无从分辨「被覆盖」与「从没写过」 | **抛错**,点名冲突键 `i18n`、两个来源栈(manifest id,无 manifest 时用 `stack #N`)与两条出路 |
20+
21+
被静默丢弃的那一侧,过去从组合结果里彻底消失;现在它不再消失 —— 它以一条点名两个
22+
来源栈的错误的形式出现,由作者决定保留哪一个。这就是本次「静默丢弃 → 不再丢弃」的
23+
激活面:凡是今天**已经**在依赖 last-wins 覆盖语言配置的组合,升级后会在
24+
`composeStacks` 处响亮地停下。
25+
26+
## 为什么本地化配置不能择一,也不能深合并
27+
28+
一个栈的 `translations` 语言包是**照着它自己声明的 `supportedLocales` 写的**。让附加
29+
包的 `i18n` 胜出,等于让基础栈的语言包去寻址一组组合后的应用不再承认的 locale ——
30+
运行时看到的只是「查不到翻译」,而不是「你的语言配置被覆盖了」。深合并同样出局
31+
(#5005 原裁决):它造出一个两位作者都没写过的第三种 locale 集合。
32+
33+
仓内实测:`examples/app-crm`(`['en','zh-CN']`)与 `examples/app-todo`
34+
(`['en','zh-CN','ja-JP']`)正是这一对 —— `defaultLocale` 一致,locale 集合不一致。
35+
36+
## 迁移
37+
38+
组合时报 `composeStacks conflict: top-level key 'i18n' …`,按错误信息里的处方二选一:
39+
40+
```ts
41+
// A. 让两处声明一致(通常是取并集,由作者决定)
42+
i18n: { defaultLocale: 'en', supportedLocales: ['en', 'zh-CN', 'ja-JP'], fallbackLocale: 'en' }
43+
44+
// B. 只在应当拥有它的那个栈里声明 `i18n`,其余栈删掉这一段
45+
```
46+
47+
`ComposeStacksOptionsSchema` **不加旋钮**:显式覆盖机制按 #5005 裁决,留给定制故事真
48+
拉动时统一设计,不为单个键预支。数组键(含 `translations`)的拼接语义一字未变。
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
"@objectstack/rest": patch
3+
---
4+
5+
fix(rest): dashboard 组件门禁在默认配置下真正执行 (#5881)
6+
7+
ADR-0057 D10 的 `requiresService` 组件门禁 —— 剔除指向未注册可选服务的 dashboard
8+
磁贴 —— 在默认部署里一次都没跑过。`GET /meta/:type/:name` 的单条读取有一条缓存分支,
9+
它排除了 `app`(per-user RBAC 过滤)与 `doc` / `book`(per-caller audience),唯独没有
10+
排除 `dashboard`;而 `enableCache` 默认为 `true`。门禁写在非缓存分支里,于是只有显式
11+
关掉缓存的部署才会执行到它。
12+
13+
后果正是该 ADR 点名要防的那一幕:在没有某个可选服务的部署里(比如单租户运行时里的
14+
Organizations KPI,其 `org-scoping` 服务不存在),console 会渲染一块绑定到缺失服务的
15+
死磁贴 —— 尽管服务端的门禁代码在、测试也在。
16+
17+
**修复**:`dashboard``app` 同款,从缓存分支排除,两种拼写(`/meta/dashboard/x`
18+
与规范复数 `/meta/dashboards/x`)都覆盖。其它元数据类型的 ETag 快路径不受影响。
19+
20+
**为什么不是"把门禁提到分支之外、两条路径共用"** —— 那读起来更整齐,但 ETag 无法承载
21+
门禁结论:validator**未过滤文档**的哈希,而 `notModified` 在 protocol 内部就已判定,
22+
REST 层没有机会重判。共用之后送出的就是"过滤过的正文 + 指向未过滤正文的 validator"。
23+
一次 boot 之内这没有危害(已注册服务集在 bootstrap 之后不可变),但 `Cache-Control:
24+
private, no-cache` 意味着客户端**存下正文**、之后只做重验证,而存下的正文比进程活得久:
25+
一次关掉该可选服务的重新部署并不改变文档,ETag 不变 ⇒ 每次重验证都回 304 ⇒ 那块死磁贴
26+
恰好在移除其服务的那次部署之后被永久缓存下来。放弃快路径的代价则接近于零:
27+
`getMetaItemCached` 本就委托给 `getMetaItem`,服务端两条路做的是同样的工作,失去的只是
28+
304 省下的正文字节。
29+
30+
对调用方的可见变化:dashboard 的单条读取不再返回 ETag / 304,每次都是完整的 200。
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
"@objectstack/driver-memory": major
3+
"@objectstack/driver-mongodb": major
4+
"@objectstack/driver-sql": major
5+
"@objectstack/driver-sqlite-wasm": major
6+
"@objectstack/driver-turso": major
7+
---
8+
9+
refactor(drivers)!: 五个驱动的 query 参数跟进 `DriverQuery`,休眠的类型谎言就此没有藏身处 (#6075)
10+
11+
#5181(PR #6076)把 `IDataDriver.find/findOne/count/updateMany/deleteMany/explain` 的 query 参数收窄为 `DriverQuery``Omit<QueryAST, 'object'>`),并在同一条 changeset 里写明:「把驱动签名一并迁到 `DriverQuery` 是后续的机械收尾」。这就是那次收尾。
12+
13+
在此之前,五个驱动的实现仍旧声明 `query: QueryAST`(turso 侧是 `query: any`)。**它不红,也不会红** —— 方法参数按双变比较,实现声明得比契约宽照样满足契约。但调用方现在**有权**省略 `object`,于是这些实现的类型说 `query.object``string`,运行期却可能是 `undefined`:一句休眠的谎言,没有任何门拦得住下一个照着它写代码的人。
14+
15+
收尾之后,「驱动读 `query.object`」直接变成编译错误:
16+
17+
```ts
18+
// 收窄前:编译通过,运行期可能是 undefined —— 谎言
19+
// 收窄后:error TS2339: Property 'object' does not exist on type 'DriverQuery'.
20+
const name = query.object;
21+
```
22+
23+
**零运行时改动。** 本次改的全部是类型注解:五个驱动的六个契约方法签名,以及为让类型自洽而必须跟进的少量私有辅助方法参数(mongodb 的 `buildFindOptions` / `buildSortSpec`,sql 的 `findRows` / `orderKeysFor`,turso 的 `toRemoteQuery` / `toRemoteReadQuery`,memory 的 `performAggregation`)—— 它们都只转发或读取 `where` / `orderBy` / `groupBy` 这些字段,本来就不读 `object`。turso 的几处 `query: any` 一并收紧,多拿回一批本已放弃的检查。emit 无差异,测试全绿(memory 524、mongodb 206、sql 906、sqlite-wasm 254、turso 788)。
24+
25+
**迁移面:删掉驱动调用字面量里的 `object:`**,与 #5181 是同一句话,只是现在也覆盖了直接按具体驱动类(`SqlDriver` / `MemoryDriver` / …)而非按 `IDataDriver` 取类型的调用方。编译器会逐处指出来(TS2353 `'object' does not exist in type 'DriverQuery'`)。本仓下游 25 个包实测零处需要改动,改动只落在五个驱动自己的测试里。
26+
27+
标 major 的依据与 #5181 一致:**源码级破坏性**(调用点内联字面量),运行时行为零变化。`check:api-surface` 只记录导出的存在与否、不记录签名,因此这条说明同样是该变更唯一的下游载体。
28+
29+
`aggregate` / `distinct` / `syncSchemasBatch` 不在本次范围内 —— 它们不是 `IDataDriver` 收窄的那六个方法,其中 `syncSchemasBatch` 的条目里 `object` 是被真实读取的必填键,`expand` 条目里的 `object` 同理命名的是关联对象,都不是冗余。
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
fix(spec): 墓碑老化时钟改按**确切 key** 起算 —— 无关簇的登记不再替一次退休提前放行 (#5898)
6+
7+
`scripts/build-schemas.ts` 的检查 (c)(#4650)保证一件事:删掉一条
8+
`authorable-surface/` 基线行必须自带证明 —— 那个文件正是检查 (a)/(b) 赖以判定的
9+
证据,行没了证据也就没了。它承认的三种证明里,第一种是「墓碑已老化」:base 里这条
10+
`[RETIRED]`,且它的退休登记比当前 major 至少早 `TOMBSTONE_AGE_MAJORS`(= 2)。
11+
12+
#4659 把检查 (b) 收口到了 `RETIRED_KEYS_BY_MAJOR` 的确切 key,检查 (c) 的同一套
13+
**叶名匹配**原封不动地留了下来:拿 key 的叶名去和**全部 major** 的所有 conversion /
14+
migration `surface` 子句做 `endsWith('.' + prop)`,再取 `Math.min`。两个后果都朝
15+
「放行」的方向 —— 一条无关登记就能让一个从没被登记过的墓碑通过「有没有登记」这一关,
16+
`Math.min` 保证时钟一律从**最早**的那次巧合起算。
17+
18+
实测:当前 97 条历史墓碑里有 **2** 条今天就可删,而且**两条都是误判**,机制还不一样:
19+
20+
- `data/Index:type` 被 protocol 11 的 `flow.node.type` 定了年份 ——
21+
`flow-node-http-callout-rename` 里一个 flow 节点的 `type`,和索引类型毫无关系。
22+
它自己那条诚实的登记 `object.indexes[].type` 是 major 17,时钟被提前了六个 major。
23+
- `api/RestApiConfig:requireAuth` 被 major 12 的 `api.requireAuth` 定了年份 ——
24+
那是 `rest-requireauth-default-flip`,一次**安全默认值翻转**,该 step 自己写着
25+
「No metadata shape changed」。它真正的退休是 protocol 17 的 conversion
26+
`stack.api.requireAuth`(#3963)。同一个 surface,不同**种类**的变更,早了五个 major。
27+
28+
现在检查 (c) 读的是检查 (b) 那张表,按确切 `` `${defKey}:${name}` `` 判定,
29+
`build-schemas.ts` 里再没有任何叶名匹配。
30+
31+
**历史墓碑不回填,并且因此不可删。** 两条可机械推导的来源都无法诚实定年:叶名匹配
32+
正是 #4659 拿掉的那种推断(上面两条误判即为实证);而
33+
`authorable-surface.json` 的 git 历史始于 `17.0.0-rc.0`,把 97 条全部定在 major 17
34+
—— 那是基线文件的**出生日期**,不是考据。所以这些行保持未登记,检查 (c) 对它们
35+
fail-closed:没有条目就无法证明年龄,基线行不许删。可删数从 **2 → 0**(按当前
36+
`authorable-surface/` 的 100 条墓碑口径同样是 2 → 0)。
37+
38+
要删其中某一行,是一次有意的、可复核的动作:确定该 key 真正的退休 major,把确切 key
39+
写进 `RETIRED_KEYS_BY_MAJOR`,由检查 (b2) 复核该条目仍指向一个本次构建确实
40+
tombstone 的 key。⚠ 定不出年份的行不要写 —— 写进这张表的估算,对之后每一道门禁都
41+
读作事实。
42+
43+
导出值本身没有变化;`RETIRED_KEYS_BY_MAJOR` 的文档注释更新为它现在同时被检查 (b)/(b2)/(c)
44+
读取,以及历史墓碑的 fail-closed 口径。

.claude/agents/os-dev.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,32 @@ all real:
206206
forcing the template; #4984 is the family origin — fixtures spelling
207207
rejected aliases kept the tests green while the rule was dead).
208208

209+
**Rejection-class cases assert the envelope, not the throw.** For any case whose
210+
point is that bad input is *refused*, the minimum assertion set is the error's
211+
**`code` AND `status`** (the ADR-0112 envelope). `expect(...).toThrow()` /
212+
`rejects.toThrow()` on its own is not a rejection test: it carries one bit where
213+
the defect has two, and PR #6142 (#6050) measured both ways it goes blind —
214+
opposite directions, same hole:
215+
216+
- **A bare `Error` ⇒ permanently green.** Deleting the new refusal gate turned
217+
22 of `driver-sql`'s 28 cases red, and *most* of those reds were the driver
218+
throwing knex's bare `Undefined binding(s)` — an `Error` whose `code` and
219+
`status` are both `undefined`. The unfixed driver already throws; only the
220+
envelope is missing. A throw-only assertion therefore stays **green on the
221+
very driver the issue targets**.
222+
- **A transport that never throws ⇒ red, but pointing away from the defect.**
223+
The same deletion turned 20 of `driver-turso`'s 29 remote cases red, and all
224+
20 failed by *answering* — that transport never throws. A throw-only
225+
assertion reports "the promise resolved", which names the absence of a throw
226+
and never the absence of an envelope, so it cannot separate "refused with the
227+
wrong envelope" from "did not refuse at all" — and those are exactly the two
228+
defects.
229+
230+
Where the wording is itself contract (#5240, one condition ⇒ one wording),
231+
assert the message's first sentence **on top of** `code`+`status`, never instead
232+
of them. A rejection test that cannot go red on a missing envelope reads as
233+
coverage and is not.
234+
209235
**Key-vs-value reachability criterion.** Match a fixture guard's assertion to
210236
what the rule guards. Guarding that a **key** is a real authoring surface →
211237
assert the schema reports no `unrecognized_keys` on the fixture. Guarding a

0 commit comments

Comments
 (0)