Skip to content

Commit 35a111e

Browse files
committed
Merge origin/main into claude/issue-4001-automation-batch11
Ledger conflict was expected and is the three-way `automation/` header the dispatch note warned about: 批 9 (#4925) landed on main while this branch was open, and both sides edit the same section. Resolved by keeping BOTH sides' row edits and recomputing the header from the merged rows rather than taking a side: 批 9 removed builtin-node-config (8) / schemaless-node-config (4) / io-node-config (2), and rewrote their triage notes 批 11 removed flow-function (1) / time-relative-trigger (1) / webhook (1), and dropped flow.zod.ts 7 → 1 header 67 → 44 strip of 75 (13+10+1+6+5+5+4, summed from the rows) authorable 41 → 18 (etl 7 + state-machine 6 + control-flow 5) `check:strictness-ledger` arbitrates that arithmetic and passes; the header was NOT copied from either side. os-regen four-step ran in full: merge → `git checkout origin/main --` every `merge=os-regen` path → `pnpm install --frozen-lockfile` + rebuild spec + `check:generated --fix` (regenerated `content/docs/references/**` only) → assert BOTH sides survive. Sibling entries verified present after the wholesale regeneration: 批 9's NotifyConfig / HttpConfig / Script node-config pages and #4922's `data/SortNode` are all in `authorable-surface.json` beside this branch's `automation/Webhook:_packageId` / `:protection`. Merged-tree verification (§10: main touched packages/spec, the same package as this diff, so the full suite was re-run rather than scoped): check:generated 8/8 · all 8 pure audits PASS (incl. strictness-ledger) @objectstack/spec 297 files / 7450 tests · tsc --noEmit clean Part of #4001 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ehu85kbvMcrNTUJjwxvLJ9
2 parents de2979d + f61c8cf commit 35a111e

53 files changed

Lines changed: 4843 additions & 213 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: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
---
3+
4+
docs(plugin-auth): the `databaseHooks` doc comment no longer claims better-auth's adapter bypasses the ObjectQL middleware chain (#4802). The option's JSDoc in `auth-manager.ts` (and its two sibling copies — the wiring comment beside `composeDatabaseHooks`, and `AuthPluginOptions.databaseHooks` in `auth-plugin.ts`) justified "use `databaseHooks`, not an ObjectQL middleware" with a mechanism claim that no longer holds: *better-auth's adapter goes through `dataEngine` directly, bypassing the `ql.registerMiddleware` chain*.
5+
6+
Re-verified hop by hop against `main`: `ObjectQLPlugin` registers **one** engine instance under both service names (`registerService('objectql', this.ql)` and `registerService('data', this.ql)`, and nothing else in the repo registers `data`); `AuthPlugin` passes exactly that instance to `createObjectQLAdapterFactory`; the adapter writes with a plain `dataEngine.insert(objectName, …)` — there is no bypass or skip-middleware option to pass; and `ObjectQL.insert()` wraps its body in `executeWithMiddleware()`, whose only filter is the object name. So `ql.registerMiddleware(fn, { object: 'sys_user' })` **does** fire for better-auth's writes, and so do the engine's `beforeInsert`/`afterInsert` hooks — the SCIM identity-source stamp in `auth-plugin.ts` is built on precisely that.
7+
8+
The **rule is unchanged** — user-lifecycle invariants still belong in `user.create.after`, not in a `sys_user` middleware — but the reason is now the one that is actually true: **ADR-0093 D2**, one owner for the invariant on the one seam every creation path already flows through (self-signup, admin create-user, import, SSO JIT). The narrower fact that survives is written down instead of the false one: adapter writes carry `context.isSystem: true` (`withSystemContext`, pinned by `objectql-adapter.test.ts`), so every *authorization* middleware — security, sharing, the ADR-0092 identity write guard — early-returns by design; a middleware that gates on `isSystem` sees nothing, one that does not, runs.
9+
10+
The stale sentence is **refuted in place rather than deleted**, because it had been copied into cloud's agent-facing docs and had already killed the middleware option in two rounds of design work there (cloud#1012, handed over as cloud#1022). A reader arriving from one of those copies needs to see the claim named and corrected; a silent deletion would leave them assuming the framework comment is the stale one.
11+
12+
Comments only — no runtime behaviour changes, nothing released.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
---
3+
4+
Tooling-only: three more `scripts/check-*.mjs` gates now fail, by name, when a declared scan root cannot be resolved (#4930). Releases nothing — no package changes.
5+
6+
Same shape as #4916/#4934, in three more scripts: a walker that opens with `try { readdirSync(dir) } catch { return out; }` (or `catch { return []; }`) cannot distinguish "this root is clean" from "this root was never opened", because both produce zero findings. Each script now resolves every declared root up front and fails with the root's name and reason ("does not exist" / "exists but is not a directory") before drawing any conclusion from the scan, and the swallows inside the walks are gone — an error mid-walk means the corpus was only partly read, which must not print as a clean pass either.
7+
8+
- `check-single-authz-resolver.mjs``SCAN_ROOTS` (`packages/`). The duplicate-resolver scan was genuinely vacuous on a dead root; the script only went red by luck, because both `DELEGATORS` happen to live under that same root, so it reported "Delegator missing" for the wrong cause. Move a delegator, or add a second scan root, and the vacuous scan goes fully silent.
9+
- `check-startup-registry-verdict.mjs` — the `packages/` scan root. The old `existsSync` guard accepted a *file* and then blamed the empty corpus; and one level in, a directory the walk could not stat shrank the corpus while `files.length` stayed non-zero, so the loud empty-corpus guard never fired.
10+
- `check-driver-conformance.mjs``DRIVERS_DIR`, `CASE_SETS_DIR`, and each driver's `src/`. DISCOVERED/CLASSIFIED did catch a dead root, but as a *consequence* naming the wrong cause: a renamed `packages/spec/src/data` printed five "CASE_SETS names X, which <file> no longer exports" errors, which reads as five deliberate deletions rather than one directory that moved.
11+
12+
No whitelist and no `optional: true` flag in any of the three, deliberately — every one of these roots is a git-tracked directory with tracked files, so no checkout that can run these gates at the repo root is legitimately missing one. An optional marker would be a supported way to silence the failure instead of fixing the rename: the empty `catch {}`, spelled politely.
13+
14+
Each proof is bidirectional and permanent rather than a one-off in the PR description: every script's `--self-test` now renames a root away mid-run and requires red naming that root (and not the survivors), replaces one with a file and requires the `not a directory` verdict, dangles a symlink inside the walk and requires the error rather than a smaller corpus, then restores everything and requires green again. `check-single-authz-resolver.mjs` had no self-test at all and gained one, with its two original invariants driven over a real temporary tree; `package.json` chains `--self-test &&` into `check:authz-resolver` to match the other ten `check:*` entries.
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
---
2+
"@objectstack/objectql": minor
3+
---
4+
5+
feat(objectql): export the delete-dispatch contract so test doubles can be pinned to it (#4550)
6+
7+
A test double that is **looser** than the implementation it replaces converts a
8+
green suite into no suite at all — silently, and on exactly the paths a double
9+
was introduced for, which are the paths that were hard to test, which are
10+
usually where the contract is densest. #4434 is the worked example:
11+
`DELETE /api/v1/sharing/rules/:idOrName` answered 500 for every rule and both
12+
address forms it advertises, from the day it was written, while
13+
`deleteRule drops rule + all its grants` asserted success against it the whole
14+
time — against a fake engine whose `delete` accepted the one call shape
15+
`ObjectQL.delete` refuses.
16+
17+
`ObjectQL.delete`'s dispatch decision now lives in one exported place instead of
18+
being re-derived by every fake:
19+
20+
```ts
21+
import { assertEngineDeleteDispatch } from '@objectstack/objectql';
22+
23+
async delete(object: string, options?: any) {
24+
assertEngineDeleteDispatch(options); // refuses what a real server refuses
25+
26+
}
27+
```
28+
29+
New exports, all pure and side-effect free:
30+
31+
- `resolveEngineDeleteDispatch(options)``{ kind: 'by-id', id }` |
32+
`{ kind: 'multi' }` | `{ kind: 'reject', message }` — what the engine will do
33+
with this call, without doing it.
34+
- `assertEngineDeleteDispatch(options)` — throws exactly what the engine throws
35+
on `reject`, returns the dispatch otherwise. This is the line a fake engine's
36+
`delete` opens with.
37+
- `scalarDeleteId(options)` — the SCALAR `where.id` or `undefined`. The half a
38+
hand-written mirror drops: `where: { id: { $in: [...] } }` looks like an id
39+
and is a multi-row predicate, so the engine rejects it without `multi`.
40+
- `ENGINE_DELETE_REJECT_MESSAGE`, `ENGINE_DELETE_DISPATCH_CASES` — the message
41+
and the shared conformance case-set, the same role
42+
`packages/spec/src/data/*-conformance.ts` plays for drivers.
43+
44+
`ObjectQL.delete` itself reads `resolveEngineDeleteDispatch`, so a double that
45+
imports it cannot be looser than the engine, ever — that is the property, and
46+
it is the one a hand-mirrored `if` can only have until somebody edits one side.
47+
No runtime behaviour changes: the same three verdicts, over the same inputs,
48+
proved case-by-case against the real engine in
49+
`engine-delete-dispatch.test.ts`.
50+
51+
Repo-side, `pnpm check:engine-double-contract` (wired into `lint.yml`) finds all
52+
39 fake ObjectQL engines in the repo, holds new ones to this predicate, and
53+
keeps the 30 not yet converted in a measured, shrink-only baseline.
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
---
2+
"@objectstack/lint": minor
3+
---
4+
5+
feat(lint): null-guard 闸门覆盖 `requiredWhen`,其余各面按"绑定是否全量"逐一定案 (#4811)
6+
7+
#4763 的 null-guard 闸门只接了两面(对象校验规则、生命周期 hook `condition`),
8+
其余各面留作"待定"。本次把"待定"收敛成一条**可判定的判据**,并按它逐面定案 ——
9+
一个只覆盖部分面、又没有任何东西说出这件事的闸门,正是这一族缺陷本身的形状。
10+
11+
## 判据:记录绑定是否对已声明字段**全量**
12+
13+
这不是口味问题,也不是"这个谓词是不是 CEL"。实测 `@marcbachmann/cel-js`,两种绑定
14+
下的语义**恰好相反**:
15+
16+
| 谓词 | 全量绑定 `{a: null}` | 稀疏绑定 `{}` |
17+
|:--|:--|:--|
18+
| `has(record.a)` | `true` ← 陷阱 | `false` ← 真守卫 |
19+
| `record.a < record.b` | FAULT `no such overload` | FAULT `No such key: a` |
20+
| `record.a != null` | `false`**修法有效** | FAULT `No such key: a` |
21+
22+
即:全量绑定下 `has()` 恒真而无用、`!= null` 是解药;稀疏绑定下 `has()` 恰恰是正确的
23+
守卫,而 `!= null` **自身就会 fault**。把闸门指向一个稀疏绑定的面,等于判红正确的元数据、
24+
并给出一个会把它改坏的"修法" —— 比不覆盖更糟。所以:**只有绑定全量的面才可以接入。**
25+
26+
## 纳入:字段 `requiredWhen`
27+
28+
议题没有列出这一面,而它恰恰是唯一满足判据的:`evaluateValidationRules` 用与对象校验
29+
规则**同一个** `materializeDeclaredFields` 合并记录来求值 `requiredWhen`
30+
31+
它也是几个已覆盖面里失败得最安静的一个:`requiredWhen` 谓词 fault 时是 **fail-open** ——
32+
`rule-validator.ts` 记一行 `failed to evaluate — skipped` 就跳过,字段于是**从未真正必填**,
33+
写入照常通过。校验规则至少自 #4761 起是 fail-closed 的拒绝。因此报错文案按面区分后果:
34+
"被跳过、字段从未必填"与"写入被 fail-closed 拒绝"是两个相反的故障,作者需要知道自己
35+
碰到的是哪一个。
36+
37+
## 排除,且各自留下可引用的理由
38+
39+
- **action `visible` / `disabled`**:谓词确实走真 CEL(裸串经 `ExpressionInputSchema`
40+
规范成 `{dialect:'cel'}` 信封,渲染器保留它),fault 也确实 fail-closed —— 陷阱在这一面
41+
是真的。但绑定是客户端已取到的那条记录(详情读取,或只带列表视图投影列的一行),
42+
`objectui` 这条路径上不存在任何物化步骤。稀疏绑定下 `!= null` 是错的修法。要覆盖它,
43+
得先决定是否把该绑定做成全量 —— 那是平台契约改动,不是 lint 改动。
44+
- **flow / edge `condition`**:议题记的理由(扁平作用域下裸标识符可能是 flow 变量)对本
45+
模块**不成立** —— 它只解析 `record.<f>` / `previous.<f>`,从不解析裸标识符,而引擎无
46+
条件绑定这两个根。真正的阻碍还是全量性:`record-change-trigger.ts` 把记录播种为
47+
`{ ...inputDoc, ...after }`,没有 `materializeDeclaredFields`,所以写入未提及的已声明列
48+
**缺键**而非 null,`!= null` 会和它本要守卫的比较一样 fault。
49+
- **字段 `readonlyWhen`**:与 `requiredWhen` 同一个字段、相反的结论 —— 它由
50+
`stripReadonlyWhenFields` 求值,那里合并的是 `{ ...previous, ...data }`,从不物化。
51+
- **`Field.formula`**:按产品判断排除,而非按本判据。formula 是 `value` 角色、天然可空,
52+
`guard ? value : null` 是被祝福的写法(#3306)。是否强制守卫会改变"作者被允许写什么",
53+
该由维护者决定,不是一个接线缺口。
54+
55+
判据、实测表与逐面台账写在 `validate-null-guards.ts` 的模块注释里,每条排除在它对应的
56+
调用点也留了注释,并各配一条断言钉住。
57+
58+
## 顺带修正:`field '?'`
59+
60+
诊断的字段名此前走 `Object.values(fields)`,把**名字键**丢掉了 —— 而名字键正是
61+
`Field.text({…})` 这种(最常见的)写法产生的形状,于是这类对象上的每条字段级诊断都定位在
62+
`field '?'`。名字只出现在 `where` 里时还能忍;现在报错正文要告诉作者改哪个字段,就不能忍了。
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
---
2+
---
3+
4+
ci(release): add the objectui pin-freshness gate (#3340 P0). `scripts/check-objectui-pin-fresh.mjs` fails when `.objectui-sha` is not objectui `main` (or a named `--ref`), naming the commits ahead and the `.changeset/*.md` files declared after the pin — the blind spot that dropped four frontend changes, two of them `minor` features, from the v16 release page. Wired as `Console Pin Freshness` in `.github/workflows/objectui-pin-freshness.yml`: it runs on every PR so the context can be required in branch protection, but blocks only on the Version Packages / release PR. Distinct from ci.yml's `Console Pin Gate` (#4290), which proves the pin still *builds* rather than that it is still *current*. Tooling and CI only; releases nothing.
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
---
2+
"@objectstack/spec": major
3+
"@objectstack/metadata-protocol": major
4+
---
5+
6+
feat(spec,metadata-protocol)!: a sort node spelling its direction `direction` is a 400, not a silently reversed page (#4721)
7+
8+
**FROM → TO:** `orderBy: [{ field: 'updated_at', direction: 'desc' }]`
9+
`orderBy: [{ field: 'updated_at', order: 'desc' }]`. One word. If you are on the
10+
`{field, direction}` shape because you moved code over from
11+
`IReportService.orderBy`, that contract is unchanged — it is `orderBy` on the
12+
QueryAST / `EngineQueryOptions` axis that has always been `{field, order}`.
13+
14+
## What was wrong
15+
16+
`SortNodeSchema` was a plain `z.object`, so zod's default `.strip` applied.
17+
Measured on `main` before this change:
18+
19+
```
20+
SortNodeSchema.parse({ field: 'updated_at', direction: 'desc' })
21+
→ { field: 'updated_at', order: 'asc' }
22+
```
23+
24+
`direction` was discarded and `order` fell back to its `asc` default. The sort
25+
therefore ran in the **opposite** direction and the request succeeded. Paired
26+
with `limit` — which is how a caller asks for "the latest N" — that is not a
27+
reordered page but a **different set of rows**, returned under an ordinary 200
28+
with nothing in the response to distinguish it from the answer that was asked
29+
for.
30+
31+
`direction` is not a typo. It is the live vocabulary of a neighbouring contract,
32+
`IReportService.orderBy` (`@objectstack/spec/contracts`), and
33+
`plugin-auth/objectql-adapter.ts` already translates between the two by hand — a
34+
translation known to be necessary and enforced nowhere, which is the ADR-0049
35+
shape.
36+
37+
## What changed
38+
39+
Both doors onto that shape, in one change:
40+
41+
1. **`SortNodeSchema`** (`spec/src/data/query.zod.ts`) is now `strictObject`
42+
with `aliases: { direction: 'order' }`. An unknown key is rejected, and
43+
`direction` specifically gets the translation in the error message — edit
44+
distance can never bridge `direction``order`, so a bare "unrecognized key"
45+
would leave the caller exactly where the silent strip did.
46+
2. **`normalizeSortNodes`** (`metadata-protocol/src/protocol.ts`), the ingress
47+
every REST/RPC `orderBy` funnels through, refuses `{ field, direction }` with
48+
`400 INVALID_SORT` naming `order` and quoting the corrected node. Closing only
49+
the schema would repeat the door asymmetry of #1535/#4522: `SortNodeSchema` is
50+
reachable by three paths the REST normalizer never sees.
51+
52+
| `orderBy` you send | Before | After |
53+
|:--|:--|:--|
54+
| `[{ field: 'x', order: 'desc' }]` | descending | unchanged — descending |
55+
| `[{ field: 'x', direction: 'desc' }]` | **200, ascending** | `400 INVALID_SORT`, message names `order` |
56+
| `[{ field: 'x', order: 'desc', direction: 'asc' }]` | 200, descending | `400 INVALID_SORT` |
57+
| `'-x'` / `['-x']` / `{ x: 'desc' }` | descending | unchanged |
58+
| `{ direction: 'desc' }` (the `{field: direction}` map) | sorts by column `direction` | unchanged — a column may legitimately be called `direction` |
59+
60+
Scope is deliberately narrow: **`QuerySchema`'s top level is untouched** and
61+
still accepts undeclared keys (`QuerySchema.safeParse({ object: 'sales',
62+
nonsenseKey: 1 }).success === true`). That is tracked in the #4001 campaign map
63+
for its own batch, not smuggled in here.
64+
65+
Related: #4674, #4720, #4363, #4371, #4001, ADR-0049.
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
---
2+
"@objectstack/spec": major
3+
---
4+
5+
feat(spec)!: reject unknown keys on the flow-node config contracts (#4001 批 9)
6+
7+
The first `automation/` wave of the 2026-08-03 "necessary-and-complete"
8+
ruling. Fourteen strip sites across three files close, and `automation/`'s
9+
remaining-strip count drops 67 → 53 (authorable 41 → 27).
10+
11+
- **`automation/io-node-config.zod.ts`**`NotifyConfigSchema`,
12+
`HttpConfigSchema`.
13+
- **`automation/builtin-node-config.zod.ts`** — the CRUD quartet
14+
(`get_record` / `create_record` / `update_record` / `delete_record`),
15+
`ScreenConfigSchema`, `ScreenFieldConfigSchema` and its `options` item,
16+
`MapConfigSchema`.
17+
- **`automation/schemaless-node-config.zod.ts`**`ScriptConfigSchema`,
18+
`SubflowConfigSchema`, `DecisionConfigSchema`, `DecisionConditionSchema`.
19+
20+
The deliberately-open `FlowNodeSchema.config` SLOT is unchanged — ADR-0018
21+
keeps `node.type` open so plugins contribute their own executors, and closing
22+
the slot would close that extension point. What is closed is the per-node-type
23+
contract *inside* it.
24+
25+
**Why the third file is different.** `registerFlow()` already hard-rejects
26+
undeclared config keys against a node's descriptor `configSchema` (#4277), and
27+
`script` / `subflow` / `decision` publish no descriptor `configSchema` — so
28+
that walk skips them by construction. Until now those three had **no**
29+
unknown-key enforcement at any layer. For them this is the first gate, not a
30+
second one.
31+
32+
**Migration.** Every key now rejected was previously stripped and had no
33+
runtime effect, so removing or renaming one never changes behaviour. All three
34+
shipped example apps were re-validated after the change and no stored shape
35+
needed an ADR-0087 conversion (160 flow nodes walked, 52 carrying one of these
36+
contracts, 0 rejections). The rejections carry their own prescriptions:
37+
38+
- `notify`: `to``recipients`, `subject``title`, `body``message`,
39+
`url``actionUrl`, `source: { object, id }``sourceObject` + `sourceId`.
40+
- CRUD: `object``objectName`, `filters``filter`,
41+
`fieldValues``fields`, `recordId` → a filter VALUE
42+
(`filter: { id: '{record.id}' }` — no CRUD executor has ever read a
43+
`recordId` key), and on `update_record` / `delete_record` `outputVariable`
44+
is a documented absence, not a typo — read the row back with a following
45+
`get_record`.
46+
- `screen`: `object``objectName`, and on a field item
47+
`visibleIf``visibleWhen`.
48+
- `map` / `subflow`: `flow``flowName`. `subflow`'s `timeoutMs` belongs on
49+
the NODE (`FlowNodeSchema.timeoutMs`), not in its config.
50+
- `script`: `functionName``function`, `input``inputs` (the singular
51+
stays canonical on `connector_action`'s `connectorConfig.input` — do not
52+
"fix" that one). The five `actionType`-branch keys keep their existing
53+
`retiredKey()` tombstones.
54+
- `decision`: `config.condition` (singular) is **not** renamed to
55+
`conditions`. Nothing reads it on a decision — it is the trigger gate on a
56+
`start` node and inert everywhere else (#4414) — and declaring branches here
57+
*and* on the out-edges is the double-declaration #4414 was filed for.
58+
Branching lives on the out-edges. On a decision BRANCH the predicate slot is
59+
`expression`, so `condition``expression` there.
60+
- decision branch `target`: a VIRTUAL designer column projected from the
61+
node's out-edges, never stored — route by matching the branch `label` to an
62+
out-edge `label`.
63+
64+
For a key rewritten at load by an ADR-0087 D2 conversion, reaching this
65+
rejection means the config carries BOTH spellings: `renameConfigKey` leaves a
66+
shadowed alias in place rather than clobbering the canonical winner, so the
67+
retired twin is dead weight and should be deleted.

0 commit comments

Comments
 (0)