Skip to content

Commit 16ffb19

Browse files
committed
Merge origin/main into claude/issue-4001-ui-batch18
Ledger `ui/` section conflicted as expected — batch 16 (#5069) landed while batch 18 was in flight and both edit it. Resolved by taking main's section wholesale (so batch 16's `widget` no-door reclass, the `i18n` split row and the AriaProps closure survive verbatim) and re-applying batch 18's three edits on top. Header and subtotal recomputed FROM THE SURVIVING ROWS, not carried from either side: 29+4+9+2+7+5+4+4+4+3+1+1+1 = 74 strip, authorable 34 of 74. Batch 18 is the ninth wrong-on-both-sides instance — it computed 84 against a tree where batch 16's rows still existed, batch 16 computed 90 against one where view was still 20, and the merge is neither. Recorded in the section's own prose, since the two batches moved the same numbers for OPPOSITE reasons (16 reclassified 14 sites it never touched; 18 closed 16 it did). check:strictness-ledger green on the merged tree: 36 open file(s) / 233 strip site(s). Part of #4001 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ehu85kbvMcrNTUJjwxvLJ9
2 parents 3eacbd7 + c272e48 commit 16ffb19

144 files changed

Lines changed: 13896 additions & 6596 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: 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: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
---
2+
"@objectstack/spec": major
3+
"@objectstack/core": major
4+
"@objectstack/plugin-hono-server": major
5+
"@objectstack/runtime": major
6+
"@objectstack/metadata-protocol": patch
7+
---
8+
9+
feat(spec,core,runtime)!: declarative `apis:` refuses loudly instead of parsing into silence; the `ApiRegistry` family retires (#4936, #4939)
10+
11+
The declarative API-endpoint surface was **zero-execution end to end**, and said nothing
12+
about it. Metadata loading worked perfectly — a stack declared `apis:`, `defineStack`
13+
accepted it, and `GET /api/v1/meta/api` returned every endpoint with every key intact.
14+
The execution side never fired once. On a real boot (showcase, 47 plugins) both declared
15+
paths answered a bare `404 {"error":"Not found"}` — not even the dispatcher's semantic
16+
404, because **no route was ever mounted** for a declared path, so the request died at
17+
Hono's `notFound`. Behind that, the dispatcher's `handleApiEndpoint` branch resolved the
18+
metadata service and called `matchEndpoint` on it — a method **no implementation in the
19+
repo has ever provided**. The branch returned "not handled" on every request ever served.
20+
21+
So every key on `ApiEndpointSchema` was declared ≠ enforced: `path`/`method` (never
22+
mounted), `type`/`target`/`objectParams` (never executed), `cacheTtl`,
23+
`inputMapping`/`outputMapping`, `rateLimit`, `summary`/`description` — and
24+
**`authRequired`**, a security semantic that parsed green and gated nothing at all. That
25+
is false compliance, the failure ADR-0049 exists to stop, not debt.
26+
27+
## BREAKING — a non-empty `apis:` is now rejected
28+
29+
Metadata that parsed cleanly before is now **refused at publish/validate**, with the
30+
prescription in the rejection itself:
31+
32+
```
33+
apis: `apis:` (declarative ApiEndpoint) is DECLARED BUT NOT EXECUTABLE in this runtime,
34+
so a non-empty array is rejected instead of silently accepted (#4936). …
35+
```
36+
37+
**FROM → TO.** `apis: [ …endpoints… ]``apis: []` (or delete the key; both are still
38+
accepted, and an empty array is not a special case). To actually serve the route today,
39+
mount it **in code** — a plugin manifest `contributes.routes` entry, or an `http.server`
40+
route. That is now the only honest path, and the one `examples/app-showcase` uses
41+
(`src/system/server/recalc-endpoint.ts`).
42+
43+
The refusal lives on `ObjectStackDefinitionSchema` itself, which is the single choke
44+
point every path runs through — `defineStack`, the metadata plugin's artifact ingestion,
45+
`os validate`, the lint scorer and `EnvironmentArtifactSchema`. There is no path that
46+
forgot to check.
47+
48+
**The `ApiEndpoint` vocabulary is deliberately KEPT.** Retiring it was considered and
49+
rejected: endpoint shapes are an industry-stable form, so a retirement would only mean
50+
re-introducing the identical schema later. Your endpoint definitions stay valid TypeScript
51+
and stay in the spec; only *authoring them into a stack* is refused, and only until the
52+
executor lands. Keep them commented next to your stack — that is what the showcase does.
53+
The executor (route mounting + endpoint matching + per-key wiring for
54+
`authRequired`/`cacheTtl`/`inputMapping`/`outputMapping`/`rateLimit`) is tracked by
55+
**#5040**, which replaces this rejection with real execution.
56+
57+
## BREAKING — the `ApiRegistry` / `ApiEndpointRegistration` family is removed (#4939)
58+
59+
The repo carried a **second**, unrelated declaration shape for "an API endpoint":
60+
`ApiEndpointRegistrationSchema` and the ~500-line `ApiRegistry` service that
61+
`createApiRegistryPlugin()` registered under `api-registry`. Nothing composed it — every
62+
assembly site lived in `packages/core/examples/`, with no registration in
63+
`packages/runtime`, `packages/cli` or any `examples/app-*`, and a real boot carried no
64+
such service. The whole family was therefore inert, including
65+
`ApiEndpointRegistration.requiredPermissions`, whose docs promised **in the present tense**
66+
that "the gateway layer automatically validates these permissions" while no gateway read
67+
it. Two declaration shapes, both dead; this retirement converges them on one.
68+
69+
Removed from `@objectstack/spec/api`: `ApiEndpointRegistration(Schema)`,
70+
`ApiRegistry(Schema)`, `ApiRegistryEntry(Schema)`, `ApiMetadataSchema`,
71+
`ApiParameterSchema`, `ApiResponseSchema`, `ApiDiscoveryQuerySchema`,
72+
`ApiDiscoveryResponseSchema`, `ApiProtocolType`, `HttpStatusCode`,
73+
`ObjectQLReferenceSchema`, `SchemaDefinition` (12 JSON-Schema defs, 67 authorable keys).
74+
Removed from `@objectstack/core`: `ApiRegistry`, `createApiRegistryPlugin`.
75+
Removed from `@objectstack/plugin-hono-server`: the `useApiRegistry` option — it was
76+
defaulted to `true` and read by nothing, configuring a service that was never composed.
77+
78+
**FROM → TO.** There is no replacement shape to migrate to, because nothing executed the
79+
old one: delete the registration objects. If you were assembling an `ApiRegistryEntry`,
80+
you were building a value only your own code read — keep it as your own type. Declarative
81+
endpoints have one vocabulary now, `ApiEndpointSchema`.
82+
83+
`ConflictResolutionStrategy` **survives** the removal and moved to
84+
`@objectstack/spec/api`'s `router.zod` — same name, same four values
85+
(`error`/`priority`/`first-wins`/`last-wins`), same import path. It is pinned there by two
86+
independent ratchets and is not part of the retired surface.
87+
88+
## Also in this change
89+
90+
- **BREAKING (`@objectstack/runtime`):** `HttpDispatcher.handleApiEndpoint()` is deleted,
91+
along with its now-orphaned private `callData` delegate, and `/__api-endpoint` leaves
92+
`LEGACY_CHAIN_PREFIXES` and the route ledger. The method was public, so this is an API
93+
removal — but it returned `{ handled: false }` for every call it ever received, so no
94+
caller can observe a behaviour change beyond the missing symbol. Delete the call.
95+
Absence is now loud (ADR-0076): the surface is refused at authoring rather than 404ing
96+
at runtime with dead code behind it.
97+
- `examples/app-showcase` no longer declares endpoints, and its coverage manifest no
98+
longer claims the capability is `demonstrated` — that entry read "executed by the runtime
99+
dispatcher (handleApiEndpoint)", which was exactly the advertise-what-you-don't-deliver
100+
claim Prime Directive #10 forbids.
101+
- The endpoint-level `rateLimit` tracking pointers left by #4910/#5006 now name **#5040**,
102+
the live executor card, instead of #4936, which closes with this change.
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: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
---
3+
4+
chore(scripts): the durability log-level gate no longer excuses a catch that only PARTIALLY rethrows
5+
6+
`check:durability-log-level` skipped any guarded `catch` containing a `throw`.
7+
That is right when the catch propagates on every path — the failure reaches the
8+
caller and nothing is being degraded. It is wrong for a catch that **recovers on
9+
one branch and rethrows on the other**: the rethrow says nothing about the
10+
branch that returns a substitute value, and that branch is a degradation like
11+
any other.
12+
13+
Found while closing
14+
[#4998](https://github.com/objectstack-ai/objectstack/issues/4998), whose seam
15+
(`writeRecoveringSummary`: recover `ERR_SUMMARY_RECOMPUTE`, rethrow everything
16+
else) has exactly that shape. Registering its callee in
17+
`DURABILITY_CRITICAL_CALLEES` produced a ledger entry that could never fire —
18+
protection that reads as real and enforces nothing, which is worse than none.
19+
Measured against the repo, the tightened rule changes the verdict on no existing
20+
seam (11 seams, all still loud or rethrowing) and needs no baseline entry.

0 commit comments

Comments
 (0)