Skip to content

Commit 52723bf

Browse files
committed
Merge origin/main into claude/issue-4001-ui-batch14
Brings in #5003 (批 13), #5006, #4983, #4991, #4855, #4729, #4250. Ledger `ui/` section conflicted, as planned — 批 13 and 批 14 both edit it. Every row from both sides kept; nothing resolved in favour of a side: - classification table: ONE merged `no door` row (批 13 authored the class; 批 14 adds the positive-control requirement and the first file that SPLITS across it, ui/sharing.zod.ts). - triage table: structure from 批 13 (it split `responsive` out and grouped the five no-door files); 批 14's four measured verdicts overlaid. - remaining-strip map: `responsive` row deleted by 批 13, `action`/`report`/ `dataset`/`dashboard` rows deleted by 批 14 — all five by the reverse pin. Header and subtotal RECOMPUTED FROM THE SURVIVING ROWS, not decremented: 29+20+14+9+7+7+6+4+4+4+3+1+1+1 = 110. 批 13 wrote 119 (against a tree where 批 14's four rows still existed), 批 14 wrote 114 (against one where `responsive` still did); both were right against their own branch and both are wrong against the merge. The fifth and sixth instances of the failure the automation/ section documents, and the first where the two wrong numbers were both this line. Subtotal 88 of 110 authorable, 22 in the fourth class. check:strictness-ledger arbitrates and is green. os-regen four-step: merged (never rebased), refreshed install + spec build, reset all seven generator-owned paths to origin/main, regenerated wholesale. Delta vs origin/main is exactly one file — content/docs/references/ui/sharing.mdx — so the regeneration reproduced main's artifacts byte-identically and no sibling entry was dropped (#5006's RateLimitConfig/RateLimitConfigSchema and 批 13's responsive entries confirmed present). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ehu85kbvMcrNTUJjwxvLJ9
2 parents b414ba6 + bf1edef commit 52723bf

78 files changed

