Skip to content

Commit 961a4e2

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-4747-integrity-audit-shutdown
2 parents 050c5ba + 8bd437f commit 961a4e2

52 files changed

Lines changed: 3285 additions & 251 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: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
---
2+
"@objectstack/objectql": patch
3+
"@objectstack/platform-objects": patch
4+
---
5+
6+
fix(objectql,platform-objects): 一次启动不能证明它自己随即违反的契约 —— ADR-0104 空库自证改为在本次启动写完数据后下结论 (#4769)
7+
8+
一个全新部署第一次 `pnpm dev` 全绿(130 rows,0 ERROR),**第二次启动开始永久 10 条
9+
ERROR**、10 条种子记录写不进去。数据没变、代码没变,只是重启了一次;被拒的正是首启
10+
自己写进去的数据。
11+
12+
根因不是哪个值算错了,是**顺序反了**`sys_migration` 里那两行
13+
(`adr-0104-file-references` / `adr-0104-value-shapes`)带着
14+
`{"attested":"datastore-created-empty"}` 写在 `kernel:ready`,而同一次启动的 seed
15+
还在往里写行。「空库 ⇒ 没有历史值」这个推理成立的前提是**没有数据可写**,而它恰恰
16+
写在即将写入 130 行之前 —— 证明落笔那一刻是真的,一秒之后就不是了。于是首启在
17+
warn-first 下把数据留下,之后每一次启动读到这张证书、进入 strict、拒掉前任写下的
18+
那批行。
19+
20+
## 改了什么
21+
22+
**证书必须覆盖它所声称的那批数据。**
23+
24+
- **写入时机**:新库自证改为在**本次启动自己的数据落定之后**进行 ——
25+
`app:seeded`(inline seed 结算点,含超出 `OS_INLINE_SEED_BUDGET_MS` 后台跑完的
26+
那一半),不 seed 的 kernel 仍由 `kernel:ready` 兜底。两条路径进的是同一个幂等
27+
调用。
28+
- **写入前提**:`attestFreshDatastore` 先问引擎「这次启动放行过违反该契约的值吗」。
29+
引擎在 warn-first 放行每一个不合形状的值时,用**与 strict 模式完全相同的判定**
30+
它记下来 —— 证明干净需要扫全库,证伪只需要一个反例,而这个反例写路径已经算出来
31+
了。任一条被本次启动证伪的迁移 id **不再自证**,部署维持 warn-first(真实且可
32+
恢复),并在日志里指名是哪个 `对象.字段` 让这道闸没关上、该跑哪条 `os migrate`
33+
两行一起改:`adr-0104-file-references``adr-0104-value-shapes` 各自独立判定,
34+
一个 `cover` 不合形状不牵连 `location`,反之亦然。
35+
- **写入之后**:证书若在签发之后被本次启动推翻(操作员显式开了
36+
`OS_ALLOW_LAX_MEDIA_VALUES` / `OS_ALLOW_LAX_VALUE_SHAPES`,或后台 seed 收尾晚于
37+
签发),引擎**撤销**它 —— `verified_at` 清空、`blocking` 记上、`details` 保留原
38+
`attested` 并补一条 `revoked`。只针对**本次启动亲手创建的库**上的自证行:扫过全
39+
库的真实迁移证据不会被一次写入的观察推翻。
40+
41+
**记忆化的第二张脸也一并修了。** 首启之所以「看起来是绿的」,一半靠的是进程内正好
42+
缓存了 `false``sys_migration` 在 kernel init 期间才注册,而第一条写可能赶在它之
43+
前 —— 那次读根本没读到账本,却被当成结论冻结了一整个进程的姿态。现在区分两种否定:
44+
**问过了、账本说不**(结论,照旧缓存)与**根本问不到**(未注册 / 查询抛错 —— 依旧
45+
`false`,闸依旧关着,但不记住,下一次写再问一次)。代价是账本存在之前每次写多一
46+
次 registry 查表(在任何查询之前就短路),账本可读之后即止。
47+
48+
启动横幅那条 ADR-0104 建议行(`kernel:bootstrapped`)也改为直接读账本而非读记忆化
49+
结果 —— 否则一个刚刚自证成功的新部署会被告知去跑一条已经不需要跑的迁移。
50+
51+
## 对既有部署的影响
52+
53+
- 数据本来就合规的新部署:行为不变,照旧 born-migrated,启动即 strict。
54+
- 种子数据不合规的新部署:**不再**发出那张假证书。首启与之后每一次启动一致地停在
55+
warn-first,并且每次都告诉你是哪一个值、跑哪条命令。数据本身该怎么修还是怎么修
56+
(showcase 的 `cover` 种子值在 #4774 单独跟踪)。
57+
- 已经跑过 `os migrate … --apply` 的部署:完全不受影响 —— 扫描得来的证据不经由本
58+
次改动的任何路径改写。
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
---
2+
"@objectstack/plugin-auth": patch
3+
---
4+
5+
fix(plugin-auth): 每号码 OTP 发送预算改用惰性解析的共享计数存储 —— 多节点下不再按节点数倍增 (#4790)
6+
7+
#2780 的「每号码 OTP 发送预算」(60s 冷却 + 每小时 5 条)此前**只有宿主显式提供
8+
better-auth `secondaryStorage` 时才跨节点共享**`AuthManager.getOtpSendGuard()` 唯一的
9+
存储来源就是 `AuthManagerOptions.secondaryStorage`,而标准 `serve` 组合里没有任何一处
10+
提供它(#4788 之后 `AuthPlugin` 也明确不再从 cache 服务派生它)。于是预算落在**每个进程
11+
一份**:N 个节点的部署,一个号码实际能收到的是声明值的 N 倍,而且**没有任何信号**告诉你
12+
它没兑现(ADR-0049 声明 ≠ 强制)。这里的计价单位是**真金白银的短信**
13+
14+
这是 #4772 那条限流洞的同类,但是独立的一处:#4788 修的是 better-auth 自己的 `rateLimit`
15+
计数器(走 `rateLimit.customStorage`),OTP 预算是 ObjectStack 在 `AuthManager` 里自己实现
16+
的另一套计数,行为未被 #4788 改变。
17+
18+
**修法:复用 #4788 建好的那条路径,而不是再写一份。** `rate-limit-storage.ts` 中把「惰性
19+
解析 → 绑定即宣告 → 解析不到就降级到有界的进程内存储并响亮告警」抽成
20+
`createLazyCounterStore()``createLazyCacheRateLimitStorage()` 现在就是它的一层薄封装),
21+
OTP 预算经由新的 `AuthManagerOptions.sharedCounterStore` 接同一条路径:
22+
23+
- **存储在每次发送校验时才解析**,因此 `CacheServicePlugin` 晚于 `AuthPlugin` 注册也照样
24+
绑定得上(插件启动顺序不再决定任何事)—— 这正是 #4772 冻结结论造成的那个洞;
25+
- 配了 cache 的多节点部署,每号码预算**现在真的是一份**,换节点不会重新获得冷却额度;
26+
- 没有 cache 服务的部署**仍然限额**,只是降级为进程内计数,并在第一次真正计数时打一条
27+
点名代价的 warn(「an N-node deployment can send up to N× the configured number of PAID
28+
SMS to one number」)—— 降级不是关闭,两种情况在日志里可区分(绑定打 info,降级打 warn)。
29+
30+
**刻意不引入 `secondaryStorage` 来修它**#4785):那会把会话的记录之处搬进缓存,静默废掉
31+
ADR-0069 D4 的三个会话管控。宿主自己提供的 `secondaryStorage` 对这个预算仍然优先且行为不变。
32+
33+
冷却与滚动小时窗的语义**未做任何改动**:计数依旧是按号码的时间戳滚动窗口,只是换了它所在的
34+
存储。(固定窗口计数器无法表达「距上一次发送满 N 秒」,把它改成定窗会在窗口边界放行两倍突发
35+
——用一种倍增换另一种倍增。)
36+
37+
对使用者的影响:
38+
39+
- 新增 `AuthManagerOptions.sharedCounterStore``AuthPlugin` 自动填充,一般宿主无需感知;
40+
- 新增导出 `createLazyCounterStore()``counterStoreFromKv()`
41+
- `OtpSendGuard` 新增 `resolveStore` 选项,原有的 `storage`(字符串 KV)选项保持可用。
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
---
2+
'@objectstack/objectql': minor
3+
---
4+
5+
**A declarative hook `condition` can now express a TRANSITION: the CEL scope binds `previous` alongside `record` (#4784).**
6+
7+
The condition gate evaluated against a single root — `{ record }`. Both published skill
8+
docs, however, taught the `previous` form: `objectstack-formula` §5 ("Update hook
9+
condition — `previous` vs `record`") gives
10+
`P\`previous.status != 'escalated' && record.status == 'escalated'\``, and its legacy
11+
migration table maps `OLD.x``previous.x` and `ISCHANGED(x)``previous.x != record.x`.
12+
Written into a hook, any of those aborted the expression with `No such key: previous`,
13+
which the gate swallowed into `false` — the hook simply never ran, leaving one WARN line.
14+
Declared ≠ delivered.
15+
16+
It became load-bearing with #4770. `record` now means the record's **state** (stored ⊕
17+
payload), so `record.done == true` is true on *every* update of an already-done row — not
18+
only the one that completed it. `showcase_audit_task_completion`'s own description says
19+
"after a task transitions to done", and there was no way to write that. Now there is:
20+
21+
```ts
22+
condition: P`previous.done != true && record.done == true`
23+
```
24+
25+
`previous` is built exactly as the validation side builds it (#4649), through the shared
26+
`materializeDeclaredFields` helper, so one CEL expression means one thing on both
27+
surfaces:
28+
29+
- **the stored pre-write row**, made **total over the object's DECLARED fields** — a
30+
column the driver never returned reads as `null` instead of aborting the expression;
31+
- **declared fields only**`previous.dnoe` stays unevaluable, so a typo is still
32+
reported rather than quietly answered;
33+
- **copied, never mutated in place.** `ctx.previous` is the engine's own pre-image object,
34+
observed by every after-hook; the materialised `null`s do not leak into it.
35+
36+
**Where `previous` is NOT bound** — verbatim the rule `validation/rule-validator.ts`
37+
already applies, so referencing it there makes the condition unevaluable:
38+
39+
- **insert events** (`beforeInsert` / `afterInsert`) — there is no prior state. Write
40+
insert conditions over `record` alone.
41+
- **predicate (`multi: true`) bulk updates** — one write matches N rows and the hook fires
42+
once, so there is no single prior record. Binding `{}` or `null` would answer
43+
`previous.x == null` with a fabricated fact about rows nobody read.
44+
45+
**Cost: none.** No new demand-driven fetch was introduced. `previous` rides on the prior
46+
row `engine.update` already reads whenever an afterUpdate hook is registered — the same
47+
one that feeds `ctx.previous` and record-change flow triggers. A condition that never
48+
mentions `previous` reads nothing extra, pinned by test.
49+
50+
**What you may see after upgrading:** hooks whose condition referenced `previous` never
51+
fired before and start firing now. That is the declaration finally being honoured — review
52+
any hook carrying a `previous.*` condition before you upgrade.
53+
54+
**Unchanged, deliberately:** a condition that is *still* unevaluable is logged at WARN and
55+
treated as `false`. Whether that should fail loudly instead is tracked separately.
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
---
3+
4+
chore(i18n): drop the undeclared `name:` key from all nine `scripts/i18n-extract.config.ts`
5+
6+
Releases nothing — build-time-only extract fixtures (`scripts/` is not in any
7+
package's published `files`), no runtime or published behaviour changes.
8+
9+
Every one of the nine extract configs opened its `defineStack({ … })` with a
10+
`name:` that the stack schema does not declare, so `ObjectStackDefinitionSchema`
11+
dropped the value at load and the #4167 unknown-stack-key lint reported it —
12+
once per package, on every `pnpm check:i18n` run, in a run that was otherwise
13+
fully green:
14+
15+
```
16+
defineStack: stack.name: 'name' is not a declared stack key, so its value is dropped at load — did you mean 'pages'?
17+
```
18+
19+
The lint was right and the configs were wrong: nothing has ever read a stack's
20+
top-level `name``os i18n extract` receives the *parsed* `defineStack` result,
21+
from which the key is already gone — so the nine values were inert. The fix is
22+
at the producer (#4736 decision A: delete the nine keys), not a new authorable
23+
key in `packages/spec` to accommodate one typo copied nine times.
24+
25+
Extraction output is unchanged: after the deletion a full
26+
`node scripts/check-i18n-bundles.mjs --write` regenerates all 40 bundles across
27+
the nine packages with a byte-identical result, and `pnpm check:i18n` stays
28+
green — now without the warning.
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
---
2+
"@objectstack/metadata-protocol": minor
3+
---
4+
5+
fix(metadata-protocol): `deleteMany` / `updateMany` honour `atomic` for real, or refuse it (#4620)
6+
7+
ADR-0119 D4 made `batchData`'s `atomic` flag a real guarantee. Its two siblings
8+
in the same file were out of that PR's confirmed scope and kept the defect:
9+
10+
- **`deleteManyData` was fake-atomic.** `atomic: true` opened no transaction; it
11+
only `break`-ed the loop, so every row deleted before the failure stayed
12+
**deleted** while the response called itself atomic and reported those rows
13+
`success: true`. Worse than the `batchData` case it was copied from, because a
14+
partial delete has no natural undo — a client cannot reconstruct the rows from
15+
its own request.
16+
- **`updateManyData` ignored `atomic` entirely.** The option was accepted,
17+
declared in `BatchOptionsSchema` with an all-or-nothing contract, and never
18+
read: a caller asking for atomicity silently got best-effort, with no signal.
19+
20+
Both now run the **same** atomic arm as `batchData`, extracted into one shared
21+
runner so a fourth copy of transaction handling cannot drift into a fourth lie:
22+
23+
- `atomic: true` runs the whole batch inside ONE `engine.transaction()`; the
24+
first failure rolls back every prior write.
25+
- A rolled-back batch reports **zero successes**. Rows that had succeeded are
26+
marked `ROLLED_BACK: record <i> failed — <cause>`, rows never reached are
27+
`NOT_ATTEMPTED: atomic batch aborted by record <i>`, and the causal row keeps
28+
its own error — so a client can tell "attempted, undone" from "never ran".
29+
- `atomic` outranks `continueOnError`, whose contract text already scoped it to
30+
`atomic=false`.
31+
32+
**Behaviour change to be aware of:** a runtime that cannot roll back (no
33+
`engine.transaction()`, or a default driver without `beginTransaction`) now
34+
**refuses** an `atomic: true` `deleteMany` / `updateMany` with `501
35+
NOT_IMPLEMENTED` instead of silently running best-effort — the same fail-closed
36+
gate `batchData` uses. That silent downgrade is the defect class this fixes; if
37+
you want best-effort, ask for it (`atomic: false`, or omit the option), or probe
38+
the runtime's transaction support before sending. Non-atomic behaviour of both
39+
endpoints — including the `continueOnError` interaction and their response
40+
shapes — is unchanged.
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
---
2+
"@objectstack/spec": major
3+
---
4+
5+
BREAKING(spec): `@objectstack/spec/kernel` 改名 `PackageDependencySchema``ResolvedPackageDependencySchema`;裸名 `PackageDependency(Schema)` 现在全包唯一地指 `@objectstack/spec/cloud` 的清单声明形 (#4741, #4535 C7)
6+
7+
`PackageDependency` / `PackageDependencySchema` 曾由 `./cloud``./kernel` 各自导出一个声明 —— 同名、键集**完全不相交(0 个共享属性)****两个概念**(#4411 陷阱):
8+
9+
| 入口 | 键集 | 语义 | 处置 |
10+
|:--|:--|:--|:--|
11+
| `./cloud`(**保名**) | `packageId` / `versionRange` / `optional` | **声明形**:作者写进包清单的依赖行,嵌在 `PackageManifestSchema.dependencies[]``sys_package_version.manifest_json` | 裸名唯一归属 |
12+
| `./kernel`(**改名**) | `name` / `versionConstraint` / `type` / `resolvedVersion` | **解析形**:依赖解析器在图上走的边,嵌在 `DependencyGraphNodeSchema.dependencies[]`,并经 `PluginSecurityProtocol` 发布给 SBOM / 冲突报告 |`ResolvedPackageDependencySchema`,字段与校验逐字不变 |
13+
14+
两边都不是 `.strict()`,所以把一侧的文档粘到另一侧时**不会响亮报错,只会静默剥掉全部外来键**(ADR-0104 silent-strip 类)—— 这正是共用一个名字所掩盖的失败模式,也是本次不留任何别名的原因。
15+
16+
## FROM → TO
17+
18+
```ts
19+
// FROM —— 编译期起以 TS2305 失败
20+
import { PackageDependencySchema, type PackageDependency } from '@objectstack/spec/kernel';
21+
22+
// TO —— 同一声明、同一形状,名字点明它是「解析结果」而非「清单声明」
23+
import {
24+
ResolvedPackageDependencySchema,
25+
type ResolvedPackageDependency,
26+
} from '@objectstack/spec/kernel';
27+
```
28+
29+
运行时命名空间对象同步改键:`PluginSecurityProtocol.PackageDependency``PluginSecurityProtocol.ResolvedPackageDependency`(指向同一 schema)。
30+
31+
**要的是清单里写的依赖声明?** `import { PackageDependencySchema, type PackageDependency } from '@objectstack/spec/cloud'` —— 本次未动其形状,仅补了一段互指 docblock。
32+
33+
**受影响面实测**:`objectstack` / `cloud` / `objectui` 三仓 import 级扫描,`./kernel` 侧零外部 importer(唯一读者是同文件的 `DependencyGraphNodeSchema``PluginSecurityProtocol`),预期无人受影响。零 importer 不等于有死侧可删(#4653 判则),故走改名而非删除。
34+
35+
不保留旧名别名:在 `./kernel` 上 re-export 任何一侧的 `PackageDependencySchema` 都会重开本次关闭的陷阱 —— 要么复活双源,要么把清单声明形谎报成解析器合法输入(承接表不变式 3 会在 build 阶段直接拒绝这条路线)。
36+
37+
## 零元数据迁移、零形状变更
38+
39+
本次只动 TS 导出名与内部 JSON Schema def 名(`kernel/PackageDependency``kernel/ResolvedPackageDependency`,走 `RENAMED_DEFS` 承接表,**4 keys carry**:`name` / `versionConstraint` / `type` / `resolvedVersion` 在新 def 名下逐个健在)。
40+
41+
- **无字段增删、无类型变更、无词表变化** —— 两个 schema 的 body 一字未改(对照 C10 的 checksum 对象→字符串、C16 的 3→5 词表拓宽:本簇均不适用)。`type``.default('required')`、cloud 侧 `optional``.default(false)` 都原样保留,并由新增的 pin 用 `parse` 实测钉住(#4666 默认值盲区的自卫)。
42+
- **无 tombstone**:tombstone 的前提是有 key 退役;本次 4 个 key 全数承接,一个都没离开契约,伪造 tombstone 会污染 ADR-0087 登记(`renamed-defs.ts` 头注明列的第 2 种错误处置)。已按 #4767`retiredKey()` 先例逐条评估后排除,非沉默跳过。
43+
- **无 ADR-0087 conversion**:没有作者路径发生位移,注册迁移等于让消费者跑一次不该跑的转换。
44+
- 发布的 JSON Schema `$id` 随之移动:`…/kernel/PackageDependency.json``…/kernel/ResolvedPackageDependency.json`
45+
46+
⚠️ 同前缀近邻 `PackageDependencyConflict(Schema)``PackageDependencyResolutionResult(Schema)`**不同概念**,一字未动,并由 pin 显式断言健在。

content/docs/references/cloud/meta.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@
1414
"marketplace",
1515
"marketplace-admin",
1616
"---Tenancy & Security---",
17-
"plugin-security",
1817
"tenant"
1918
]
2019
}

content/docs/references/cloud/package-version.mdx

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,8 @@ See `docs/adr/0003-package-as-first-class-citizen.md` for the full rationale.
3030
## TypeScript Usage
3131

3232
```typescript
33-
import { CreatePackageVersionRequestSchema, PackageManifestSchema, PackageVersionSchema, PackageVersionStatusSchema, PublishPackageVersionRequestSchema, UpdatePackageVersionRequestSchema } from '@objectstack/spec/cloud';
34-
import type { CreatePackageVersionRequest, PackageManifest, PackageVersion, PackageVersionStatus, PublishPackageVersionRequest, UpdatePackageVersionRequest } from '@objectstack/spec/cloud';
33+
import { CreatePackageVersionRequestSchema, PackageDependencySchema, PackageManifestSchema, PackageVersionSchema, PackageVersionStatusSchema, PublishPackageVersionRequestSchema, UpdatePackageVersionRequestSchema } from '@objectstack/spec/cloud';
34+
import type { CreatePackageVersionRequest, PackageDependency, PackageManifest, PackageVersion, PackageVersionStatus, PublishPackageVersionRequest, UpdatePackageVersionRequest } from '@objectstack/spec/cloud';
3535

3636
// Validate data
3737
const result = CreatePackageVersionRequestSchema.parse(data);
@@ -55,6 +55,21 @@ Create a new draft package version
5555
| **createdBy** | `string` || User ID creating this version |
5656

5757

58+
---
59+
60+
## PackageDependency
61+
62+
Package dependency declaration
63+
64+
### Properties
65+
66+
| Property | Type | Required | Description |
67+
| :--- | :--- | :--- | :--- |
68+
| **packageId** | `string` || Manifest ID of the dependency |
69+
| **versionRange** | `string` || Semver version range (e.g. ^1.0.0) |
70+
| **optional** | `boolean` || Whether this dependency is optional |
71+
72+
5873
---
5974

6075
## PackageManifest

0 commit comments

Comments
 (0)