Skip to content

Commit de5da8b

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-4876-responsive-retire
2 parents bf73bf2 + c5adfe1 commit de5da8b

73 files changed

Lines changed: 5566 additions & 298 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: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
---
2+
"@objectstack/plugin-approvals": patch
3+
---
4+
5+
fix(approvals): an approval decision can no longer strand a flow run silently when no automation engine is attached (#4420)
6+
7+
#4420's fix closed every path by which a decision could be recorded while its
8+
flow stayed parked — except one, and it is the one where none of the new guards
9+
could run. Every guard it added (`assertRunResumable`'s pre-flight, the
10+
`RESUME_TARGET_LOST` refusal, the `RESUME_FAILED` throw) hangs off the
11+
automation engine. In a process where **no engine is attached**, all of them
12+
were skipped by the same `typeof this.automation?.resume === 'function'`
13+
condition that wrapped the resume itself — so the decision was written, the
14+
mirrored status field advanced, and the call answered HTTP 200 with
15+
`resumed: false` and **nothing logged at all**. That is #4420's reported
16+
symptom exactly, reproduced in the one composition its fix could not see.
17+
18+
The composition is reachable the same way the original bug was: a flow parks at
19+
an `approval` node in a process that has the automation service, and the
20+
decision arrives in one that does not (the plugin failed to init, or the host
21+
was recomposed between releases). The request row still carries a
22+
`flow_run_id` — which is the row's own declaration that a run is parked on this
23+
decision.
24+
25+
**What changes.** The decision still stands. Rolling it back is not on the
26+
table (a human really decided, and the row is durable by then), and refusing
27+
every such call would break the standalone approvals compositions the
28+
pre-flight deliberately protects — so `finalized` and `resumed` are unchanged
29+
for every existing caller. What changes is that the gap is no longer silent:
30+
31+
- it is logged at **`error`**, per the durability rule in `AGENTS.md`
32+
persisted state and runtime state disagree while nothing looks broken from
33+
the outside, which is the class that rule exists for;
34+
- the response carries **`resumeError`**, so `resumed: false` arrives with its
35+
reason and the stranded run's id instead of leaving the caller to guess
36+
whether a resume was even attempted.
37+
38+
It reuses the already-registered `RESUME_FAILED` code and the existing resume
39+
message shape rather than introducing a new vocabulary — the fact being
40+
reported (an outcome recorded whose run did not advance) is the same one.
41+
42+
Applied at all five sites that resume a recorded outcome: `decide`, the
43+
revision-limit auto-rejection, `sendBack`, `resubmit`, and both branches of
44+
`recall` (whose revise-window path needs `cancelRun` rather than `resume`).
45+
46+
A request that names **no** run is unaffected and stays quiet — there is
47+
nothing parked on it, and reporting one there would be the mirror-image
48+
failure that trains operators to skim `error`.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
---
3+
4+
docs(plugin-auth): the two admin-identity audit comments no longer claim better-auth's writes bypass the ObjectQL lifecycle hooks plugin-audit subscribes to (#4940). `writeAdminAudit` in `admin-user-endpoints.ts` and the run-level insert in `admin-import-users.ts` both justified their explicit `sys_audit_log` row with the same stale mechanism claim #4802 refuted one layer up: *better-auth writes bypass the ObjectQL hooks that plugin-audit subscribes to, so admin identity operations would otherwise leave no compliance trail*.
5+
6+
Re-verified, then **measured** rather than reasoned about: the adapter writes with a plain `dataEngine.insert(...)`, `ObjectQL.insert()` fires `triggerHooks('afterInsert')` inside `executeWithMiddleware()`'s executor, and plugin-audit registers `writeAudit` as a bare `engine.registerHook('afterInsert', …)` with no object filter. Driving the real routes against the showcase stack with `AuditPlugin` installed: `POST /admin/create-user` leaves **two** `action: 'create'` rows on the new `sys_user` (the explicit one and plugin-audit's row snapshot) plus an `update` row for the must-change-password stamp; each imported user likewise gets plugin-audit's own per-row `create` row.
7+
8+
The explicit rows are **kept** — the discipline was right, only the reason was wrong — and the reasons are now the ones that hold. plugin-audit is an **optional** plugin, so without it there is no `sys_audit_log` table at all. `sys_account` **is** in plugin-audit's `SKIP_OBJECTS`, so the credential write behind `/admin/set-user-password` produces **zero** generic rows: that explicit row is the only trail an administrative password reset leaves, and deleting it on the strength of "the hook covers it" would silently destroy it. And the import's run-level row (`action: 'import'`, `record_id: null`) is a shape plugin-audit's `actionFor` cannot emit, answering what no per-row ledger can — who ran which import, and what it did overall.
9+
10+
The stale sentences are **refuted in place rather than deleted**, matching #4802's handling: the claim was copied into cloud's agent-facing docs (cloud#1022), and a reader arriving from a copy needs to see it named and corrected instead of finding silence.
11+
12+
Every measured fact is pinned by a new gate — `packages/qa/dogfood/test/admin-identity-audit-trail.dogfood.test.ts` — so the corrected comment is an assertion the suite enforces rather than a fresh unverified claim. Comments and tests only; no runtime behaviour changes, nothing released.
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
---
3+
4+
ci(deps): lift the `brace-expansion` pin to 5.0.9 so `Validate Package Dependencies` stops failing on every PR (#4945)
5+
6+
`GHSA-rgw5-rvv9-x895` (7.5 high) affects `brace-expansion` 5.0.8 — which is
7+
exactly the version the previous pin (`brace-expansion@>=5.0.0 <5.0.8: ^5.0.8`,
8+
added for `GHSA-mh99-v99m-4gvg`) had settled on. The OSV-Scanner step in
9+
`.github/workflows/validate-deps.yml` reads `pnpm-lock.yaml` directly and exits
10+
non-zero on any match, so the job was red on `main` itself and attached that red
11+
to every PR that touched a manifest or the lockfile, whatever the PR contained
12+
(observed on #4944, which never touched `pnpm-lock.yaml`).
13+
14+
The `pnpm-workspace.yaml` override bound moves to `<5.0.9` / `^5.0.9`. It stays a
15+
transitive-only pin — nothing declares `brace-expansion` directly; it arrives via
16+
`minimatch` (ts-morph, eslint, `@typescript-eslint`, glob, `@vscode/vsce`,
17+
archiver), so no published manifest changes and `check-override-consistency`
18+
still has nothing to reconcile. 5.0.8 disappears from the lockfile entirely; the
19+
three `minimatch` snapshots that referenced it now resolve 5.0.9.
20+
21+
The reason to fix this on its own rather than let it ride along with the next
22+
dependency PR is the one the issue names: a permanently red required check
23+
trains everyone to scroll past it, and the next real advisory will look exactly
24+
like this one in the PR list.
25+
26+
Lockfile and override metadata only; releases nothing.
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
---
3+
4+
chore(tooling): 把 `docs/` 的活语料纳入 doc-authoring 守卫,历史快照按路径豁免 (#4929)
5+
6+
`scripts/check-doc-authoring.mjs``ROOTS` 一直是 `['.claude', 'skills', 'content']`
7+
#4916 修的是「声明了但解析不到的 root」;这条是它的**对称方向** —— 一个真实存在、
8+
真的在教 metadata 编写、却从来没被声明过的目录。`docs/` 就是那个目录:
9+
`docs/notes/crm-development-standards.mdx` 单独一份就有 16 个 ts 围栏块,
10+
ADR-0010 / 0015 / 0017 / 0057 和 `docs/design/permission-model.md` 里都有 `defineX(...)`
11+
AGENTS.md Prime Directive #13 要求每个 agent 在改动 ADR 治下的行为前先 grep ADR,
12+
所以一份 ADR 里的裸字面量会被下一个 agent 原样抄进 app 代码,和 `skills/` 里的坏样本没有区别。
13+
14+
`ROOTS` 现为 `['.claude', 'docs', 'skills', 'content']`。取 `docs` 而不是三个子目录,
15+
理由与 #4913`.claude` 而非 `.claude/skills` 相同:以后新增的子目录**一进来就在范围内**,
16+
不会以同样的方式被漏第二次;手写的顶层指南(`docs/protocol-upgrade-guide.md`
17+
`docs/upgrading-to-11.md` 等)也因此在内。扫描文件数 219 → 360。
18+
19+
`docs/audits``docs/handoff``docs/plans`#4915`SKIP_PATHS` 机制按路径排除。
20+
它们是有日期的一次性过程记录 —— 某一天写下的、关于仓库当天状态的审计/交接/计划,
21+
没有任何一段是以「照这样写」提供给读者的。把它们纳入等于让两个月前的 handoff 永久
22+
受今天的 lint 约束,而那种红只有两条出路:改记录(等于伪造史料),或者晚一场争论之后
23+
照样加豁免。脚本注释写明了这是**永久豁免、不是待办**,判断线是「这份文档现在是否在教
24+
你怎么写 metadata」,不是「它是否在 docs/ 下」。
25+
26+
纳入时全仓零违规,现在纳入是零成本 —— 这正是纳入的最佳时机。双向证明折进了常驻
27+
`--self-test`:`docs/adr` 里的裸字面量必须判红,**同样内容**放进三个豁免目录必须保持绿。
28+
两个方向各自单独成立时都会被一个方向错误的范围满足,所以两半一起断言。
29+
30+
纯工具链改动,不发布任何包。
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
'@objectstack/service-automation': patch
3+
'@objectstack/core': patch
4+
---
5+
6+
fix: 节点执行与热重载 shutdown 的超时守卫在 race 落定时被清除,不再留下孤儿定时器 (#4952)
7+
8+
#4813(PR #4874,内核 init/start)与 #4875(PR #4950,周期性健康检查)修掉的是同一种漏法:
9+
守卫 armed 之后就被扔掉 —— 被守护的一方赢下 race 之后,那根 `setTimeout` 既没 `clearTimeout`
10+
也没 `unref()`,带着 ref 一直把事件循环钉满整个超时预算。本次清仓剩下的两处生产实例:
11+
12+
- **`AutomationEngine.executeWithTimeout()`**(`service-automation`)—— 三处里量级最大的一处:
13+
**每个声明了 `timeoutMs` 的流程节点各一根**,孤儿数随流程节点数 × 触发频率线性增长;一次性进程
14+
(`os` CLI 跑到 flow 的路径)干完活之后还会被最长的那根守卫按住到超时才退出。
15+
- **`HotReloadManager.reloadPlugin()`**(`core`)—— 插件 `destroy()` 的 shutdown 守卫,与 #4813
16+
修掉的两处一字不差:一次毫秒级完成的热重载,照样把循环钉满 `shutdownTimeout`
17+
18+
两处修法与 #4874 / #4950 同形,不新造变体:私有 helper +
19+
`try { return await Promise.race([...]) } finally { clearTimeout(guard) }``hot-reload.ts`
20+
helper 把入参放宽到 `T | PromiseLike<T>`(Plugin 契约允许同步 `destroy()`);`engine.ts` 的不放宽
21+
(`NodeExecutor.execute` 声明返回 `Promise`)。
22+
23+
**为什么是 `clearTimeout` 而不是 `unref()`** `unref()` 让定时器不再钉住事件循环的同时,也让它
24+
不再是一个守卫 —— 若被守护的一方永不 settle 且没有别的东西撑着事件循环,Node 会在定时器触发之前
25+
退出,超时被静默吞掉。守卫必须在 race 未决期间保持 ref'd、在落定那一刻被回收,这正是
26+
`finally { clearTimeout(guard) }` 表达的语义。两处的回归测试各自沿用 #4950 的双向写法:
27+
真实定时器下不留 ref'd 定时器、fake timers 下连跑多轮不累积(计数能看见 `unref()` 过的定时器,
28+
因此识破 `unref()` 式的假修复)、以及被守护方真的挂住时超时照常上报。
29+
30+
超时时长(`timeoutMs` / `shutdownTimeout`)一个都没动 —— 问题从来不在时长,而在没人回收。
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
---
2+
"@objectstack/objectql": minor
3+
"@objectstack/lint": minor
4+
---
5+
6+
fix(objectql,lint): enforce parent-scoped `readonlyWhen` on the server (#4889)
7+
8+
`readonlyWhen: P\`parent.status == 'paid'\`` — the documented "once the header
9+
invoice is Paid, its lines are frozen" lock — was enforced **only in the client
10+
grid**. The server-side strip bound `record` and `previous` and had no `parent`
11+
at all, so every parent-scoped predicate faulted, took the fail-open branch, and
12+
the write landed anyway. On the reference app that meant one `PATCH` rewrote the
13+
quantity and unit price of a settled invoice's line: HTTP 200, value persisted,
14+
the grid still drawing the cell read-only. ADR-0057 D10 puts enforcement on the
15+
server and makes the client courtesy; here only the courtesy layer enforced.
16+
17+
**`parent` is now bound on the write path.** For a detail object — one declaring
18+
exactly one `master_detail` relationship — the engine resolves the master record
19+
and binds it as `parent` before the strip runs, on both the single-id and the
20+
bulk (`multi: true`) update paths. A repointing write is judged against the
21+
master it *lands on*, not the one it leaves. The read is gated on the payload
22+
actually touching a parent-scoped predicate (decided from the parsed CEL AST, so
23+
a field named `parent_id` costs nothing), and the bulk path batch-reads the
24+
distinct headers in one query rather than one per row.
25+
26+
**An unbindable scope no longer waives the lock.** A `readonlyWhen` that names a
27+
root the operation could not bind now resolves to **locked** — the field is
28+
stripped — instead of "not locked". "The platform could not check this" must not
29+
mean "allowed" on a field the author declared frozen. This is deliberately the
30+
narrowest possible carve-out from the fail-open policy the strip has always had:
31+
a predicate that is merely *broken* on the record (undeclared key, `null`
32+
ordering overload, parse error, engine throw) still fails open exactly as
33+
before, and `requiredWhen` / option `visibleWhen` are untouched. Recorded as an
34+
addendum to ADR-0058's D5 fail-policy matrix, alongside the same narrowing
35+
already made for validation predicates (#4649) and hook conditions (#4775).
36+
37+
**And the runtime branch is a backstop, not the plan.** `objectstack compile`
38+
now **rejects** a `parent`-scoped `readonlyWhen` on an object that declares no
39+
`master_detail` relationship, or two of them (where the metadata does not say
40+
which one is "the parent" and picking by declaration order would make a
41+
data-integrity lock depend on field ordering). The common authoring mistake is
42+
caught where it is cheap to fix, so it never reaches a runtime that has to judge
43+
it — declared, not guessed.
44+
45+
No metadata changes are required: an app whose parent-scoped locks were already
46+
correct simply starts having them enforced. If you authored one on an object
47+
with no single master, the build now names it.
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
---
2+
"@objectstack/lint": minor
3+
---
4+
5+
feat(lint): reject a sharing-rule condition the runtime can only skip (#4698)
6+
7+
#4698 reported the same failure shape three times in one app in one day: a key
8+
that is authored, is schema-valid, reads as meaningful — and is never consumed
9+
by the runtime. Every check verifies that what is declared is *well-formed*,
10+
never that it is *read*. The issue's third measured instance is a sharing rule
11+
whose CEL `condition` uses `has(...)`: the seeder cannot lower it, skips the
12+
rule, and the only signal is one WARN line at boot. The rule exists in
13+
metadata, is absent from `sys_sharing_rule`, and grants nothing.
14+
15+
**New rules, both `error`, on all three authoring commands:**
16+
17+
- **`sharing-rule-unlowerable-condition`** — the condition is outside the
18+
pushdown subset: a function call (`has(...)`, `size(...)`), arithmetic, a
19+
ternary, or a cross-object path (`record.account.region`).
20+
- **`sharing-rule-runtime-variable-condition`** — the condition reads
21+
`current_user.*`. Criteria sharing rules are materialised (one static
22+
`criteria_json` per rule, from which grants are written), so there is no
23+
"current user" at compile time. The fix is a different mechanism, not a
24+
different spelling, which is why it has its own id.
25+
26+
Fix each by rewriting the predicate inside the lowerable subset — `==` `!=`
27+
`>` `<` `>=` `<=`, `in`, `&&` `||` `!`, `== null` / `!= null`, and
28+
`startsWith` / `endsWith` / `contains` over single-column `record.<field>`
29+
paths (ADR-0058 D2). Two specific migrations: `has(record.x)` → `record.x !=
30+
null` (`has()` is correct in an object *validation* rule, which is
31+
interpreted, and wrong here, where the condition is compiled); and a related
32+
record's field → denormalise it onto this object (formula/rollup) and test
33+
that column, or share the related object instead. For per-user access, use an
34+
RLS policy (`rowLevelSecurity[].using`), where `current_user.*` *is* resolved.
35+
36+
**Why this one surface and not "unread keys" in general.** "Is this key read?"
37+
is only a lint question when the answer is computable from the authored
38+
metadata alone, and usually it is not — a repo-wide grep for a reader is not
39+
evidence of absence, and a consumer may live in another package, another repo,
40+
or an uninstalled plugin. A sharing rule's `condition` is the case where the
41+
predicate is exact: its one runtime consumer
42+
(`bootstrapDeclaredSharingRules`) does exactly one thing with the key —
43+
`compileCelToFilter(condition, { variables: {} })` — and a condition that does
44+
not lower means the rule is skipped outright. So the lint calls that same
45+
compiler, from the same package, with the same options, instead of modelling
46+
the consumer; the verdict is identical to the seeder's by construction and is
47+
pinned in both directions by a test over a shared corpus.
48+
49+
`error` rather than advisory, per the ADR-0078 claim `SharingRuleSchema`'s own
50+
docblock makes ("the whole authorable surface is enforced — nothing here
51+
validates and then silently does nothing"): there is no reading under which an
52+
unlowerable condition does what it says. It fails closed, which is why it was
53+
survivable, not why it was acceptable. Measured before shipping: every
54+
sharing-rule condition declared anywhere in this repo lowers cleanly, so the
55+
gate turns nothing red that works today.
56+
57+
CEL *syntax* errors are deliberately left to `expression-invalid`, which
58+
already gates this same field with a message written about syntax.

0 commit comments

Comments
 (0)