Lines changed: 5607 additions & 378 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: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
---
2+
"@objectstack/lint": patch
3+
---
4+
5+
fix(lint): 扩 ADR-0105 D6 ② 的收件人词表至 ADR 原文范围 —— `unit_and_subordinates`
6+
也判红
7+
8+
`org-axis-cross-org-bu-grant`(D6 ②)此前只对 `sharedWith.type ===
9+
'business_unit'` 判红,而授权面**更大**的另一个业务单元收件人
10+
`unit_and_subordinates`(一个 BU **加上其全部后代单元**,ADR-0057 D5 子树扩张)
11+
直接放行。两者的缺陷完全相同:平台级对象(`tenancy.enabled: false` /
12+
`systemFields.tenant: false`)没有 organization 列可供 Layer 0 收口,BU 子树没有
13+
任何 organization 可供解析,授权因而跨到库里每一个 organization —— 正是 ADR 拒绝
14+
的"跨 org BU 巨树",从后门到达。
15+
16+
漏掉的恰恰是 ADR-0105 D6 ② 自己点名的那一个:
17+
18+
> Every BU mechanism — `unit_and_subordinates` sharing, `adminScope`
19+
> delegation, depth scopes — operates within one organization. There is no
20+
> cross-org tree.
21+
22+
判定改为收件人类型 ∈ `{ business_unit, unit_and_subordinates }`,诊断信息里点名
23+
**实际写下的**类型并说明其触及范围(子树那一个额外写明 "AND every descendant
24+
unit"),修复建议改为指向三个扁平收件人。
25+
26+
词表与 spec 枚举 `ShareRecipientType` 的差集不再是隐式的:规则里以表格逐条写明
27+
拦截二者、放行 `user` / `team` / `position` 的理由(它们的运行时展开都不经
28+
`BusinessUnitGraphService`,是 `tenancy.enabled: false` 平台级目录**被设计用来**
29+
共享的方式),并附一条测试断言两半恰好划分 `ShareRecipientType` —— 将来枚举加成员
30+
会在词表处失败,而不是无声地落进没人选过的那一桶。#4991 正是这条断言缺席的产物。
31+
32+
这是 error 级门禁的扩张,因此复核了真实元数据:`examples/app-showcase` /
33+
`app-crm` 是仓库里仅有的已声明 sharing rule(共 11 条),全仓无任何对象关掉
34+
tenancy,扩张后 org-axis 红线数为 **0** —— 不产生新红。
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
---
3+
4+
ci(dx): `packages/qa/dogfood` 的 strict tsconfig 现在真的被执行 —— 加上 `typecheck` script 并从 `scripts/check-type-check-coverage.mjs` 的 DEBT 账本毕业(#4855)。Dev scripts / CI only;releases nothing。
5+
6+
dogfood 有一份认真的 tsconfig(`strict`、`NodeNext`、`include: ["src/**/*", "test/**/*"]`),但 package.json 的 scripts 里只有 `test`。根 `pnpm typecheck` 是 `turbo run typecheck`,只跑声明了该 script 的包 —— 三个 qa 包里,这是唯一没声明的一个,所以这份配置**从未被执行过**。#4311 的覆盖率闸门确实看见了这个洞,但把它记成 DEBT(`errors: 12`)冻结了下来,而 DEBT 是「暂缓」不是「豁免」:门后的错误只会继续涨。在最新 main 上实测是 **14** 条,比账本冻结时多了 2 条 —— 正是这种漂移说明冻结不能长期替代执行。
7+
8+
14 条全部修掉,分四类:
9+
10+
- **NodeNext 缺扩展名(2 条,TS2307)** —— `field-zoo-roundtrip.dogfood.test.ts` 与 `field-zoo-value-shape.test.ts` 写 `from './field-zoo.matrix'`。包内另外 33 处相对 import 全都带 `.js`,这两处是仅有的例外。vitest 能解析,tsc 不能。附带消掉 1 条级联的 TS7006(未解析的 import 让符号退化成 `any`,回调参数随即报 implicit-any)。
11+
- **flow fixture 的 `type` 没有收窄(8 条,TS2322)** —— 四个 fixture 的 flow 是裸对象字面量,`type: 'autolaunched'` 推成 `string`,喂给 `defineStack` 时对不上字面量联合。修法不是 `as const`,而是按 `examples/app-todo/src/flows/task.flow.ts` 的既有写法标注 `: Flow`(`import type { Flow } from '@objectstack/spec/automation'`)—— 这样整份 fixture 都被 spec 的真实契约检查,而不只是让报错闭嘴。
12+
- **条件展开出的 headers(2 条,TS2322)** —— `attachments-permission-matrix.dogfood.test.ts` 的 `token ? { Authorization } : {}` 在匿名分支上推出 `Authorization?: undefined`,展开进 `headers` 后 `HeadersInit`(`Record< string, string >`)拒收。给该常量标注 `Record< string, string >`。
13+
- **连接器 handler 少传一个参数(1 条,TS2554)** —— `showcase-mcp-self-connection.dogfood.test.ts` 调 `handler!({})`,而 `McpConnectorBundle.handlers` 声明的是 `(input, ctx)`,引擎侧 `connector-nodes.ts` 也始终按两参数派发。这条一直「能跑」,只是因为 MCP 这个 handler 恰好忽略 `ctx` —— 契约上它是错的。改为按声明传两参数,与 connector-mcp 自己的测试一致;**没有放松任何契约**(把 `ctx` 改成可选才是错误方向)。
14+
15+
修完接进执行:package.json 加 `"typecheck": "tsc --noEmit"`,并按闸门的 RECONCILED 不变式在同一个 PR 里删掉 DEBT 条目(graduated)。覆盖率从 60/77 走到 61/77,DEBT 从 17 个包降到 16 个。反向验证:在 fixture 里塞一个非法的 flow `type`,`turbo run typecheck` 判红并精确指出该行;移回后判绿(命中修复前那一次绿跑的同一个 turbo hash)。`pnpm --filter @objectstack/dogfood test` 481 passed / 3 skipped,行为未变。
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/runtime": minor
4+
"@objectstack/plugin-hono-server": minor
5+
"@objectstack/plugin-auth": patch
6+
"@objectstack/cli": patch
7+
---
8+
9+
feat(spec,runtime,hono): `server.security.rateLimit` — an authored budget that actually returns 429 (#4910, #4937)
10+
11+
Rate limiting in ObjectStack was three shapes with nothing between them. `packages/spec`
12+
declared `RateLimitConfig` in three places and the whole repo had **zero readers** for any
13+
of them, so an author wrote a budget, it parsed, and nothing happened (#4686).
14+
`@objectstack/runtime` shipped a token bucket whose comments claimed, in the present tense,
15+
that the dispatcher called it and short-circuited with 429 — it had **zero call sites**
16+
outside its own unit test, and the `DispatcherPluginConfig.rateLimit` field it told you to
17+
tune did not exist (#4937). Neither half was broken; they were simply never connected, and
18+
both were documented as if they were.
19+
20+
They are connected now, along one narrow path.
21+
22+
## What you write
23+
24+
```ts
25+
export default defineStack({
26+
manifest: { /* … */ },
27+
server: {
28+
security: {
29+
rateLimit: { enabled: true, windowMs: 60_000, maxRequests: 600 },
30+
},
31+
trustProxy: false,
32+
},
33+
});
34+
```
35+
36+
`server:` is a **new** top-level stack key. Nothing declared it before, so no existing
37+
stack changes behaviour on upgrade — there is no configuration that was inert yesterday
38+
and starts throttling today.
39+
40+
It is deliberately **narrow**: it carries `security.rateLimit` and `trustProxy` and
41+
nothing else, because those are the two keys with a consumer. It is NOT the nine-key
42+
`HttpServerConfigSchema` — the other seven have no reader and no authoring surface, and
43+
mounting them here would have made seven dead keys writable in one move (their
44+
enforce-or-remove fate stays with #4938). It is strict from birth (#4001), so a misspelled
45+
budget is rejected with the correction rather than silently defaulted, and `maxRequests: 0`
46+
is refused at `defineStack` rather than at 3am.
47+
48+
**No `server.port`.** The listening socket belongs to the deployment, not the artifact, and
49+
`objectstack serve -p` already owns it. The precedence rule is recorded in the schema and
50+
the docs in advance, so it cannot be re-litigated per caller: **CLI flag > `server:` >
51+
built-in default.**
52+
53+
## What happens
54+
55+
Every inbound request the server routes — REST, dispatcher, service routes, anything
56+
mounted on that transport — consumes from a token bucket sized `capacity = maxRequests`,
57+
refilling at `maxRequests / (windowMs / 1000)` per second. An empty bucket answers **429**
58+
with a `Retry-After` computed from the bucket itself and the standard error envelope
59+
(`code: "RATE_LIMIT_EXCEEDED"`). `OPTIONS` preflights are never metered.
60+
61+
The bucket is keyed by **resolved principal**, falling back to the caller's **IP** for
62+
anonymous traffic — so one abusive session cannot spend another user's budget, and
63+
credential-stuffing traffic (which has no principal yet) is still metered per source. That
64+
IP comes from `X-Forwarded-For` / `X-Real-IP` **only when `trustProxy: true` is declared**;
65+
otherwise it is the transport's own peer address. Undeclared, those headers are attacker
66+
input: honouring them by default would hand anyone an unlimited supply of fresh buckets and
67+
let them drain a chosen victim's.
68+
69+
Counters live in the kernel `cache` service when one is registered, so a multi-node
70+
deployment enforces one budget instead of one per node (ADR-0069 D2), resolved lazily at
71+
consume time so a cache plugin that registers later is still picked up (#4772). With no
72+
cache service at all it falls back to a per-process store and says so once, naming the
73+
consequence: the effective limit becomes the declared budget multiplied by the number of
74+
nodes, and nothing about the deployment looks wrong.
75+
76+
## Also in this change
77+
78+
- **`IHttpServer.use()` is a real middleware seam.** The Hono adapter's implementation
79+
passed `{}` for both `req` and `res` and called `next()` unconditionally, so a registered
80+
middleware could not read the request, write a response, or decline to continue — a
81+
declared seam with no execution behind it, unnoticed because nothing called it. It now
82+
delivers method/path/query/headers plus the transport peer address
83+
(`IHttpRequest.remoteAddress`, new), and honours a short-circuit. Middleware must be
84+
registered before the routes it guards; the kernel's two-phase boot makes that automatic
85+
(`init()` before every `start()`).
86+
- **`packages/runtime/src/security/rate-limit.ts` no longer describes an execution chain it
87+
does not have** (#4937). The token-bucket arithmetic is extracted so the synchronous
88+
in-process limiter and the new shared-store one cannot drift, and `DEFAULT_RATE_LIMITS` is
89+
now labelled as the reference material it always was rather than as live defaults.
90+
91+
## Explicitly NOT wired
92+
93+
`ApiEndpointSchema.rateLimit` and `ApiEndpointRegistrationSchema.rateLimit` remain
94+
**known-unwired**. Declaring them still changes nothing. They are not retired here either:
95+
the fate of the whole declarative `apis:` surface is undecided (#4936), and retiring one
96+
key of a surface that may yet be implemented would only have to be undone. Tracked, not
97+
silent.
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
---
2+
"@objectstack/formula": minor
3+
"@objectstack/lint": minor
4+
"@objectstack/plugin-security": patch
5+
---
6+
7+
feat(formula,lint): wire ADR-0056 D4's RLS authoring gate, from the runtime's own predicate (#4983)
8+
9+
`isSupportedRlsExpression` has carried the same docblock since ADR-0056 D4:
10+
"exposed so an authoring-time gate (`objectstack compile`) can REJECT a
11+
predicate the runtime would silently drop … A `false` here means 'this
12+
predicate will never enforce'." It had **no non-test consumer anywhere** — the
13+
function written to fix declared-but-never-read was itself declared and never
14+
read. This lands the consumer, in two steps that had to happen in this order.
15+
16+
**1. `sqlPredicateToCel` and `isSupportedRlsExpression` move FROM
17+
`@objectstack/plugin-security` (`src/rls-compiler.ts`) TO `@objectstack/formula`
18+
(`src/rls-predicate.ts`), and are exported from its root.** Executable code
19+
unchanged — a change of address, not of behaviour; `plugin-security` now imports
20+
them from `@objectstack/formula` and keeps no copy, so there is still exactly
21+
one definition. No import path outside the two packages changes: neither symbol
22+
was ever exported from `@objectstack/plugin-security`'s entry point. The move is
23+
what makes step 2 possible at all — `@objectstack/lint` may depend on
24+
`@objectstack/spec` and never on a runtime, so with the predicate living in a
25+
runtime the gate's only other door was copying the SQL→CEL bridge, whose
26+
boundary conditions (quoted literals are never rewritten; canonical CEL passes
27+
through unchanged) *are* the gate's red/green line. A fork drifting by one
28+
character rejects policies the runtime executes correctly — the false-positive
29+
direction, which is worse than the gap. ADR-0058 D1 asks for a single canonical
30+
shape gate; the bridge is part of that gate.
31+
32+
**2. New `@objectstack/lint` rule `validateRlsPredicateEnforceability`,
33+
`error`, on all three authoring commands**, over
34+
`permissions[].rowLevelSecurity[].using` and `.check`:
35+
36+
- **`rls-predicate-unenforceable`** — parses as CEL, outside the pushdown
37+
subset: a function call (`size(...)`, `has(...)`), arithmetic, a ternary, a
38+
cross-object path (`record.account.region`).
39+
- **`rls-predicate-unparseable`** — does not parse as CEL even after the legacy
40+
SQL bridge (`=` → `==`, `IN` → `in`): SQL `AND` / `OR` / `LIKE`, a subquery.
41+
Its own id because the fix is different — write CEL (`&&`, `||`), not a
42+
different shape.
43+
44+
What the gate prevents, measured through `plugin-security` rather than inferred:
45+
`RLSCompiler` drops the policy and logs one request-time WARN. On the read path,
46+
when it is the only applicable policy, `compileFilter` returns the
47+
`RLS_DENY_FILTER` sentinel instead, which is AND-ed onto the where clause — so
48+
every select / update / delete on the object matches **zero rows**. On the
49+
ADR-0058 D4 write path the post-image `check` becomes that same sentinel, which
50+
no record satisfies, so every insert / update fails with `PermissionDeniedError`.
51+
The runtime fails closed, which is why this was survivable: the result is not a
52+
hole but a policy that reads as an authorization and behaves as a blanket
53+
refusal, with nothing at authoring time pointing at the line that caused it.
54+
55+
Fix a flagged predicate by rewriting it inside the lowerable subset — `==` `!=`
56+
`>` `<` `>=` `<=`, `in`, `&&` `||` `!`, `== null` / `!= null`, and
57+
`startsWith` / `endsWith` / `contains` over single-column field paths (ADR-0058
58+
D2), against a literal or a `current_user.*` value. Two specific migrations:
59+
`has(x)` / `size(x) > 0` → `x != null` (a function call is correct in an object
60+
*validation* rule, which is interpreted, and wrong here, where the predicate is
61+
compiled to a filter); and a related record's field → denormalise it onto this
62+
object (formula/rollup) and test that column, since RLS cannot join (ADR-0055).
63+
64+
Same construction as the sharing-rule gate (#4698): the rule does not model the
65+
consumer or grep for it — it calls `isSupportedRlsExpression`, the exact
66+
function `RLSCompiler.compileFilter` consults to decide whether a dropped policy
67+
earns its warning, so the two verdicts are one boolean by construction, pinned
68+
in both directions over a shared corpus. Measured before shipping: every RLS
69+
predicate declared anywhere in this repo — the `plugin-security` platform seeds,
70+
the examples, the dogfood fixtures, the authoring skill — is supported, so the
71+
gate turns nothing red that works today. Unlike the sharing-rule gate, CEL
72+
*syntax* is reported here rather than deferred to `expression-invalid`:
73+
`validateStackExpressions` does not walk `rowLevelSecurity` at all, and could not
74+
judge this field correctly if it did, because `owner_id = current_user.id` is a
75+
CEL syntax error and a working RLS predicate at the same time.
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): a seed failure that is COUNTED as an error now logs at `error` (#4729)
6+
7+
`SeedLoaderService`'s pass-2 deferred back-fill carried a comment stating that a
8+
failed back-fill "must be a reported, counted error, **never** a silent warning"
9+
— and the line under it called `logger.warn`. The count was right (the failure
10+
lands in `result.errors`, flips `success: false`) but the level contradicted it,
11+
and that log line is the only trace a seed leaves in a host's console. `warn` is
12+
the level #4420 proved nobody reads.
13+
14+
**What changed**
15+
16+
- The failed back-fill logs at **`error`**, and the line now owes what
17+
AGENTS.md → "Degradation log levels" requires of one: the **consequence**
18+
(`<object>.<field>` stays NULL on a named record, the row itself was seeded so
19+
every row counter reads clean, the circular relationship is half-written) and
20+
the **fix** (nothing retries it — repair the write error, which is either a
21+
transient failure that outlasted the retry budget or a validation rule vetoing
22+
the update, then re-run the seed).
23+
- The rest of the file was audited against the same criterion — *is this failure
24+
counted in the load's `errors` (i.e. does it make `success: false`)?* Five more
25+
sites answered yes while logging `warn`, and were raised to `error`: a failed
26+
batch insert row, a record dropped because its `cel` expression could not
27+
resolve, the two invalid-reference paths that DROP a reference field (the row
28+
lands without its association and the row counters stay clean — framework#3932),
29+
and the two write-failure catches on the sequential/update paths. The two
30+
dropped-reference lines also gained the consequence and fix in the message.
31+
- Deliberately left at `warn`, and now documented as audited: "Halting on first
32+
error" (a control-flow notice about failures already reported at `error`), the
33+
`NODE_ENV` scope warning (a functional, fail-open degradation), and the
34+
roll-up-summary recompute (records *were* written; whether a stale summary
35+
column is the same class is #4998).
36+
- The seam is now pinned by CI, not only by tests: the back-fill write was
37+
extracted as `writeDeferredReference` and added — with `writeRecord` — to
38+
`DURABILITY_CRITICAL_CALLEES` in `scripts/check-durability-degradation-log-level.mjs`,
39+
so `pnpm check:durability-log-level` fails if either catch is ever quietened
40+
again.
41+
42+
No API, schema or result-object change: the same errors are reported in
43+
`SeedLoaderResult` exactly as before. What changed is the level and the wording
44+
of what a seeding host sees in its log.
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
---
3+
4+
ci: the stall guard now proves it still fires (`pnpm check:stall-guard`), and a stalled run's SIGKILL escalation actually runs (#4250). CI-only — releases nothing.
5+
6+
`scripts/run-with-stall-guard.mjs` gains a `--self-test` that drives it against synthetic stalls — an idle hang, a sync-spinning hang, a hang that never prints a first line, and a descendant that traps SIGTERM — asserting the exit-75 verdict, the idle/ON-CPU classification, the SIGUSR2 stack harvest including the "no report = blocked event loop" inference, full process-group teardown, and the negative direction (a healthy run keeps its own exit status; steady output is never called a stall). Six jobs across five workflows depend on this guard, and until now nothing exercised its firing path between real stalls.
7+
8+
Writing that harness surfaced a real defect, since fixed: the SIGKILL escalation was armed as an unref'd timer and the guard exited from the direct child's `exit` handler, so the timer never fired. The direct child (`pnpm` → `turbo`, or `sh`) dies on SIGTERM immediately, so any **descendant** that traps SIGTERM outlived the guard — the shape `ObjectKernelConfig.gracefulShutdown` installs in every kernel a test boots. The guard now waits for the process group to actually empty and SIGKILLs the holdouts, naming them in the log.

0 commit comments

Comments
 (0)