Skip to content

Commit 4aa1ebd

Browse files
Merge origin/main into claude/issue-6452-surface-base-shallow-anchor
#6459 (#6359's stop-gap) landed the shallow diagnostic this PR's fallback replaces. Resolution keeps the re-anchor branch and hands #6359's wording to the one arm it still describes — the tip fallback, reached only when no upstream anchor is usable. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AZgRyPVwi1jLb1mNNuUQ9o
2 parents d9b24bb + 4c9a85e commit 4aa1ebd

26 files changed

Lines changed: 1577 additions & 57 deletions
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): `ActionSchema.visible` / `disabled` speak one shape — `boolean | string(CEL) | {dialect, source}` (#5970)
6+
7+
An action's two condition keys accepted different vocabularies. `disabled` took
8+
all three arms; `visible` had no **boolean** arm, so `visible: true` — the most
9+
obvious thing an author can write, and a shape already present in stored
10+
metadata — was a parse error on the spec side while objectui's `ActionDef`
11+
accepted it and pinned it with tests.
12+
13+
Both keys now accept the same three arms, cheapest first:
14+
15+
| arm | example | meaning |
16+
|:---|:---|:---|
17+
| `boolean` | `visible: false` | the degenerate literal — settled at authoring time |
18+
| `string` | `disabled: "record.status == 'closed'"` | CEL shorthand, normalized to the envelope at parse time |
19+
| `{ dialect, source }` | `{ dialect: 'cel', source: '…', meta: { rationale } }` | the full envelope, for authorship metadata or a non-default dialect |
20+
21+
**Purely additive** — every shape that parsed before parses the same way, and
22+
every shape that was rejected is still rejected (an empty CEL string, a number,
23+
`null`, an envelope missing `dialect`, an envelope with neither `source` nor
24+
`ast`, an unknown dialect). No migration, no ADR-0087 disposition: nothing an
25+
author can write was removed or renamed.
26+
27+
The boolean arm is deliberately **not** normalized into
28+
`{dialect: 'cel', source: 'true'}`. A literal survives as a literal so a
29+
renderer can branch on it without standing up an evaluator, and `false` stays
30+
statically greppable.
31+
32+
**Why unify rather than leave it.** An asymmetry between two keys that mean the
33+
same *kind* of thing is a dialect nursery: it teaches every consumer to carry
34+
its own widening, and each of those is a second de-facto contract (Prime
35+
Directive #12). Console's `DeclaredActionsBar` was carrying exactly that as an
36+
`(action as any).disabled` cast. This change is what lets #4075 step 3 derive
37+
objectui's `ActionDef` from the spec schema and delete the casts.
38+
39+
**One new rejection, at the interaction with `requiresFeature`.** The
40+
declarative feature-gate sugar lowers into `visible`, so it now meets two
41+
literals it never could before, and boolean algebra decides them in opposite
42+
directions:
43+
44+
- `visible: true` + `requiresFeature: 'x'` → the gate alone. `true && <gate>` IS
45+
`<gate>`, so spelling the default out explicitly lowers exactly like omitting
46+
the key.
47+
- `visible: false` + `requiresFeature: 'x'`**parse error**. `false && <gate>`
48+
is `false` whatever the flag says, so the gate could never take effect and the
49+
declaration is inert on arrival — the parses-clean-changes-nothing shape
50+
ADR-0078 exists to reject. The message names both exits: drop
51+
`requiresFeature` to keep it hidden, or drop `visible: false` to let the flag
52+
decide. This combination was unwritable before (the boolean arm did not
53+
exist), so no stored metadata can carry it.
54+
55+
`bulkActions[].visible` is unchanged and keeps the two predicate arms only — a
56+
per-record eligibility predicate has nothing to say as a constant. Its
57+
description no longer claims shape-identity with `action.visible`.
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
Reorder the three hand-written `unrecognized_keys` error maps so the fix is read before the explanation (#6416, applying #5955's ruling).
6+
7+
`strictVisibilityError` (`shared/visibility.ts`), `strictWidgetAnalyticsError` (`ui/dashboard.zod.ts`) and `strictTenancyError` (`data/object.zod.ts`) are independent `$ZodErrorMap` functions rather than `strictUnknownKeyError` call sites, so #5955's reorder of the shared template did not reach them and #5593's `strictObject` migration cannot either. Each reproduced the exact shape #5955 was filed against: a non-actionable explanatory sentence sitting between the offending key and the prescription that fixes it, which on the single-line renders several consumers use (`os validate`'s `• where: message`, CI logs, `validateFlowTriggerReadiness`) pushed the fix out of the part an author actually reads.
8+
9+
Every message now emits front matter (which key is wrong) → every fix channel (the `visibleWhen` alias pointer; the ADR-0021 dataset / objectui-quarantine / #5022 drill branches; the per-key `tenancy` tombstone bullets) → the explanatory sentence last. Nothing is deleted and nothing becomes conditional — each sentence is still emitted verbatim, once per message, and all seven message variants are byte-identical in length and character multiset to their previous spelling. No input changes acceptance: these maps only shape the text of an already-failing parse, and the `visibility.ts` alias tables are untouched.
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
---
2+
"@objectstack/objectql": patch
3+
---
4+
5+
fix(objectql): 字段 `readonlyWhen` 在服务端看到的记录改为「对象声明的全量形状」(#4953)
6+
7+
`materializeDeclaredFields`(#1871 / #4649)此前只接在两个求值接缝上:
8+
`evaluateValidationRules`(对象级校验规则、字段 `requiredWhen`、option
9+
`visibleWhen`)与生命周期 hook 的 `condition`**字段 `readonlyWhen` 不在其中** ——
10+
写入路径上的 `stripReadonlyWhenFields` / `stripReadonlyWhenFieldsMulti` 直接把
11+
`{ ...previous, ...data }` 交给 CEL 求值。
12+
13+
后果是同一个字段上的两条谓词对「记录是什么」给出相反答案:``requiredWhen:
14+
P`record.approved_at == null` `` 是一条可用的守卫,而写在同一字段上的
15+
``readonlyWhen: P`record.approved_at == null` `` 只要驱动没把 `approved_at`
16+
这一列回读出来就会 fault;**`readonlyWhen` fault 是 fail-open**,于是作者声明
17+
为冻结的字段被照常写入。某次写入是否被拦,取决于驱动回读了哪些列 —— 作者既看不见
18+
也控制不了的存储细节。
19+
20+
本次把这两个 strip 的 `record``previous` 两个根都过 `materializeDeclaredFields`,
21+
按维护者 2026-08-06 裁决(#4953)统一**服务端**接缝。
22+
23+
**这是一次可见的行为变化,方向如下:**
24+
25+
- 稀疏行上原本 fault→放行的谓词现在正常求值,谓词为真则改动被剥离(即恢复本应生效的
26+
只读约束)。`record.x == null` / `!= null` / `previous.x == null` 都属此类。
27+
- 相应地,`has(record.<已声明字段>)` 在全量绑定下恒为 `true`(物化出的 `null` 是一个
28+
「存在且值为 null」的键,这是 CEL 自身的规则),`!has(record.<已声明字段>)` 恒为
29+
`false`。因此以 `readonlyWhen: !has(record.x)` 表达「x 为空时冻结」的写法**不再锁住
30+
字段** —— 它原本也不是一条保证(在回读全部列的驱动上它从来不锁),现在它变成确定的
31+
`false`。要表达「为空时冻结」请改写为 `record.x == null`(即 `@objectstack/lint`
32+
null-guard 闸门一直建议的写法)。
33+
34+
未改动的部分:`readonlyWhen` 的 fail-open 策略本身;#4889`parent` 未绑定 ⇒
35+
**LOCKED** 判定(`parent` 是另一个对象的行,不做物化);对象级 `script` /
36+
`cross_field`#4649 起的 fail-closed;INSERT 仍不走 `readonlyWhen` 剥离。
37+
未读到前序行时(引擎未取或行已不存在)**不做**物化 —— 那样不是补齐缺失值,而是
38+
凭空捏造一个与库中行相矛盾的值。
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
---
2+
"@objectstack/objectql": patch
3+
---
4+
5+
fix(objectql): a `sys_file` hydrate read fault is no longer indistinguishable from "this record has no file" (#6116)
6+
7+
A file-field value stored as an opaque `sys_file` id is enriched on read into
8+
`{ id, name, size, mimeType, url }`. That one batched lookup sat behind a bare
9+
`catch { return records }`: **every** failure — connection drop, timeout,
10+
permission denial, query error, and the benign "the table was never
11+
provisioned" — was answered with the same silent pass-through of un-hydrated
12+
ids. Consumers (UI, export) then receive a bare id where a file reference was
13+
due and render it as *no attachment*, so a live outage looked exactly like a
14+
record that genuinely holds no file. That is the ADR-0110 D3 shape — a fault
15+
wearing the appearance of legitimate absent data — carried here on a functional
16+
surface rather than a durability one.
17+
18+
**Fail-open behaviour is unchanged, deliberately.** A file-metadata read that
19+
fails must not take down the record read that asked for it, so the ids still
20+
pass through un-hydrated and no read starts throwing. This is a
21+
diagnosability fix: what changes is that the two reasons stop being the same
22+
silence.
23+
24+
The catch now discriminates by error **type**, through the shared
25+
`isMissingTableError` predicate (`@objectstack/metadata/errors`) — the same
26+
call the engine's autonumber seeding already makes, never a hand-rolled
27+
`code === '42P01'` copy:
28+
29+
- **table never provisioned** — the storage plugin is present but schema sync
30+
has not run. There are genuinely no committed rows, so the un-hydrated
31+
answer *is* the truth: passed through in silence, exactly as before, so an
32+
app whose storage schema is not yet synced gains no per-read noise.
33+
- **every other read failure** — the rows may well exist and simply were not
34+
seen. One `warn` now names the parent object, the fields left un-hydrated,
35+
how many ids went unresolved, the driver's own error, the consequence (those
36+
ids will render as "no file" for this read) and the fix (check
37+
storage/database availability, then re-read). Said once per read, not once
38+
per record or per id.
39+
40+
`warn` rather than `error` per the repo's degradation-log-level rule: nothing
41+
on this path claims to have persisted anything, the answer is visibly smaller
42+
for this response only, and the next successful read repairs it.
43+
44+
Note for operators reading logs: the generic read handler one frame up already
45+
logged `Find operation failed` for the failed sub-read. That line is unchanged
46+
and is not a substitute — it is emitted identically for the benign and the
47+
non-benign failure and describes the `sys_file` sub-read only, never the parent
48+
object, the fields, or the degraded answer that was nevertheless returned.

.claude/skills/pm-dispatch/SKILL.md

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -790,8 +790,48 @@ in-scope,验收依据 …」),维护者可否决。
790790

791791
(**分诊座位的活** —— 执行座位跳过本步,rule 4。)
792792
The maintainer does not pre-sort the backlog. On every round (and every
793-
idle check-in), sweep issues that carry no `pm:*` / `needs-user-decision`
794-
label and classify each:
793+
idle check-in), sweep every issue matching **任一**析取,并逐张分类:
794+
795+
1. **`pm:*`、无 `needs-user-decision`、也无 `domain:*`** —— 全裸卡。今天的
796+
规则,判据不变;`domain:*` 一项此前只活在实践里(带车道标签的单被读作
797+
「已路由」而跳过),析取 3 出现后必须写明,否则两条互相吞掉。
798+
2. **`pm:queue` 但无 `domain:*`** —— ⚠️ **仅限 `objectstack-ai/objectstack`**
799+
3. **`domain:*` 但无 pm-state**(`pm:queue` / `pm:dispatched` / `pm:blocked` /
800+
`pm:on-hold` / `finding` / `needs-user-decision`)。
801+
802+
**析取 2、3 只取 `updated_at` 早于 ~2 分钟前的卡**(析取 1 无此限)。
803+
804+
**为什么 2、3 不是可选项。** `domain:*`**路由**、pm-state 是**状态机**,只带
805+
其一的单对两个视图**同时**不可见 —— 队列按 pm-state 取,车道认领按 `domain:*`
806+
取,而旧判据「带了 `pm:*` 就跳过」把补票的最后一道也关上了。这不是假想:同形
807+
已四次实测(两次各停滞整日,一次同日六张 11:00–13:55Z 立、16:47Z 才被两个 PM
808+
会话**手工**报回来才捞起)。
809+
810+
- 析取 2 的生产者是**协议本身**,不是某个车道的坏习惯:跨座位转移卡(转出方
811+
按转移协议自带 `pm:queue`)与队列管家 Routine 的队列健康卡,**按设计**预先
812+
带队列标签,又**按单一生产者规则不准自打 `domain:*`**(管家原话:「no
813+
`domain:*` / `repo:*` label applied — routing labels are the triage seat's
814+
single-producer territory. Only `pm:queue` is set here.」)。于是它们在看板上
815+
显示可派、实际**谁都不能认领**
816+
- 析取 3 那张实测卡是**分诊自己写的纪律的反例**:同一条「立单方别自打
817+
`domain:*`、留给分诊」当天早上刚写在另一张卡上,几小时后 PM 立单时照犯,
818+
卡片对队列与扫描同时隐身 ~69 分钟。⇒ **纪律写在别处不够,判据本身必须兜
819+
** —— 这是本节存在的理由,不是修辞。
820+
821+
⚠️ **析取 2 必须 repo-scoped,否则它自己就是噪声源。** 兄弟仓(objectui /
822+
cloud)是**整仓座位**,车道标签在那里**根本不存在**(实测 2026-08-07:objectui
823+
`domain:devx` 查无此标签),所以「有 `pm:queue`、无 `domain:*`」是它们**每一
824+
**队列卡的正常形状 —— 不限定就一次扫进 objectui 38 + cloud 19 张(同日实测
825+
open 计数),把分诊轮淹掉。
826+
827+
**年龄下限键在 `updated_at`,不是 `created_at`** 部分标注状态有两个来源:
828+
刚立还没打完标签的新卡,以及**一次标签写入**把老卡打成半标注 —— 分诊自己打
829+
标签就是分开的两次写(`domain:*``pm:queue` 各一次),中间那几秒正好落在
830+
析取 2 / 3 里。按 `created_at` 判会漏掉后一种,按 `updated_at` 两种都兜住;代价
831+
是一条评论也会把卡推迟一轮,可接受(下一轮即取到)。
832+
833+
**分类动作**(对上面选中的每一张;析取 2 选中的卡只欠 `domain:*`,补它即可,
834+
⛔ 不重打已在位的 `pm:queue`):
795835

796836
- **Auto-queue (`pm:queue`)**: a concrete defect with a named location or
797837
repro; a scoped tooling/gate fix; a restore-invariant finding; a
@@ -816,6 +856,9 @@ label and classify each:
816856
`tracking``status:parked` 的 issue(它们的状态由别的机制管,分诊不重判)、
817857
以及 **#4604 与全部 `pm:seat` 座位贴**(协议载体,不是待分诊的工作)。存量大时**每轮
818858
限量、优先最新**(试点用 ~15 条/轮),防一轮吃光存量把轮次拖过一个调度周期。
859+
⚠️ 排除项在析取 3 下更吃重:parked**正常形状**就是「带 `domain:*`、无
860+
pm-state」(2026-08-07 实测:3 张 `status:parked` 全部长这样),漏判排除就是每轮
861+
把它们重新扫回来一次。
819862

820863
#### 发现分诊轮 —— 队列的出水口(objectstack#4949)
821864

.github/workflows/lint.yml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -489,6 +489,22 @@ jobs:
489489
steps:
490490
- name: Checkout repository
491491
uses: actions/checkout@v7
492+
with:
493+
# The authorable-surface deletion gate (#4650) below anchors on the
494+
# MERGE BASE of HEAD with origin/main — that is the only anchor under
495+
# which "a key this PR deleted" and "a key main gained since the fork
496+
# point" are different facts. A shallow clone has no walkable
497+
# ancestry, so `merge-base` fails and the gate falls back to
498+
# origin/main's TIP, where those two facts collapse into one and the
499+
# SECOND one is reported as the first: #6359 had PR #6356 (which
500+
# touches no spec file at all) go red for "deleting"
501+
# ui/BulkActionDef:requiredPermissions — a key main had just ADDED.
502+
#
503+
# Same line as the ESLint job above, opposite failure mode, and that
504+
# is why it is spelled out here rather than cross-referenced: shallow
505+
# degrades the slot-lookup ratchet to "not verified" (a false GREEN),
506+
# and degrades this gate to a false RED on an innocent PR.
507+
fetch-depth: 0
492508

493509
- name: Setup Node.js
494510
uses: actions/setup-node@v7

content/docs/references/ui/action.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,9 +80,9 @@ const result = ActionSchema.parse(data);
8080
| **refreshAfter** | `boolean` | optional | Refresh view after execution |
8181
| **undoable** | `boolean` | optional | Offer an Undo affordance after this single-record update action succeeds. |
8282
| **resultDialog** | `{ title?: string; description?: string; acknowledge?: string; format?: Enum<'qrcode' \| 'code-list' \| 'secret' \| 'text' \| 'json'>; … }` | optional | Render API response in a one-shot reveal dialog (suppresses successMessage when set). |
83-
| **visible** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL). |
83+
| **visible** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Visibility predicate `true`/`false` literal, CEL string, or `{dialect, source}` envelope. The action is offered when it evaluates TRUE. Omit = always visible. |
8484
| **requiresFeature** | `Enum<'twoFactor' \| 'passkeys' \| 'magicLink' \| 'organization' \| 'multiOrgEnabled' \| 'degradedTenancy' \| 'oidcProvider' \| 'sso' \| 'ssoEnforced' \| 'deviceAuthorization' \| … +3 more>` | optional | Public auth feature flag gating this action; lowered into `visible` at parse time. |
85-
| **disabled** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Boolean or predicate (CEL) — action is disabled when TRUE. |
85+
| **disabled** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Disabled predicate `true`/`false` literal, CEL string, or `{dialect, source}` envelope. The action is shown but refused when it evaluates TRUE. Omit = never disabled. |
8686
| **requiredPermissions** | `string[]` | optional | [ADR-0066 D4] Capabilities required to invoke this action. Enforced with 403 on the platform action route (script/flow/modal + MCP) and mirrored as a UI hide; a `type: api` action pointed at a custom endpoint must re-check it there. |
8787
| **shortcut** | `never` | optional | [REMOVED] `action.shortcut` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — it never triggered anything: no keydown listener feeds ActionEngine.getShortcuts(), and objectui's keyboard stack (useKeyboardShortcuts) is hand-registered and never consults action metadata. Delete the key. For a real shortcut, register the key in the Console keyboard stack and have its handler invoke the action by name. |
8888
| **bulkEnabled** | `never` | optional | [REMOVED] `action.bulkEnabled` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — the multi-select toolbar is driven by the LIST VIEW's `bulkActions` / `bulkActionDefs`, never by this flag, so setting it changed nothing. Delete the key and declare the action in the view's `bulkActions` instead. |

0 commit comments

Comments
 (0)