Skip to content

Commit f3b85e1

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-4769-adr0104-attest-after-seed
2 parents ccafecd + 941dec4 commit f3b85e1

24 files changed

Lines changed: 1002 additions & 99 deletions
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
"@objectstack/spec": major
3+
---
4+
5+
BREAKING(spec): `@objectstack/spec/studio` 改名 `ActionLocationSchema``ActionContributionLocationSchema`;裸名 `ActionLocation(Schema)` 现在全包唯一地指 `@objectstack/spec/ui` 的应用 UI 位置词表 (#4737, #4535 C17)
6+
7+
`ActionLocationSchema` 曾由 `./studio``./ui` 各自导出一个声明 —— 同名、词表完全互斥的**两个概念**(#4411 陷阱):
8+
9+
| 入口 | 词表 | 语义 | 处置 |
10+
|:--|:--|:--|:--|
11+
| `./studio`(**改名**) | `toolbar` / `contextMenu` / `commandPalette`(3 值) | Studio IDE 外壳里插件 action contribution 出现的位置(唯一嵌入方 `ActionContributionSchema.location`) |`ActionContributionLocationSchema`,枚举值逐字不变;新增 `ActionContributionLocation` 类型导出(旧 const 从无 type 导出) |
12+
| `./ui`(**一字不动**) | `list_toolbar` / `list_item` / `record_header` / `record_more` / `record_related` / `record_section` / `global_nav`(7 值) | 运行中应用的 UI 上 action 渲染的位置,docblock 自宣全平台唯一真源 | 裸名唯一归属(objectui 按引用钉住 `ACTION_LOCATIONS` 并 re-export 类型族) |
13+
14+
## FROM → TO
15+
16+
```ts
17+
// FROM —— 编译期起以 TS2305 失败(实测 objectstack / cloud / objectui 三仓零外部 importer,预期无人受影响)
18+
import { ActionLocationSchema } from '@objectstack/spec/studio';
19+
20+
// TO —— 同一声明、同一词表,名字点明它唯一的语义
21+
import {
22+
ActionContributionLocationSchema,
23+
type ActionContributionLocation,
24+
} from '@objectstack/spec/studio';
25+
```
26+
27+
**要的是应用 UI 的 action 位置?** `import { ActionLocationSchema, type ActionLocation } from '@objectstack/spec/ui'` —— 本次未动。
28+
29+
不保留旧名别名:在 `./studio` 上 re-export 任何一侧的 `ActionLocationSchema` 都会重开本次关闭的陷阱(要么复活双源,要么把应用 UI 词表谎报成 Studio 清单词表)。
30+
31+
## 零元数据迁移
32+
33+
本次只动 TS 导出名与内部 JSON Schema def 名(`studio/ActionLocation``studio/ActionContributionLocation`,走 `RENAMED_DEFS` 承接表,0-key carry —— 枚举 def 无 authorable properties)。作者在 Studio 插件清单里写的 `contributes.actions[].location` 取值域(`toolbar` / `contextMenu` / `commandPalette`)逐字节不变,已有清单原样解析。无 tombstone(没有 key 退役)、无 ADR-0087 conversion —— `StudioPluginManifestSchema` 是根 schema,不在 stack 树上,conversion walker 到不了它(`converge-activation-event-schema` 先例论证)。发布的 JSON Schema `$id` 随之移动:`…/studio/ActionLocation.json``…/studio/ActionContributionLocation.json`
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
---
2+
"@objectstack/service-storage": patch
3+
---
4+
5+
fix(service-storage): an UNSCOPED multi-delete of `sys_attachment` is refused instead of authorized (#4757)
6+
7+
`installAttachmentAccessHooks`'s `beforeDelete` gate resolved the rows a delete
8+
matches in two ways — by `input.id`, or by `input.options.where` — and then
9+
short-circuited with `if (!rows.length) return`. A delete carrying **neither**
10+
an id **nor** a `where` took neither branch, so `rows` stayed empty and the gate
11+
returned *allow*. That is not "nothing matched": nothing was ever queried.
12+
13+
The engine reads the same call as a bulk delete over everything — with no
14+
single id it seeds the delete AST as `{ object }` and hands that to
15+
`driver.deleteMany` — so `ql.delete('sys_attachment', { multi: true })` emptied
16+
the whole attachment table with the record-level gate having authorized exactly
17+
zero rows. Neither layer underneath catches it: plugin-sharing composes no
18+
row-scoping predicate for an object with no owner field (`sys_attachment`'s
19+
provenance column is `uploaded_by`), and plugin-security only refuses callers
20+
whose grants lack the delete bit on `sys_attachment` — an app shipping the
21+
domain grant the attachments panel requires passes RBAC and lands here.
22+
23+
The gate now fails **closed** on that shape: no id and no `where` is refused
24+
with 403 `ATTACHMENT_DELETE_DENIED` ("Refusing an unscoped multi-delete of
25+
attachments — scope the delete to the rows you mean"), the posture #4630 gave
26+
`sys_comment` in `resolveTargetRows`. "Nothing to authorize" and "nothing was
27+
ever queried" are different verdicts, and reading the second as the first is
28+
fail-open.
29+
30+
Scoped deletes are unchanged: an id-bound delete, a `where`-bound multi-delete,
31+
and even `where: {}` (which matches every row but is a real query) still resolve
32+
their rows and authorize each one uploader-or-parent-editor as before — a delete
33+
that legitimately matches no row still passes. Only the predicate-less call is
34+
newly refused. If you were relying on `ql.delete('sys_attachment', { multi:
35+
true })` to clear the table, pass a predicate (`{ multi: true, where: {} }`
36+
authorizes row-by-row) or perform the sweep under a system context, which
37+
bypasses the gate as before.
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
---
2+
'@objectstack/objectql': minor
3+
---
4+
5+
**A declarative hook `condition` is now evaluated against the RECORD — the stored row overlaid with this write's payload — not against the update payload alone (#4770).**
6+
7+
⚠️ **Behaviour change — read this before upgrading.** The condition gate used to evaluate
8+
against `ctx.input.data`: only the fields the current write happened to carry.
9+
`ctx.previous` sat behind it, unreachable, and the two were never merged. So a condition
10+
could reference only a field the update *happened* to touch; referencing anything else
11+
aborted the CEL expression with `No such key` — which the gate swallowed into `false`,
12+
leaving one WARN line as the sole trace.
13+
14+
For a guard-style hook that reads as "let it through"; for an audit-style hook it reads as
15+
"do not record it". `condition: "record.done == true"` on an audit hook therefore did NOT
16+
run on the most ordinary updates there are — change the status, change the assignee —
17+
because `done` was not in the payload.
18+
19+
The record a condition reads is now built the same way a validation predicate's is
20+
(#1871 / #4649, via one shared helper so the two cannot drift):
21+
22+
- **stored ⊕ payload** — the prior record overlaid with this write's data, so a condition
23+
may reference any field of the record, not just the changed ones. The payload still
24+
wins for the fields it carries.
25+
- **total over the object's DECLARED fields**`null` for a declared field present in
26+
neither, so a driver that stores only the columns it wrote no longer decides whether an
27+
expression is evaluable.
28+
- **declared fields only** — an undeclared or typo'd key (`record.stauts`) stays
29+
unevaluable and is still reported, exactly as before.
30+
31+
Materialisation happens only when the persisted state is actually in hand — an insert, or
32+
an update whose prior row was fetched. A predicate (`multi: true`) bulk update carries no
33+
prior row, so its payload is left as it is rather than gaining `null`s that would
34+
contradict the stored rows. No code path fetches a record it did not already load.
35+
36+
**What you may see after upgrading**
37+
38+
- **Conditions that never fired start firing.** A hook gated on a field the payload rarely
39+
carried was silently skipped; it now evaluates. This is the declaration finally being
40+
honoured, but expect hooks to run on writes where they previously did not.
41+
- **A condition is now about the record's STATE, not about this write's diff.**
42+
`record.done == true` fires on every update of a task that *is* done, not only on the
43+
update that set it. A condition cannot express a transition today — the CEL scope binds
44+
`record` only.
45+
- **Conditions guarded with `has(...)` need `!= null`.** `has(x)` asks whether the key is
46+
**present**, and a declared field holding `null` is present — so
47+
`has(a) && has(b) && a > b` still faults on `null > null`. Same lesson as #4649:
48+
49+
```diff
50+
- condition: 'has(record.spent) && has(record.budget) && record.spent > record.budget'
51+
+ condition: 'record.spent != null && record.budget != null && record.spent > record.budget'
52+
```
53+
54+
`has()` remains correct for asking whether an **undeclared** key exists.
55+
56+
**Unchanged, deliberately:** what happens when a condition is *still* unevaluable after
57+
merging — it is logged at WARN and treated as `false`, as before. Whether that fallback
58+
should differ by hook category (a guard fails open, an audit fails silent) is a separate
59+
decision, tracked on its own issue.

content/docs/references/studio/action.mdx

Lines changed: 0 additions & 29 deletions
This file was deleted.

content/docs/references/studio/meta.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
{
22
"title": "Studio Protocol",
33
"pages": [
4-
"action",
54
"flow-builder",
65
"object-designer",
76
"plugin",

content/docs/references/studio/plugin.mdx

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,8 +100,8 @@ modes: ['preview', 'design', 'data'],
100100
## TypeScript Usage
101101

102102
```typescript
103-
import { ActionContributionSchema, CommandContributionSchema, MetadataIconContributionSchema, MetadataViewerContributionSchema, PanelContributionSchema, PanelLocationSchema, SidebarGroupContributionSchema, StudioPluginContributionsSchema, StudioPluginManifestSchema, ViewModeSchema } from '@objectstack/spec/studio';
104-
import type { ActionContribution, CommandContribution, MetadataIconContribution, MetadataViewerContribution, PanelContribution, SidebarGroupContribution, StudioPluginContributions, StudioPluginManifest, ViewMode } from '@objectstack/spec/studio';
103+
import { ActionContributionSchema, ActionContributionLocationSchema, CommandContributionSchema, MetadataIconContributionSchema, MetadataViewerContributionSchema, PanelContributionSchema, PanelLocationSchema, SidebarGroupContributionSchema, StudioPluginContributionsSchema, StudioPluginManifestSchema, ViewModeSchema } from '@objectstack/spec/studio';
104+
import type { ActionContribution, ActionContributionLocation, CommandContribution, MetadataIconContribution, MetadataViewerContribution, PanelContribution, SidebarGroupContribution, StudioPluginContributions, StudioPluginManifest, ViewMode } from '@objectstack/spec/studio';
105105

106106
// Validate data
107107
const result = ActionContributionSchema.parse(data);
@@ -122,6 +122,17 @@ const result = ActionContributionSchema.parse(data);
122122
| **metadataTypes** | `string[]` || Applicable metadata types |
123123

124124

125+
---
126+
127+
## ActionContributionLocation
128+
129+
### Allowed Values
130+
131+
* `toolbar`
132+
* `contextMenu`
133+
* `commandPalette`
134+
135+
125136
---
126137

127138
## CommandContribution

examples/app-showcase/src/data/hooks/index.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -63,11 +63,15 @@ export const WarnOverBudgetHook = {
6363
label: 'Warn On Over-Budget Project',
6464
object: 'showcase_project',
6565
events: ['afterUpdate'] as LifecycleEvent[],
66-
// Guard with has(): an afterUpdate fired by a partial write (e.g. the
67-
// task-rollup that only touches task_count) carries a record WITHOUT
68-
// spent/budget, and CEL throws "No such key" on a bare `record.spent`.
69-
// has() is the missing-key-safe macro — the hook simply skips those.
70-
condition: "has(record.spent) && has(record.budget) && record.spent > record.budget",
66+
// Guard with `!= null`, NOT with `has()` (#4770, same lesson as #4649). A
67+
// condition is evaluated against the STORED record overlaid with this
68+
// write's payload, made total over the object's declared fields — so a
69+
// partial write (the task-rollup that only touches task_count) still sees
70+
// spent/budget, and `has(record.spent)` is uniformly TRUE for a declared
71+
// field, including one holding null. Only `!= null` actually keeps
72+
// `null > null` — which CEL has no overload for — from aborting the
73+
// expression.
74+
condition: "record.spent != null && record.budget != null && record.spent > record.budget",
7175
body: {
7276
language: 'js' as const,
7377
source: "var r = ctx.result || ctx.input || {}; ctx.log.warn('project over budget: ' + (r.name || r.id || 'unknown') + ' (' + r.spent + ' / ' + r.budget + ')');",
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Make a record TOTAL over an object's DECLARED fields.
5+
*
6+
* Shared by the two places that evaluate a CEL expression against "the
7+
* record": object-level validation predicates
8+
* (`validation/rule-validator.ts`, #1871 / #4649) and declarative hook
9+
* `condition`s (`hook-wrappers.ts`, #4770). They used to disagree — a
10+
* predicate saw a total record while a hook condition saw only the fields the
11+
* current write happened to carry — which is precisely the drift this module
12+
* exists to prevent: an author cannot be expected to know that the same
13+
* `record.done == true` means two different things depending on which surface
14+
* reads it.
15+
*
16+
* CEL is strict about missing keys: `record.x` on a record that does not carry
17+
* the key `x` aborts the whole expression with `No such key`, which is NOT the
18+
* same as reading `null`. Whether a key is carried is a property of the DRIVER
19+
* (a driver that stores only written columns returns a record missing every
20+
* column the write never touched), not of the data — so without this an
21+
* expression's evaluability depends on storage internals the author cannot
22+
* see.
23+
*
24+
* Scope is deliberately the object's **declared fields only**. Materialising
25+
* every key an expression happens to name would paper over author typos: a
26+
* `record.stauts` must stay unevaluable so it is reported (fail-closed for
27+
* validation, #4649) rather than silently read as `null` and quietly answered
28+
* "no violation" / "condition false".
29+
*
30+
* `undefined` counts as absent (not just a missing key): CEL treats an own key
31+
* holding `undefined` exactly as it treats no key at all.
32+
*
33+
* ## Only ever call this when the record's persisted state is IN HAND
34+
*
35+
* On insert there is nothing to know — absence genuinely means "no value". On
36+
* update it is knowable only when the prior row was actually fetched. Without
37+
* it, defaulting a declared field to `null` would not be materialising an
38+
* absent value, it would be FABRICATING one that contradicts the stored row.
39+
* Callers decide; this function only applies the rule.
40+
*
41+
* ## Consequence worth knowing before writing an expression
42+
*
43+
* Because a declared field is always present afterwards, `has(record.<declared
44+
* field>)` is uniformly TRUE (a materialised `null` is a present key holding
45+
* null — CEL's own rule). `has()` therefore guards against an UNDECLARED key,
46+
* not against an empty value; test emptiness with `record.x != null`.
47+
*/
48+
export function materializeDeclaredFields<T extends Record<string, unknown>>(
49+
record: T,
50+
fields: Record<string, unknown> | undefined | null,
51+
): T {
52+
if (!fields || typeof fields !== 'object') return record;
53+
const target = record as Record<string, unknown>;
54+
for (const name of Object.keys(fields)) {
55+
if (target[name] === undefined) target[name] = null;
56+
}
57+
return record;
58+
}

0 commit comments

Comments
 (0)