Skip to content

Commit fbf7a02

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-4936-apis-loud-reject
2 parents b3b02c5 + a1a855a commit fbf7a02

47 files changed

Lines changed: 5743 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: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
---
3+
4+
docs(adr-0121): record the 2026-08-04 maintainer ruling on declarative endpoint routing — namespace-scoped paths, the actions/apis channel split, and keeping `type: flow` (#5060).
5+
6+
Three decisions, drafted onto the zero-cost window #4936 opened. v17 hard-rejects a non-empty `apis:` (the loud-reject route ruled on 2026-08-04 00:20Z), so no stack can carry a declarative endpoint into 17.x — which makes this the one moment the path shape can be tightened with no migration to pay.
7+
8+
**Namespace, not arbitrary routes.** `ApiEndpointSchema.path` constrains nothing today but a leading slash, so app metadata can legally claim `/api/v1/data/…` or collide with another installed package. The path narrows to `<prefix>/apps/<namespace>/<subpath>`: `apps` is the platform's one reserved cut-out segment (verified free — it is in neither the domain registry's prefix set nor `LEGACY_CHAIN_PREFIXES`), the namespace segment derives from `manifest.namespace` (the only stack identity key that is URL-safe by charset, carries an instance-uniqueness contract, and is already enforced as every object name's prefix), and the author names only the subpath. Endpoint-vs-builtin and cross-app collisions become structurally impossible rather than list-checked, which retires #5040 design §1's reserved-prefix pin list and its spec/runtime consistency test — a mechanism that needed a test to keep it from rotting.
9+
10+
**actions vs apis, by where the caller is.** Caller inside the platform (session, platform dialect — UI buttons, AI/MCP, SDK) → `actions`; caller outside (third-party webhooks, partner systems) → `apis`. actions is the mature command channel (ADR-0104 params, ADR-0066 D4 gates, #3962 HTTP-semantic failures, `ActionAiSchema`), but structurally cannot serve the three hard traits of an outside caller: the payload shape is theirs (`inputMapping`), there is no platform session (`authRequired: false` + endpoint `rateLimit`), and the URL is a contract written into their system (stable + OpenAPI).
11+
12+
**`type: flow` stays**, with three disciplines: the split criterion goes into both schemas' `describe()` (spec-lane item), flow endpoints purely delegate to the automation service so picking the wrong channel is a style question and never a behavior question, and `authRequired: false` must declare `rateLimit` or publish rejects. Signature verification is named a future vocabulary candidate and deliberately not promised — adding keys no executor consumes is the ADR-0078 shape this ADR exists to avoid.
13+
14+
Alternatives recorded with the two-axis analysis: O1 (free paths + a reserved-prefix gate) is rejected because it compensates at the consumer for a producer-side problem and forces every author to learn which prefixes the platform happens to occupy; O3 (actions replaces apis / the Dataverse single-channel model) is rejected because it drives third-party webhook reception out of metadata and into ungoverned handler code.
15+
16+
Documentation only; releases nothing. The executable half lands via #5040's E-series (E7 carries the publish gates).
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.

0 commit comments

Comments
 (0)