Skip to content

Commit 79ff030

Browse files
os-salesclaude
andcommitted
Merge remote-tracking branch 'origin/main' into claude/issue-4998-seed-summary-stale-loud
Conflict: scripts/check-durability-degradation-log-level.mjs — both sides appended a DURABILITY_CRITICAL_CALLEES entry at the same list position. Resolution keeps BOTH: this branch's `performSeedWrite` (#4998) and #5025's `dropPromotedDraftRow` (#4981), with the seed-loader callees kept adjacent. Verified on the merged state: #5025's seam is unaffected by this branch's tightening of the rethrow rule. Its catch contains no `throw` at all, so `rethrows` is false and the "only an unconditional rethrow excuses the seam" change cannot apply to it — it is judged on log level exactly as before, and reports loud via console.error in draftDrainVerdict(). Gate: 12 seams, all loud or rethrowing, exit 0; self-test 13/13. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NrmBxj8rK2uGCnh9aipjwX
2 parents a2b6e5b + 785a748 commit 79ff030

45 files changed

Lines changed: 5352 additions & 215 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
---
2+
"@objectstack/plugin-approvals": patch
3+
---
4+
5+
fix(approvals): 删除两处读 `session.roles` 的 admin 豁免 —— 记录锁与委托守卫回到单一权限词汇 (#4839)
6+
7+
`plugin-approvals` 的 `lifecycle-hooks.ts` 里有两处 admin 豁免,都读
8+
`ctx.session.roles`:审批**记录锁**的 `bindApprovalLockHook`,以及
9+
`sys_approval_delegation` 的 `bindDelegationWriteGuard`。两处都已删除。
10+
11+
**这不是行为变更。** `session.roles` 在整个平台没有生产者 —— ObjectQL 的
12+
`buildSession()` 逐字段构造 session,从不写 `roles` —— 所以两个分支在任何真实引擎
13+
路径上都是死代码,记录锁一直就对 admin 生效,委托一直就只能本人管理。删除让代码
14+
说出运行时本来就在做的事(spec 的 `HookContext` 声明了 `roles`,消费方在读,生产方
15+
从不写:典型的 declared ≠ enforced)。
16+
17+
**为什么不是「改用正确判据」而是删除。** `roles.includes('admin')` 还是第二套权限
18+
方言:本仓库的权限一律由 ADR-0095 词汇裁决(能力授予 `permissions`、任职
19+
`positions`、由其派生的 posture),ADR-0090 D3 更是直接禁掉 `role` 这个拼法。同包的
20+
`ApprovalService.isOverrideActor` 已经这么做了。维护者裁定两处都取「删除」而非改判据:
21+
22+
- **记录锁**:admin 释放锁定记录的正规路径已经存在(#3424 —— `recall` /
23+
`decideNode` 驳回 / `reassign`,全部由 `isOverrideActor` 把关并留痕
24+
`via_override`)。让审批终结来释放锁,记录就永远不会在审批在途时被改写 —— 这正是
25+
合规场景购买记录锁所要的保证。
26+
- **委托**:最终语义确定为**仅本人管理**(`delegator_id` 必须等于写入者;只有 system
27+
上下文旁路)。审批人临时不可用时,替他处置**在途**审批用的是
28+
`reassign`(把该审批人的名额交给替代人,连 per_group 分组归属一起带过去)/
29+
`recall` / 驳回。反过来,「替别人建一条委托」本来也做不到这件事:委托只在请求
30+
**开启**时(`resolveApproverSpec` 内的 `applyOooDelegation`)被查询,对已经挂在该
31+
审批人名下的在途审批毫无作用。
32+
33+
新增 `admin-exemption-retired.test.ts`,把上述证据变成可执行断言,并加了一道源码级
34+
pin:本包非测试源码中不得再出现 `roles` 标识符或与字符串 `'admin'` 的比较。
35+
36+
spec 侧 `session.roles` 的退役(至此零消费方)按 ADR-0049 enforce-or-remove 另立协议
37+
单处理,不在本次改动内。
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
---
2+
"@objectstack/objectql": patch
3+
---
4+
5+
fix(objectql): a bulk write blocked by a `previous` hook condition is told it is a VERSION limit, not an authoring mistake (#5037)
6+
7+
#4775 made an unevaluable hook `condition` abort the operation, and #4861 gave
8+
the predicate-bulk-write case its own sentence instead of a raw
9+
`Unknown variable: previous`. What that sentence still said was
10+
*"rewrite the condition without `previous`, or target the write at one record"* —
11+
written before the maintainer's 2026-08-04 ruling on #4800/#4862, and wrong in
12+
its most important claim. The ruling settled the contract: **on a bulk write,
13+
after-hooks and record-change flow triggers evaluate and fire per row** (recorded
14+
as an ADR-0058 addendum, implemented by #5038). The author's transition condition
15+
is legitimate; the engine is what is behind. Telling them to drop `previous` was
16+
advising a silent semantic change — a transition ("just became done") becomes a
17+
state test ("is done"), which fires on every row that was already done.
18+
19+
The rejection now says what is actually true:
20+
21+
- it names the batch and why there is no single prior record to bind, as before;
22+
- it states this is a **current-version limitation**, cites the per-row contract
23+
(ADR-0058 addendum, #4800/#4862) and the issue that retires the rejection
24+
(#5038);
25+
- it leads with the route that works today — target the write at one record, and
26+
the same condition evaluates as authored — and prices the rewrite instead of
27+
recommending it;
28+
- it still refuses to point at a record-change flow trigger as a way out, which
29+
remains verified rather than assumed: that trigger binds the same lifecycle
30+
hooks and receives the same unbound `previous` on a bulk write (#4862).
31+
32+
**Machine-readable, so a caller never parses the prose.** `HookConditionError`
33+
gains `limitation?: 'bulk_write_previous_unbound' |
34+
'bulk_write_stored_state_unavailable'` (exported as `HookConditionLimitation`)
35+
alongside the existing `predicateBulkWrite` flag. It is deliberately *not* named
36+
`code`: ADR-0112 makes `error.code` a closed wire vocabulary
37+
(`StandardErrorCode` ∪ `ERROR_CODE_LEDGER`) and `rest-server.ts` promotes a
38+
thrown error's `.code` onto the response envelope, so a `.code` here would mint
39+
an unregistered wire code as a side effect. A code that needs to travel goes
40+
through the ledger as a decision.
41+
42+
**"Does this condition read `previous`" is now read off the parsed CEL AST**
43+
(`collectCelRootIdentifiers`, the utility #4972's build gate already uses),
44+
computed once at wrap time, with the old fault-text check kept as a fallback.
45+
The diagnosis no longer depends on cel-js's wording, and it stays correct when
46+
the evaluator faults on some other key the same condition reads.
47+
`record.previous_status` is not a `previous` reference — the AST reports roots,
48+
not member names — so it keeps the declared-field diagnosis, whose remedy is the
49+
right one there.
50+
51+
Unchanged, and pinned by tests: single-record writes (bound `previous`, condition
52+
evaluates, handler runs), bulk writes whose conditions do not name `previous`,
53+
the plain undeclared-key typo report on a bulk write, and fail-loud itself — the
54+
write still fails. Nothing here is an exemption; it is the same rejection with a
55+
diagnosis attached.
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
---
2+
"@objectstack/spec": major
3+
---
4+
5+
fix(spec)!: `composeStacks` 不再静默丢弃顶层键 —— 同值放行、冲突报错、未声明规则必警 (#5005)
6+
7+
`composeStacks` 从一个空对象开始逐项填充:`manifest`、`i18n`、`objects`,再加一份
8+
手工维护的数组白名单。**不在白名单里的顶层键不是"原样保留",而是被删除** ——
9+
不报错、不告警,消费方看到的 `undefined` 与"作者从没写过"完全无法区分。
10+
11+
组合栈是平台的应用打包/安装承载,所以这份静默一路蔓延到了安全配置:
12+
13+
| 顶层键 | 谁消费 | 组合后(修复前) |
14+
|:--|:--|:--|
15+
| `api`(含 `enforceProjectMembership` 每环境成员 403 闸门) | `objectstack serve` → REST + dispatcher | **丢** |
16+
| `server`(`security.rateLimit` / `trustProxy`,#4910) | `objectstack serve` → 入站限流器 | **丢** |
17+
| `functions`(声明式 hook / action / script 节点按名解析的 handler) | `AppPlugin` 启动绑定 | **丢** |
18+
| `datasourceMapping`、`datasets`、`jobs`、`emailTemplates`、`docs`、`books`、`tiers` | 各自运行时 | **丢**(声明为数组,却漏进白名单) |
19+
| `runtimeModule` | 构建产物的 ESM handler bundle | **丢** |
20+
21+
`stacks.length === 1` 时 `composeStacks` 原样返回,所以单栈一切正常 —— 只有真正
22+
≥2 个栈才丢,这是它至今没被发现的原因。ADR-0109 当年也只是给 `tools` 单独补了
23+
一行白名单,并没有堵住这一类。
24+
25+
## 新语义(维护者 2026-08-04 裁决)
26+
27+
1. **同值放行** —— 多个栈声明同一个非数组顶层键且值深相等,照常合成。
28+
2. **冲突报错**,错误信息点名冲突键、两个来源栈(manifest id,无 manifest 时用
29+
`stack #N`)与两条出路(改一致 / 只在应当拥有它的那个栈里保留)。
30+
⛔ **不做 last-wins** —— 后组合的包无声关掉前一个栈的 403 闸门或收紧过的限流
31+
预算,正是本单要消灭的静默安全降级;⛔ **不做 deep-merge** —— 那会造出一个两
32+
位作者都没写过的第三种值。
33+
3. **未声明规则的顶层键必警** —— 按默认规则合成(数组拼接,其余按单值规则)**并**
34+
点名告警指向 #5005,而不是消失。
35+
36+
数组键的拼接语义一字不变。`functions` 按名合并(组合 CRM + Todo 必须两边的
37+
handler 都在),重名报错而非择一;两种书写形态(map / array)不互转(array 条目
38+
带 `packageId`,map 条目没有位置放它),混用报错。`i18n` 保留既有 last-wins ——
39+
它是这里唯一本来就有明确策略的键,本单主题是"被丢掉的键",不动它。
40+
41+
## 结构性保证
42+
43+
顶层键的处置表类型是 `Record< keyof ObjectStackDefinition, ComposeDisposition >`,
44+
**新增一个顶层键而没说清它怎么合成,`tsc --noEmit` 直接不过**。白名单让"忘记"成为
45+
默认,处置表让它成为编译错误;运行时那条 warn 兜住类型看不见的入口
46+
(`strict: false`、手搓 stack 对象)。
47+
48+
## 破坏性
49+
50+
组合两个对 `api` / `server` / `runtimeModule` 声明了**不同**值的栈,过去静默丢弃、
51+
现在抛错;`functions` 重名同理。这正是要的:过去"成功"的那次组合,产出的是一个
52+
少了闸门或少了 handler 的栈。改法见错误信息里的处方。
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
---
2+
"@objectstack/spec": patch
3+
"@objectstack/lint": patch
4+
---
5+
6+
fix(spec): classify the 22 dashboard widget keys and refuse undeclared container inheritance (#4956)
7+
8+
The spec liveness ledger's `dashboard.widgets` entry carried one blanket `live`
9+
verdict plus a `note` asserting that the per-widget props were *"classified in
10+
the DashboardWidgetSchema subtree"*. **No such subtree ever existed.** The gate's
11+
walk drills one level and only through an explicit `children`, and `widgets`
12+
declared none — so all 22 authorable keys of the strict `DashboardWidgetSchema`
13+
were never classified, never counted as unclassified, and every run printed
14+
"all governed-type properties are classified" anyway.
15+
16+
That gap — not evidence — is what carried `widgets[].responsive` through the
17+
#3896 inert-key sweep that removed both its sibling `widgets[].performance` and
18+
its literal namesake `view.responsive`. `view` is drilled through `children`, so
19+
`list.responsive` got asked and went out; `widgets` was never asked. It was
20+
finally retired in #4876 / PR #4995, by hand, four days late.
21+
22+
**What changed for authors**
23+
24+
The `objectstack build` / `objectstack lint` advisory now covers dashboards, so
25+
five widget keys warn at build time (they never did before — `dashboard` was not
26+
in the lint's type collections, because until now its ledger warned on nothing):
27+
28+
| Widget key | Why it warns | What to do instead |
29+
| :--- | :--- | :--- |
30+
| `widgets[].colorVariant` | no render path reads the top-level key — only the authoring panels do | move it under `options` (the inline metric card reads it there); the dataset-bound path has no colour affordance |
31+
| `widgets[].actionUrl` | no renderer draws a per-widget action button; every `actionUrl` the dashboard renderer reads belongs to `header.actions[]` | use `dashboard.header.actions[]` |
32+
| `widgets[].actionType` | pairs with the above | as above |
33+
| `widgets[].actionIcon` | zero readers in either repo | as above |
34+
| `widgets[].aria` | declared ARIA attributes never reach the DOM — the same false-compliance shape as the dashboard-level `aria` removed in 17.0.0 | delete it; the renderer emits its own `aria-*` |
35+
36+
Advisory only — the build never fails on these. **Nothing is removed and no
37+
runtime behaviour changes**: this records verdicts, it does not act on them.
38+
Enforce-or-remove (ADR-0049) for the five is tracked separately.
39+
40+
Two verdicts worth knowing because they cut the other way: `requiresService` is
41+
**live** — it reads as inert in the renderer repo but the REST layer strips
42+
widgets whose service is unregistered (ADR-0057 D10) — and `compareTo` is live
43+
on the inline chart path only; on the ADR-0021 dataset path the string arms are
44+
dropped and `{ offset }` fails in the analytics executor.
45+
46+
**What changed for the gate**
47+
48+
`pnpm --filter @objectstack/spec check:liveness` gains a third direction. A
49+
ledger entry sitting on a container property must now declare one of exactly
50+
three dispositions, all of them data: **drilled** (`children`), **deferred** (a
51+
`{ container, to }` row naming the coordinate that does classify the subtree),
52+
or **recorded** (a row in the shrink-only
53+
`scripts/liveness/undrilled-containers.baseline.json`). A container in none of
54+
the three fails, and so does a baseline row whose container has since been
55+
drilled.
56+
57+
A deferral is **resolved, not believed** — the target must exist (a governed
58+
type root, or a drilled `type/prop` coordinate) and classify exactly the
59+
container's child keys; a dangling or drifted target fails. That is the #4956
60+
claim itself, made checkable: pointing a deferral at `DashboardWidgetSchema`
61+
now produces a build failure naming it, where the same words in a `note` were
62+
believed for a release.
63+
64+
Every run reports both populations (today: 58 containers / 292 child keys
65+
classified nowhere, plus 6 resolved deferrals covering 248), `--undrilled`
66+
prints the worklist, and the success line no longer claims a completeness it
67+
does not have.
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
---
2+
"@objectstack/plugin-hono-server": patch
3+
---
4+
5+
ci(deps): OSV security batch 2026-08 — undici to 7.29.0, hono to 4.12.34,
6+
fast-uri to 3.1.5, so `Validate Package Dependencies` stops failing on every PR (#5032)
7+
8+
Eight advisories (2 high, 6 medium) matched packages resolved in `main`'s
9+
`pnpm-lock.yaml`, and all eight name a fixed version:
10+
11+
| advisory | CVSS | package | resolved | fixed |
12+
| --- | --- | --- | --- | --- |
13+
| `GHSA-7p8r-x3mc-p8w7` | 7.5 | `fast-uri` | 3.1.4 | 3.1.5 |
14+
| `GHSA-8j4g-w8fx-2239` | 5.3 | `hono` | 4.12.32, 4.12.33 | 4.12.34 |
15+
| `GHSA-4cwx-7wf7-3272` | 7.4 | `undici` | 7.28.0 | 7.29.0 |
16+
| `GHSA-jr45-8vmc-qm54` | 5.9 | `undici` | 7.28.0 | 7.29.0 |
17+
| `GHSA-8xcm-r25x-g524` | 4.8 | `undici` | 7.28.0 | 7.29.0 |
18+
| `GHSA-v3r7-h72x-cjcm` | 4.8 | `undici` | 7.28.0 | 7.29.0 |
19+
| `GHSA-m8rv-5g2x-5cg5` | 4.2 | `undici` | 7.28.0 | 7.29.0 |
20+
21+
The OSV-Scanner step in `.github/workflows/validate-deps.yml` reads
22+
`pnpm-lock.yaml` directly and exits non-zero on any match, so the job was red on
23+
`main` itself and attached that red to every PR touching a manifest or the
24+
lockfile, whatever the PR contained (observed on #5027, whose own lockfile delta
25+
is three lines and resolves no new package). A permanently red gate is worse
26+
than no gate: the next PR that really does introduce a vulnerable dependency
27+
looks exactly like all the others.
28+
29+
`undici` repeats the trap #4945 taught. The existing pin
30+
(`undici@>=7.23.0 <7.28.0: ^7.28.0`, added for `GHSA-vmh5-mc38-953g`) had
31+
settled on 7.28.0 — the version these five advisories affect — and its exclusive
32+
upper bound no longer covered it, so the override sat there doing nothing.
33+
Selector and target move together, to `<7.29.0` / `^7.29.0`. Transitive-only via
34+
`@vscode/vsce` > `cheerio`; `@ai-sdk/provider-utils` already resolved 7.29.0, so
35+
the two dedupe onto one copy. `jsdom`'s `undici` 8.9.0 is outside the selector
36+
and untouched.
37+
38+
`fast-uri` is transitive-only through `ajv@8.20.0` (declares `^3.0.1`), reaching
39+
`@modelcontextprotocol/sdk`, `@objectstack/objectql`, `secretlint` and `table`;
40+
a `fast-uri@<3.1.5: ^3.1.5` override covers all of them.
41+
42+
`hono` is the one that is not transitive-only, which is why this changeset
43+
releases something. Two versions were resolved: 4.12.32 from our own packages
44+
and 4.12.33 pulled by `@modelcontextprotocol/sdk`. The override moves the
45+
transitive copy and the declared ranges move with it — `@objectstack/plugin-hono-server`
46+
`dependencies.hono` to `^4.12.34` (the published-manifest change this patch
47+
covers), plus the `@objectstack/hono` and `@objectstack/plugin-auth`
48+
devDependencies. Overrides do not ship with published packages, so a declared
49+
range left behind would mean downstream resolves a version CI never ran —
50+
exactly what `scripts/check-override-consistency.mjs` exists to catch. The
51+
`@objectstack/hono` **peer** range stays the permissive `^4.12.8` on purpose: a
52+
peer states which host `hono` the adapter works against, and a host that pins an
53+
old one owns that copy. After the bump the workspace resolves a single
54+
`hono@4.12.34`.
55+
56+
Scope is the eight advisories #5032 lists and nothing else. #4965 (advisories
57+
with no fix available, and the `osv-scanner.toml` exemption conventions that
58+
answer them) is a separate question — every advisory here has a fix, so this is
59+
an upgrade, not an exemption.
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
---
2+
"@objectstack/metadata-protocol": patch
3+
---
4+
5+
fix(metadata-protocol): stop `promoteDraft`'s draft drain from swallowing every failure (#4981)
6+
7+
Publishing a draft is two writes: a transactional `put` that promotes the body onto
8+
the active row, then a `delete` that drains the now-redundant `state='draft'` row.
9+
The drain was guarded by a bare `catch {}` whose comment named exactly one cause —
10+
"a concurrent publisher may have already drained the draft" — while its behaviour
11+
covered **all** of them: connection drops, statement timeouts, missing privileges,
12+
driver faults, `parentVersion` mismatches.
13+
14+
The result was a silent, self-perpetuating inconsistency. `publishDraft` returned
15+
success, the active row was correct and durable, and a stale `state='draft'` row
16+
stayed in `sys_metadata` holding the body that had just been published. Nothing
17+
logged it and nothing retried it, so Studio/Setup kept reporting "unpublished
18+
changes" for an artifact that had none, and the next publish of that artifact
19+
promoted the same already-published body again — which overwrites the active row if
20+
anything published or reverted in between.
21+
22+
**The drain now discriminates by cause.** `ConflictError` — the only error
23+
`delete()` raises from its own pre-driver row lookup — stays silent, because both of
24+
its arms are genuinely benign: `actualHead === null` is the concurrent-publisher
25+
race the old comment described, and a differing head means a *newer* draft was saved
26+
while the publish was in flight, so the surviving row is real pending work that must
27+
not be dropped. Every other failure is reported at `error` level (per the
28+
`warn`-vs-`error` rule: the system keeps looking healthy while something it claims to
29+
have cleaned up is still there), naming the orphaned artifact, the consequence, and
30+
the remedy, with the original cause attached.
31+
32+
**`promoteDraft` still returns success, deliberately.** The drain runs *after* the
33+
`put` has committed, so throwing would misreport a durably successful publish as a
34+
failure and invite the caller to retry — and a retried publish is precisely the
35+
harmful path, because it re-promotes the stale draft. The failure is surfaced
36+
without lying about the publish instead: alongside the log, the result carries a new
37+
optional `draftDrainFailed` field (`{ ref, draftHash, cause }`, exported as
38+
`DraftDrainFailure`) so callers can react without parsing logs. It is an additive
39+
optional field on an existing result object — absent on every clean publish — so no
40+
existing caller changes.
41+
42+
No protocol or spec shape changed. The drain seam is registered with
43+
`pnpm check:durability-log-level` (as the named callee `dropPromotedDraftRow`) so
44+
the catch cannot quietly go back to swallowing everything.

0 commit comments

Comments
 (0)