@objectstack/lint 17.0.0-rc.3 → 17.0.0-rc.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,13 +1,1947 @@
1
1
  # @objectstack/lint
2
2
 
3
- ## 17.0.0-rc.3
3
+ ## 17.0.0-rc.5
4
4
 
5
5
  ### Patch Changes
6
6
 
7
+ - Updated dependencies [e8f8f6c]
8
+ - Updated dependencies [7f713b6]
9
+ - Updated dependencies [c960170]
10
+ - Updated dependencies [def5919]
11
+ - Updated dependencies [ce0cfe9]
12
+ - Updated dependencies [1363084]
13
+ - @objectstack/spec@17.0.0-rc.5
14
+ - @objectstack/formula@17.0.0-rc.5
15
+ - @objectstack/sdui-parser@17.0.0-rc.5
16
+
17
+ ## 17.0.0-rc.4
18
+
19
+ ### Minor Changes
20
+
21
+ - 28ad90e: feat(types,cloud-connection,lint,cli): ADR-0120 17.x 收尾 —— `isolated` 安装期姿态硬门(D5e)、D5c 重拼写 advisory、成文契约扫荡与三姿态 conformance (#5081)
22
+
23
+ ADR-0120 17.x 波的第三块,也是最后一块。前两块已在 main 上:#5212(driver 侧
24
+ D3+D4 —— `COALESCE(organization_id, '__global__')` 物化、drift 两侧同步、重复预检)
25
+ 与 #5208(spec 词汇 `'organization'` + D5a/D5b lint)。本次补齐三件事:安装期的
26
+ 姿态决策点、剩余的成文契约、以及把「一个 app 包跑遍三种姿态」从假设变成测试。
27
+
28
+ **D5e —— 装进 `isolated` 环境时的硬门。** 词汇本身是姿态无关的:作者说的是业务
29
+ 边界(`'organization'` 一个组织一份 / `'global'` 整个安装一份),没有任何索引形状
30
+ 读姿态。唯一的残留在一个方向上:`isolated` 下组织就是**不同客户**,此时 app 业务
31
+ 对象上的 `'global'` 唯一既跨客户过度约束,又变成跨客户的存在性预言机(S10/S14)。
32
+ 维护者裁定这是**硬门而非 advisory**:把带 `'global'` 唯一(非 `sys` 对象)的 app
33
+ 装进 `isolated` 环境会**停下来并逐索引列出**,安装者(通常是 AI agent)要么确认它
34
+ 确实是平台级的,要么改写为 `'organization'`;确认按 ADR-0104 attestation 风格
35
+ 留痕在安装清单里(`InstalledManifestEntry.globalUniqueAttestation` —— 确认了什么、
36
+ 谁确认的、何时、在哪个姿态下问的),**之后不复问**。
37
+
38
+ - 停下的安装**什么都不留**:先于 hot-register 和任何 ledger 写入,所以作者改完
39
+ 元数据可以直接重试,不需要先卸载。
40
+ - 逐索引确认是有牙齿的:`confirmGlobalUniques` 收 `true` 或明确的 id 数组,只确认
41
+ 其中一条仍会在剩下的那条上停住。
42
+ - 升级引入的**新**约束会被问,老的答案继续算数。
43
+ - 另一个姿态下给出的确认**不算同意** —— `isolated` 那个问题在 `single` 下从未被
44
+ 问过,所以按「未确认」处理(唯一不会静默放行跨客户约束的方向)。
45
+ - ⛔ **永不做成启动期告警**(#4884 纪律)。boot 时的 rehydrate 不评估此门;门够不到
46
+ 的两类存量 —— 门禁上线前的安装、装后姿态变更的环境 —— 由 `os doctor` 与
47
+ `os migrate plan` 的 advisory 形态覆盖。
48
+
49
+ 判定里有三条是承重的,别「简化」掉:声明索引上的裸 `unique: true` **算**(D1 说它
50
+ 就是 `'global'` 的位置式拼写,排除它等于让整个 17.x 可以靠拼写绕过);字段级
51
+ `true` **不算**(它是 `'organization'`,永久合法);`sys_`/`base_` 对象**不算**
52
+ (S5 那批引擎幂等键天然就是平台级的,每次安装都问一遍就是 #4884 的误报类)。
53
+
54
+ CLI: `os package install` 新增 `--confirm-global-uniques`,并把 409 渲染成可读的
55
+ 逐条清单而不是一句 "Install failed (409)"。
56
+
57
+ **D5c —— 遗留手写组织复合索引的 advisory。** 新规则
58
+ `unique/legacy-organization-composite`:声明的唯一索引自己列出了组织列
59
+ (`{ fields: ['name','organization_id'], unique: true }`)—— 这是词汇出现之前手写
60
+ per-organization 的写法。它读起来像「每组织唯一」,物化出来却是普通复合索引,而
61
+ SQL UNIQUE 是 NULL-distinct 的:组织列为 NULL 的行上它**什么都不约束**(#5030),
62
+ 在单组织部署上那就是每一行。改写成 `unique: 'organization'`(`fields` 原样保留,
63
+ driver 会把已列出的组织列**就地**变成 NULL-safe 形式)正是补上这个洞的动作。
64
+ **永远只是 advisory,永远不自动修**:老拼写永久合法、零强制 drift,而 opt-in 是
65
+ 真实的物理收紧,要走 D4 的 `recreate_index` + 重复预检。
66
+
67
+ **D6 —— 成文契约扫荡。** `content/docs/data-modeling/indexing.mdx` 的
68
+ §Two ways to say "unique" 全节按新词汇重写(含 `os:check` 代码块);
69
+ `content/docs/protocol/objectql/schema.mdx` 的 §Uniqueness and tenancy 重写为
70
+ §Uniqueness and scope —— 其中那句「单租户部署不受影响,租户列是常量,复合索引
71
+ 退化为单列索引」是 #5030 **证伪过的原话**,现已替换为 D3 的 NULL-safe 事实;
72
+ `content/docs/deployment/cli.mdx` 的 `replace_unique_index` / `recreate_index`
73
+ 条目补上 NULL-safe 形状与重复预检;`content/docs/references/**` 经
74
+ `gen:schema && gen:docs` 再生成,未手改。
75
+
76
+ 按 ADR-0120 Resolved #2 的非规范性引导(官方示例/脚手架/生成器在新代码中输出
77
+ 显式拼写),`skills/objectstack-data/**` 的索引与校验规则整体扫过:声明索引一律
78
+ 说清 scope,并新增一节完整讲 `'organization'` 的 NULL-safe 语义与「永远不写姿态」。
79
+ 顺带修掉那里长期使用的 `tenant_id` —— 平台的列叫 `organization_id`。
80
+ `examples/**`、`create-objectstack` 模板与 `os generate` 经核查**根本没有声明任何
81
+ 唯一约束**,故无可扫;这是核查结论,不是遗漏。
82
+
83
+ **三姿态 conformance(ADR §Acceptance tests)。** 同一个 fixture app 在
84
+ `single | group | isolated` 三姿态下启动,逐 S 行用**真实的违规插入**断言 enforcement
85
+ (S1/S2/S3/S4/S5/S6/S7/S8/S9/S11/S12),并逐姿态捕获物化出的索引键,断言三者
86
+ **逐字节相同** —— 「没有任何索引形状读姿态」这句话一旦有两者不同就是假的。相同性
87
+ 断言配了一条正向断言(对着期望的键形状),这样「三次都什么都没建」不会读成「一致」。
88
+ 外加 ADR 只要的那一条 transition smoke:在 `single` 下建库、`isolated` 下重新打开,
89
+ drift op 为零。
90
+
91
+ 对既有部署的影响:除新增的安装期确认外,本次不改变任何已有物化行为。字段级
92
+ `unique: true` 一如既往合法。
93
+
94
+ - ddc2527: fix(approvals): the ADR-0044 revise window is a service-owned node type, not a bare `wait` (#3823)
95
+
96
+ #3801 gated `POST /api/v1/automation/:name/runs/:runId/resume` on the **node type**
97
+ that produced the suspension: an `approval` pause declares
98
+ `resumeAuthority: 'service'`, so it continues only through `ApprovalService`.
99
+ ADR-0044's **revise window** was the same trust boundary in a shape that key
100
+ could not see. Send-back parked the run on an ordinary `wait` node the flow
101
+ author placed — correctly `resumeAuthority: 'any'`, because a signal wait is
102
+ _meant_ to be resumable by an external producer — and `ApprovalService.resubmit`
103
+ was the only thing that checked anything about continuing it.
104
+
105
+ Demonstrated (not reasoned) against the real engine: a raw `resume(runId)` with
106
+ an **empty body**, from any caller, walked the `resubmit` back-edge into the
107
+ approval node and opened round N+1 with **no submitter check and no `resubmit`
108
+ audit row** (`['submit','revise']` — no third row, ever). Worse, when another
109
+ request was already pending on the record — the exact case `resubmit` refuses
110
+ with `DUPLICATE_REQUEST` _specifically to keep the run alive_ — the raw resume
111
+ went around that guard: the approval node's re-entry failed **after** the engine
112
+ consumed the suspension, and the run was **permanently destroyed** with its
113
+ round-N request stuck `returned` and no resubmit able to reach it.
114
+
115
+ The revise pause is therefore its own node type:
116
+
117
+ - **`approval_revise`** (`APPROVAL_REVISE_NODE_TYPE`), registered by
118
+ `@objectstack/plugin-approvals` alongside the `approval` node, declaring
119
+ `resumeAuthority: 'service'`. It stays a first-class box on the canvas, in the
120
+ run log and in the suspended-run store — only the _reuse_ of `wait` was wrong.
121
+ It takes **no config**: the window ends on the submitter's explicit resubmit,
122
+ never on a signal or timer. The `resumeAuthority` gate itself is unchanged.
123
+ - `sendBack` refuses a `revise` edge whose target is not an `approval_revise`
124
+ node, **before any mutation** (like the existing missing-`revise`-edge check),
125
+ so no run can be parked in a window something else can advance.
126
+ - New gating lint `flow-approval-revise-target-not-service-owned`
127
+ (severity `error`, on `os build` / `os validate` / `os lint` and the runtime
128
+ metadata publish gate) rejects the old shape at authoring time.
129
+
130
+ **Upgrading a flow authored against the original ADR-0044 D3.** One token:
131
+
132
+ - **FROM:** `{ id: 'wait_revision', type: 'wait', waitEventConfig: { eventType: 'signal', … } }`
133
+ - **TO:** `{ id: 'wait_revision', type: 'approval_revise' }` — drop
134
+ `waitEventConfig` / any `config`; the window has no event to wait on.
135
+
136
+ Until you do, such a flow keeps registering and running and its approvals stay
137
+ decidable (`approve` / `reject` / `recall` / `reassign` are untouched), but
138
+ **send-back is refused** with a message naming the node and this fix, and
139
+ re-publishing it reports the lint error. A run _already parked_ in a legacy
140
+ revise window keeps its recorded node type (a republish never re-types a live
141
+ pause) and is drained by `resubmit` or `recall` as usual.
142
+
143
+ ADR-0044's 2026-07-28 amendment records the reversal of its D3 and of its
144
+ `Alternatives` rejection of a service-owned revise pause, with the evidence
145
+ above; the implementation section there records what shipped, why the approval
146
+ node does not re-suspend itself instead, and why no ADR-0087 conversion was
147
+ added for the old shape.
148
+
149
+ - 0161c7f: feat(spec,lint): declare the chart segment drill — `ChartDrillDownSchema`, on the react tier where it is actually read (#5022)
150
+
151
+ `drillDown` has driven a real capability since long before this release: click a
152
+ bar or a slice on an `<ObjectChart>` and objectui opens the underlying records,
153
+ filtered by the clicked category, in a drawer. The protocol declared it
154
+ **nowhere**. objectui read it as `(schema as any).drillDown`, so every key inside
155
+ it — right, wrong, or misspelled — reached the renderer unchecked, and a typo was
156
+ simply ignored at click time. This is Prime Directive #10 inverted: not declared
157
+ without being delivered, but delivered without ever being declared.
158
+
159
+ It is declared now, as `ChartDrillDownSchema`, and it is **additive** — nothing
160
+ that parsed before stops parsing.
161
+
162
+ ## What you can write
163
+
164
+ `drillDown` is a prop on the react-tier `<ObjectChart>` block:
165
+
166
+ ```jsx
167
+ <ObjectChart
168
+ objectName="opportunity"
169
+ aggregate={{ function: "sum", field: "amount", groupBy: "stage" }}
170
+ drillDown={{ columns: ["name", "amount"], maxRows: 50 }}
171
+ />
172
+ ```
173
+
174
+ | key | type | meaning |
175
+ | --------- | ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
176
+ | `enabled` | `boolean` | Only needed to force the drill OFF — the block being present already means on, so `drillDown={{}}` enables it |
177
+ | `filter` | `Record<string, unknown>` | Filter for the drilled list; values support `${event.*}`. Omit it and the filter is derived from `aggregate.groupBy` equal to the clicked category |
178
+ | `title` | `string` | Drawer/dialog heading; supports `${event.*}` |
179
+ | `target` | `'drawer' \| 'dialog'` | In-place side sheet (default), or a centered modal when the chart is already inside a drawer |
180
+ | `columns` | `string[]` | Column whitelist for the drilled list |
181
+ | `maxRows` | `number` | Rows per page in the drilled list |
182
+
183
+ Every one of those six is a key objectui's `ObjectChart` was measured to read.
184
+ The renderer's own drill type is wider — it is shared with the table / pivot /
185
+ metric widgets — and the extra keys are **deliberately not declared**, because a
186
+ chart reads none of them:
187
+
188
+ - **`mode`** (`'filter'`/`'record'`) is a table/pivot/metric key. A chart segment
189
+ is always an aggregate, so there is nothing to discriminate.
190
+ - **`report`** (drill into a report instead of a record list) is a metric/pivot
191
+ capability.
192
+ - **`view`** and **`sort`** are read by _no_ renderer at all (objectui#3354).
193
+ - **`target: 'navigate'`** is implemented for the other widgets but not for a
194
+ chart, which falls back to the drawer.
195
+
196
+ Writing any of them is now a loud rejection that says which surface owns it,
197
+ rather than a value that silently does nothing.
198
+
199
+ ## Where it is NOT declared, and why that is deliberate
200
+
201
+ **Not on `ChartConfigSchema`, and not a dashboard widget key.** A dashboard
202
+ widget has no per-widget drill configuration, by design: an ADR-0021
203
+ dataset-bound widget drills through the semantic layer, deriving the target
204
+ object and filter from the dataset row that was clicked. That is what
205
+ `content/docs/ui/dashboards.mdx` has said all along, and it is what the renderer
206
+ does — `DashboardRenderer` never reads `chartConfig`, and `DatasetWidget`
207
+ forwards exactly one key out of it (`showLegend`). Declaring the drill there
208
+ would have produced authorable metadata that parses clean and never reaches a
209
+ renderer — the failure this campaign removes elsewhere.
210
+
211
+ So the three places an author might reach for it now answer instead of shrugging:
212
+
213
+ - `widget.chartConfig.drillDown` → rejected, pointing at the react-tier prop.
214
+ - `widget.drillDown` / `widget.drilldown` → rejected, explaining that dashboard
215
+ drill-through is **automatic**, and naming both configurable drills.
216
+ - `report.drillDown` → rejected, pointing back at the chart prop.
217
+
218
+ ## `drillDown` is not `drilldown`
219
+
220
+ Two capabilities, one letter apart, and they are now disambiguated in both
221
+ directions at the schema gate:
222
+
223
+ | | `drillDown` | `drilldown` |
224
+ | -------- | -------------------------- | ----------------------------------------------- |
225
+ | spelling | camelCase | all lowercase |
226
+ | type | configuration object | boolean |
227
+ | surface | react `<ObjectChart>` prop | `ReportSchema` key (ADR-0021 D2, on by default) |
228
+
229
+ Edit distance alone gets this wrong — the two spellings are a distance of 1, so a
230
+ plain "did you mean" would happily send an author writing `drillDown` on a report
231
+ to `drilldown`, where their config object then fails a second time as a boolean.
232
+ Both gates name the **type** difference, not just the spelling.
233
+
234
+ ## Enforced, not just declared
235
+
236
+ `@objectstack/lint`'s react-page publish gate now **parses** the schema
237
+ (`react-chart-drilldown-invalid`) against a static `drillDown={{…}}` literal,
238
+ rather than re-deriving the rules. Unknown keys, the wrong `target`, and the
239
+ near-key spelling all fail the build with the schema's own prescription. A value
240
+ assembled from React state is skipped, unchanged: an unresolvable binding is not
241
+ a wrong one (ADR-0072 D1).
242
+
243
+ - eaaf03c: refactor(spec,lint)!: retire the dashboard widget action trio + `aria` — and the build gate that enforced a button nobody renders (#5010, ADR-0049)
244
+
245
+ `DashboardWidgetSchema` let an author declare a per-widget action **button**
246
+ (`actionUrl` / `actionType` / `actionIcon`) and per-widget ARIA attributes
247
+ (`aria`). None of the four reached a renderer. Re-measured 2026-08-04 across both
248
+ repos on a closed call graph:
249
+
250
+ - **the action trio** — all 14 `actionUrl` reads in objectui's
251
+ `DashboardRenderer.tsx` are scoped to `schema.header.actions[]`, which is
252
+ `DashboardHeaderAction`, a _different_ schema. Nothing anywhere reads
253
+ `widget.actionUrl`. `actionIcon` is the starkest: zero references in either
254
+ repo outside its own declaration — not even the lint looked at it.
255
+ - **`aria`** — no consumer of `widget.aria` anywhere. The `aria-*` attributes in
256
+ `DashboardRenderer` / `DatasetWidget` are the renderer's own DOM attributes,
257
+ and objectui's single `.aria` read (`plugin-view/ObjectView.tsx:989`) is a
258
+ **view**'s. This is the dashboard-level `aria` that #3896 removed, one level
259
+ down — an accessibility guarantee an author could declare and nothing honoured.
260
+
261
+ These four survived the #3896 sweep for the same reason `widgets[].responsive`
262
+ did, and it is not "we looked and they were live": the liveness ledger declared
263
+ no `children` on `dashboard.widgets`, so **no widget-level key had ever been
264
+ classified**. #4956 fixed that instrument and gave all 22 keys their first per-key
265
+ verdicts; this change acts on four of the six it found dead.
266
+
267
+ ## The second-order cost this settles
268
+
269
+ `packages/lint`'s `validate-dashboard-action-refs` enforced **ERROR-severity**
270
+ reference integrity on `widgets[].actionUrl` — a dangling target failed the
271
+ build. Its docblock called the key _"the per-widget button"_ and claimed to
272
+ mirror the objectui runtime dispatch. It did not, because that button does not
273
+ exist. So an author could be blocked from shipping because a control that cannot
274
+ render pointed at an action that also did not.
275
+
276
+ A rule written to delete false affordances was sustaining one. That is why the
277
+ keys were retired rather than the check merely relaxed: the widget branch is
278
+ deleted, with a pin test asserting it stays silent and a second pin proving
279
+ header actions are still checked in the same stack.
280
+
281
+ FROM → TO:
282
+
283
+ | Removed | Replacement |
284
+ | :------------------------------- | :----------------------------------------------------------------------------------------------------- |
285
+ | `dashboard.widgets[].actionUrl` | `dashboard.header.actions[].actionUrl` |
286
+ | `dashboard.widgets[].actionType` | `dashboard.header.actions[].actionType` |
287
+ | `dashboard.widgets[].actionIcon` | `dashboard.header.actions[].icon` (the header spelling) |
288
+ | `dashboard.widgets[].aria` | **none** — delete it; author `title`/`description`, which the renderer really does label the card with |
289
+
290
+ For a per-**row** affordance, reach for a dataset-bound `table`/`pivot` widget:
291
+ its rows are clickable and drill through the semantic layer already (no
292
+ per-widget drill config exists, by design — #5022).
293
+
294
+ **The `AriaProps` shape is NOT removed — only this embed.** `AriaPropsSchema` /
295
+ `AriaProps` stay exported and stay live on `app.aria` and
296
+ `page.components[].aria`. Nothing importing the shape breaks.
297
+
298
+ The retirement kit:
299
+
300
+ - **Tombstones.** `retiredKey()` on all four, matching `responsive` in this same
301
+ schema. `DashboardWidgetSchema` _is_ `.strict()`, so a plain delete would still
302
+ be loud — but only as a generic "unrecognized key". The tombstone keeps the key
303
+ declared so the rejection carries the **prescription**, and types it `never` so
304
+ authoring it fails `tsc` first. Pins assert the message _is_ the prescription
305
+ and is _not_ `Unrecognized key`. The action trio shares one prescription that
306
+ names all three, so an author who deletes the one key they were told about does
307
+ not hit the same error twice more.
308
+ - **ADR-0087 D2 conversion + D3 chain step**
309
+ (`dashboard-widget-action-aria-removed`, `retiredFromLoadPath`):
310
+ `os migrate meta --from 16` strips the four from author sources, and stored
311
+ dashboards replay clean instead of meeting a tombstone at load. Lossless
312
+ deletes — none of the keys had an effect to lose. Its own entry rather than
313
+ more keys on `dashboard-inert-keys-removed`, whose identity is the #3896 sweep.
314
+ - **Liveness rows stay** (`status: dead`, `verifiedAt`, a REMOVED note) because
315
+ a tombstone keeps the key in the walked shape — the `rls.priority` precedent.
316
+ `authorWarn`/`authorHint` are dropped from all four: the parse owns them now.
317
+ - Baselines moved at KEY level only, as the shape's survival implies:
318
+ `authorable-surface.json` gains four `… [RETIRED]` lines;
319
+ `json-schema.manifest.json`, `api-surface.json` and
320
+ `api-surface-signatures.json` are unchanged by construction — no def stopped
321
+ being emitted and no export was removed.
322
+
323
+ No runtime behaviour changes — that impossibility is the reason for the removal.
324
+ The one behaviour that _does_ change is a build that used to fail and now does
325
+ not.
326
+
327
+ ## Not in this change
328
+
329
+ `widgets[].colorVariant`, the fifth dead key #5010 lists, is **deliberately
330
+ untouched**. The rewrite target its triage assumed — `options.colorVariant` —
331
+ measured dead as well: `options` only reaches a renderer through the inline
332
+ `componentSchema` path, and `dataset` is _required_ on this schema, so every
333
+ spec-authorable widget is dataset-bound and renders through `DatasetWidget`,
334
+ which has no colour affordance at all. Moving the key would relocate 16 authored
335
+ sites (7 in `platform-objects`, 9 in `app-showcase`) from one dead slot to
336
+ another and mint a second inert key. Returned for adjudication.
337
+
338
+ - 06ffad3: fix(lint): 字段公式校验首次对 spec 合法元数据生效 —— `f.formula` 收敛为 `f.expression` (#5026)
339
+
340
+ `validate-expressions.ts` 的字段公式校验(`validateStackExpressions` 里的
341
+ field-formula pass)一直读 `f.formula`。`FieldSchema` 声明的是 `expression`,而
342
+ `formula` 恰是 `field.zod.ts:333` **按名拒绝**的别名之一
343
+ (`aliases: { formula: 'expression', calculation: 'expression', compute: 'expression' }`)。
344
+ 该规则以 `input: 'parsed'` 注册,compile/build/validate 路径上看到的是
345
+ `ObjectStackSchema` 的解析产物,所以 `f.formula` 恒为 `undefined` ——
346
+ **整段检查对任何 spec 合法 stack 从未执行过一次**。
347
+
348
+ 这不是删死代码,是**启用一条从未跑过的检查**。字段公式从此真正受
349
+ ADR-0032 §1a/1b 的三条判决管辖:CEL 语法、`record.<field>` 字段存在性、
350
+ 以及 #1928 的裸引用 / 类型健全性。对 AI 生成的元数据这一条最要紧 ——
351
+ `amount * probability`(而不是 `record.amount * record.probability`)正是公式槽位
352
+ 最常见的错法,它在 CEL 里静默求值为 null,过去没有任何门拦得住。
353
+
354
+ **覆盖面扩大,但对现有元数据零新红。** 激活后在仓库全部真实元数据上实测过:
355
+ `examples/app-showcase`(3 个 `expression` 槽)、`examples/app-crm`(5 个)、
356
+ `examples/app-todo`(0 个)全部 `ObjectStackSchema` 解析通过,新增判决 0 条;
357
+ `platform-objects`、`plugin-security` 的 default-permission-sets 不含公式字段;
358
+ `skills/` 里的公式样例全部已是 canonical 拼法。
359
+
360
+ **Authoring impact.** 之前拼 `formula:` 的字段本来就无法解析,schema 会按名拒绝
361
+ 并给出 `Did you mean \`formula\` → \`expression\`?`——该行为不变,本规则不再对同一个键
362
+ 给出第二套说法。诊断定位串同步改名以免继续传播错拼法:
363
+
364
+ ```
365
+ FROM object 'X' · field 'Y' formula
366
+ TO object 'X' · field 'Y' expression
367
+ ```
368
+
369
+ `validate-null-guards.ts` 的 surface ledger 相应把该行从 `Field.formula` 正名为
370
+ field `expression`(`Field.formula({ expression: … })` 写入的槽)。null-guard 判决
371
+ **仍然**排除该 surface(公式是 `value` 角色、天然可空,`guard ? value : null` 是祝福
372
+ 写法),排除的只是 null-guard 这一条,语法 / 字段存在性 / 裸引用判决从此生效。
373
+
374
+ `validate-expressions.test.ts` 的 `TRACKED_UNDECLARED_READS` 记账随之清空 —— 这
375
+ 份"只缩不长"的清单现在是零条,规则读的每一个键都是 spec 声明的键。
376
+
377
+ - 123067c: fix(cli): the i18n walker collects `objects.<o>._sections` — section headings are gated and scaffolded like every other label (#5405)
378
+
379
+ An object's SECTION headings were the one declared, resolved, rendered
380
+ translation surface the shared i18n walker had no kind for.
381
+ `ExpectedEntry['source']` listed `object | field | option | view | action |
382
+ globalAction | app | navigation | dashboard | widget | page` plus two
383
+ `metadataForm*` kinds — and those two cover **Studio metadata forms**
384
+ (`metadataForms.<type>.sections.*`, hidden behind `--include-platform`), not app
385
+ objects. So `objects.<o>._sections.<s>.label` was structurally unreachable in
386
+ both directions: `os i18n extract` never scaffolded a heading, and
387
+ `os lint` could not report one missing.
388
+
389
+ The surface itself was never in doubt. `ObjectTranslationDataSchema` declares
390
+ `_sections` (with `sections` as an authoring alias), and `@object-ui/i18n`'s
391
+ `sectionLabel` resolves it for `record:details`, for `ObjectForm`/`ModalForm`
392
+ and for the field-group designer. Only the walker disagreed — which is exactly
393
+ the drift `collectExpectedEntries` was consolidated to prevent (#3370).
394
+
395
+ Measured downstream before this landed: 85 sections across 15 objects, **2 of
396
+ 85** translated in `ja-JP` and in `es-ES` — English headings on essentially
397
+ every record page and form — with `objectstack lint` reporting **zero** i18n
398
+ warnings for both locales.
399
+
400
+ **What is collected.** A `section` kind, from the two independent surfaces that
401
+ both resolve to the same key — a heading is expected if _either_ declares it,
402
+ and one heading is one expected key however many declare it:
403
+
404
+ - **`fieldGroups` × field `group`** — the fields decide which sections exist and
405
+ `fieldGroups[].label` supplies the source text. Membership is read through
406
+ `deriveFieldGroupLayout` (ADR-0085 §5), the same shared derivation the
407
+ renderers consume, so a group nothing visible references — or a `group:` no
408
+ `fieldGroups` entry declares — produces no heading and therefore no expected
409
+ key. The trailing ungrouped bucket renders without chrome and is skipped.
410
+ - **A named `sections[]`** on a form view (including a view container's default
411
+ `form`) or inside a record page's component tree.
412
+
413
+ A section with no `name` is skipped: every renderer guards the lookup on it
414
+ (`s?.name ? sectionLabel(…) : s?.label`), so it is untranslatable by
415
+ construction and demanding a bundle entry for it would be noise. A group that
416
+ declares no `label` still yields a scaffold key seeded from its own name, but no
417
+ coverage finding — nobody authored that text.
418
+
419
+ **What you get.** `os lint` gains an `i18n/missing-section` category — user
420
+ metadata, so it is reported without `--include-platform` — and `os i18n
421
+ extract` scaffolds the headings for free, because the gate and the extractor
422
+ read the one walker. A project that declares no locales still reports nothing;
423
+ the gate stays opt-in.
424
+
425
+ **`@objectstack/lint`** now exports its shared page traversal
426
+ (`walkPageComponents`, `isSourceAuthoredPage`, `WalkedComponent`) so the CLI
427
+ consumes it instead of growing a private copy — that walk exists precisely
428
+ because duplicating it produced a dead rule once already (#3583). Reusing it is
429
+ also what makes the page half correct rather than merely present: it reaches
430
+ `slots.<slot>` and the untyped nesting a record page really uses
431
+ (`page:tabs` → `properties.items[].children[]` → `record:details`), skips
432
+ source-authored pages whose `regions` are a derived cache, and resolves each
433
+ component's OWN binding (`dataSource.object` → `properties.object` → the page's
434
+ `object`) — so a re-bound `record:details` keys its headings under the object it
435
+ actually shows.
436
+
437
+ - 5582e18: never-fire 族三条 lint 规则 warning → error:声明了触发器却确定不会运行的 flow 现在被拒收
438
+
439
+ `validateFlowTriggerReadiness` 里的规则此前一律是 warning。族内复审(#5762)后按**一个**标准重新定级 ——
440
+ 「仅凭这份 stack 是否已经足够断定这个 flow 不会运行」—— 三条规则答「是」,升为 `error`:
441
+
442
+ - **`flow-time-relative-descriptor-invalid`**(#5496)—— `config.timeRelative` 描述符
443
+ `TimeRelativeTriggerSchema` 判不过。判它的正是 trigger 在 bind 时 `safeParse` 的同一个 schema,
444
+ 所以 schema 拒的描述符在运行时同样被拒:sweep 永不安装,flow 声明了 time-relative 触发器却永不运行。
445
+ - **`flow-time-relative-descriptor-unroutable`**(#5647)—— `config.timeRelative` 不是对象
446
+ (`timeRelative: 'daily'` 是典型)。判据就是引擎自己的路由谓词 `typeof … === 'object'`,
447
+ 所以这个值不会被任何部署路由到 time-relative trigger —— 全族里最硬的判定,也是唯一连
448
+ bind 时那一行 warn 都没有的一条。
449
+ - **`flow-trigger-unknown-event`**(#3427/#3457/#3481)—— `record-*` token 落在文法之外
450
+ (`record-after-updated` 拼错、`record-change` 缺相位、数组形式)。引擎用硬编码前缀把任何
451
+ `record-` 开头的 token 路由给 record-change trigger(不查注册表,所以装任何包都无法声明新
452
+ `record-*` token),该 trigger 再用 `triggerTypeToHookEvents` 的封闭文法映射 —— 文法外就是零
453
+ hook event,即绑不上任何东西。
454
+
455
+ **两条对照规则维持 warning,这正是本次定级不是「整个文件都升 error」的原因:**
456
+
457
+ - `flow-trigger-unknown-object` —— 本 stack 没定义的对象名**可能由另一个已安装包提供**,
458
+ 规则看不见那个包的对象。免责是真的,所以继续 advisory(它自己的 hint 就这么写)。
459
+ - `flow-draft-status-ambiguous` —— draft flow **确实会**触发,这是意图歧义而非死 flow。
460
+
461
+ ## 影响面:P1 运行时发布门(`surfaces: CLI_AND_RUNTIME`)
462
+
463
+ 注册表 tier 同步 `advisory` → `gating`。除 `os validate` / `os build` / `os lint` 之外,本规则也跑在
464
+ 元数据写入路径的 publish 门上,`error` 会让 `state: 'active'` 的写入被拒(422)。**边界值得写清,因为
465
+ 直觉猜错的方向恰好对租户有利**:该门交给规则的快照里 `flows` 只装**正在写入的那一个** item
466
+ (`runtime-gate.ts`:`candidate = { objects, [stackKey]: [item] }`),并且会减掉 baseline 已产出的
467
+ finding。所以:
468
+
469
+ - 发布 flow A **不会**因为已存储的 flow B 是死 flow 而被拒;租户既有的死 flow 继续被读取和服务
470
+ (ADR-0087 读路径不对称);
471
+ - 被拒的是**死 flow 自己的发布**(含再次发布),以及 CLI 侧 —— stack 里含死 flow 的包
472
+ `os validate` / `os build` / `os lint` 转红;
473
+ - draft 保存从不过门(#4463 D1),只有发布才过。
474
+
475
+ ## 迁移
476
+
477
+ 修死 flow,不要降级规则:
478
+
479
+ - 描述符判不过 → 按 finding 里 `TimeRelativeTriggerSchema` 的原文改(它会点名该写的键);
480
+ 描述符要 `{ object, dateField, 且 withinDays | offsetDays 恰好其一 }`。
481
+ - `timeRelative` 写成了 `'daily'` 这类节奏值 → 节奏是**同级兄弟键** `config.schedule`
482
+ (默认每日 08:00 UTC,通常可省),`timeRelative` 只描述扫哪些记录。
483
+ - `record-*` token 落在文法外 → 用 `record-{before,after}-{create,insert,update,delete,write}`;
484
+ 「创建或更新」用 `record-after-write` 一条 flow 覆盖(#3427);多事件数组仍未支持(#3457),
485
+ 按事件各写一条 flow。
486
+
487
+ 仓内三个示例 app(showcase / CRM / todo)`os validate` 升级前后输出逐字一致、均通过,
488
+ 本次升级不需要修改任何示例。
489
+
490
+ - 2f1e2a5: feat(lint): null-guard 闸门覆盖 `requiredWhen`,其余各面按"绑定是否全量"逐一定案 (#4811)
491
+
492
+ #4763 的 null-guard 闸门只接了两面(对象校验规则、生命周期 hook `condition`),
493
+ 其余各面留作"待定"。本次把"待定"收敛成一条**可判定的判据**,并按它逐面定案 ——
494
+ 一个只覆盖部分面、又没有任何东西说出这件事的闸门,正是这一族缺陷本身的形状。
495
+
496
+ ## 判据:记录绑定是否对已声明字段**全量**
497
+
498
+ 这不是口味问题,也不是"这个谓词是不是 CEL"。实测 `@marcbachmann/cel-js`,两种绑定
499
+ 下的语义**恰好相反**:
500
+
501
+ | 谓词 | 全量绑定 `{a: null}` | 稀疏绑定 `{}` |
502
+ | :-------------------- | :----------------------- | :--------------------- |
503
+ | `has(record.a)` | `true` ← 陷阱 | `false` ← 真守卫 |
504
+ | `record.a < record.b` | FAULT `no such overload` | FAULT `No such key: a` |
505
+ | `record.a != null` | `false` ← **修法有效** | FAULT `No such key: a` |
506
+
507
+ 即:全量绑定下 `has()` 恒真而无用、`!= null` 是解药;稀疏绑定下 `has()` 恰恰是正确的
508
+ 守卫,而 `!= null` **自身就会 fault**。把闸门指向一个稀疏绑定的面,等于判红正确的元数据、
509
+ 并给出一个会把它改坏的"修法" —— 比不覆盖更糟。所以:**只有绑定全量的面才可以接入。**
510
+
511
+ ## 纳入:字段 `requiredWhen`
512
+
513
+ 议题没有列出这一面,而它恰恰是唯一满足判据的:`evaluateValidationRules` 用与对象校验
514
+ 规则**同一个** `materializeDeclaredFields` 合并记录来求值 `requiredWhen`。
515
+
516
+ 它也是几个已覆盖面里失败得最安静的一个:`requiredWhen` 谓词 fault 时是 **fail-open** ——
517
+ `rule-validator.ts` 记一行 `failed to evaluate — skipped` 就跳过,字段于是**从未真正必填**,
518
+ 写入照常通过。校验规则至少自 #4761 起是 fail-closed 的拒绝。因此报错文案按面区分后果:
519
+ "被跳过、字段从未必填"与"写入被 fail-closed 拒绝"是两个相反的故障,作者需要知道自己
520
+ 碰到的是哪一个。
521
+
522
+ ## 排除,且各自留下可引用的理由
523
+
524
+ - **action `visible` / `disabled`**:谓词确实走真 CEL(裸串经 `ExpressionInputSchema`
525
+ 规范成 `{dialect:'cel'}` 信封,渲染器保留它),fault 也确实 fail-closed —— 陷阱在这一面
526
+ 是真的。但绑定是客户端已取到的那条记录(详情读取,或只带列表视图投影列的一行),
527
+ `objectui` 这条路径上不存在任何物化步骤。稀疏绑定下 `!= null` 是错的修法。要覆盖它,
528
+ 得先决定是否把该绑定做成全量 —— 那是平台契约改动,不是 lint 改动。
529
+ - **flow / edge `condition`**:议题记的理由(扁平作用域下裸标识符可能是 flow 变量)对本
530
+ 模块**不成立** —— 它只解析 `record.<f>` / `previous.<f>`,从不解析裸标识符,而引擎无
531
+ 条件绑定这两个根。真正的阻碍还是全量性:`record-change-trigger.ts` 把记录播种为
532
+ `{ ...inputDoc, ...after }`,没有 `materializeDeclaredFields`,所以写入未提及的已声明列
533
+ 是**缺键**而非 null,`!= null` 会和它本要守卫的比较一样 fault。
534
+ - **字段 `readonlyWhen`**:与 `requiredWhen` 同一个字段、相反的结论 —— 它由
535
+ `stripReadonlyWhenFields` 求值,那里合并的是 `{ ...previous, ...data }`,从不物化。
536
+ - **`Field.formula`**:按产品判断排除,而非按本判据。formula 是 `value` 角色、天然可空,
537
+ `guard ? value : null` 是被祝福的写法(#3306)。是否强制守卫会改变"作者被允许写什么",
538
+ 该由维护者决定,不是一个接线缺口。
539
+
540
+ 判据、实测表与逐面台账写在 `validate-null-guards.ts` 的模块注释里,每条排除在它对应的
541
+ 调用点也留了注释,并各配一条断言钉住。
542
+
543
+ ## 顺带修正:`field '?'`
544
+
545
+ 诊断的字段名此前走 `Object.values(fields)`,把**名字键**丢掉了 —— 而名字键正是
546
+ `Field.text({…})` 这种(最常见的)写法产生的形状,于是这类对象上的每条字段级诊断都定位在
547
+ `field '?'`。名字只出现在 `where` 里时还能忍;现在报错正文要告诉作者改哪个字段,就不能忍了。
548
+
549
+ - af96af6: fix(lint): `validateOrgAxisRedLines` reads the sharing-rule keys the spec declares — the ADR-0105 D6 red lines fired on nothing before (#4984)
550
+
551
+ The two ADR-0105 D6 red lines are declared `error` and gate `os validate` /
552
+ `os build` / `os lint`. On the sharing-rule path neither of them could fire.
553
+
554
+ `validateOrgAxisRedLines` read `rule.criteria ?? rule.filter` and
555
+ `rule.sharedTo ?? rule.recipient`. `SharingRuleSchema` is `.strict()` and its
556
+ declared keys are `condition` and `sharedWith`; all four names it was reading
557
+ exist only as **rejected aliases** in `sharingRuleUnknownKeyError`, the
558
+ prescription attached to the refusal message. The rule runs on the post-parse
559
+ stack (`input: 'parsed'`), so for every spec-valid stack those four properties
560
+ were `undefined`, `JSON.stringify(undefined ?? '')` was `'""'`, and the
561
+ `parent_organization_id` test was constantly false.
562
+
563
+ **This is a behaviour change: the rule previously never triggered.** Both red
564
+ lines are now live on the sharing-rule path:
565
+
566
+ | Authored shape | Before | After |
567
+ | :--------------------------------------------------------------------------- | :----- | :------------------------------------------------------------------------ |
568
+ | `condition` reading `parent_organization_id` | passed | `error` `org-axis-permission-inheritance` at `sharingRules[i].condition` |
569
+ | `sharedWith` reading `parent_organization_id` | passed | `error` `org-axis-permission-inheritance` at `sharingRules[i].sharedWith` |
570
+ | `sharedWith: { type: 'business_unit' }` on a `tenancy.enabled: false` object | passed | `error` `org-axis-cross-org-bu-grant` at `sharingRules[i].sharedWith` |
571
+
572
+ A stack that ships today keeps building unless it contains one of those three —
573
+ the shapes D6 forbids and the gate was meant to have been refusing all along.
574
+ The rejected aliases are deliberately **not** read: a rule spelling `criteria`
575
+ or `sharedTo` is refused by the schema's own parse with the canonical key
576
+ named, and a consumer must not tolerate what the producer's contract rejects.
577
+
578
+ `condition` is an `ExpressionInput`, so all three of its shapes are scanned —
579
+ the authored bare string, the parsed `{ dialect, source }` envelope, and the
580
+ compiled `{ dialect, ast }` form.
581
+
582
+ The rule's own tests were the reason this survived review: their fixtures used
583
+ the same rejected aliases, so the suite was green while the gate was dead. Every
584
+ sharing-rule fixture now goes through `SharingRuleSchema` before the lint sees
585
+ it, and every object fixture through `ObjectSchema` — a fixture that drifts from
586
+ the spec surface fails at the fixture instead of silently exercising a shape no
587
+ author can write.
588
+
589
+ - 5b8f95b: fix(objectql,lint): enforce parent-scoped `readonlyWhen` on the server (#4889)
590
+
591
+ `readonlyWhen: P\`parent.status == 'paid'\``— the documented "once the header
592
+ invoice is Paid, its lines are frozen" lock — was enforced **only in the client
593
+ grid**. The server-side strip bound`record`and`previous`and had no`parent`at all, so every parent-scoped predicate faulted, took the fail-open branch, and
594
+ the write landed anyway. On the reference app that meant one`PATCH` rewrote the
595
+ quantity and unit price of a settled invoice's line: HTTP 200, value persisted,
596
+ the grid still drawing the cell read-only. ADR-0057 D10 puts enforcement on the
597
+ server and makes the client courtesy; here only the courtesy layer enforced.
598
+
599
+ **`parent` is now bound on the write path.** For a detail object — one declaring
600
+ exactly one `master_detail` relationship — the engine resolves the master record
601
+ and binds it as `parent` before the strip runs, on both the single-id and the
602
+ bulk (`multi: true`) update paths. A repointing write is judged against the
603
+ master it _lands on_, not the one it leaves. The read is gated on the payload
604
+ actually touching a parent-scoped predicate (decided from the parsed CEL AST, so
605
+ a field named `parent_id` costs nothing), and the bulk path batch-reads the
606
+ distinct headers in one query rather than one per row.
607
+
608
+ **An unbindable scope no longer waives the lock.** A `readonlyWhen` that names a
609
+ root the operation could not bind now resolves to **locked** — the field is
610
+ stripped — instead of "not locked". "The platform could not check this" must not
611
+ mean "allowed" on a field the author declared frozen. This is deliberately the
612
+ narrowest possible carve-out from the fail-open policy the strip has always had:
613
+ a predicate that is merely _broken_ on the record (undeclared key, `null`
614
+ ordering overload, parse error, engine throw) still fails open exactly as
615
+ before, and `requiredWhen` / option `visibleWhen` are untouched. Recorded as an
616
+ addendum to ADR-0058's D5 fail-policy matrix, alongside the same narrowing
617
+ already made for validation predicates (#4649) and hook conditions (#4775).
618
+
619
+ **And the runtime branch is a backstop, not the plan.** `objectstack compile`
620
+ now **rejects** a `parent`-scoped `readonlyWhen` on an object that declares no
621
+ `master_detail` relationship, or two of them (where the metadata does not say
622
+ which one is "the parent" and picking by declaration order would make a
623
+ data-integrity lock depend on field ordering). The common authoring mistake is
624
+ caught where it is cheap to fix, so it never reaches a runtime that has to judge
625
+ it — declared, not guessed.
626
+
627
+ No metadata changes are required: an app whose parent-scoped locks were already
628
+ correct simply starts having them enforced. If you authored one on an object
629
+ with no single master, the build now names it.
630
+
631
+ - 73580e7: feat(lint): the react-page publish gate PARSES `ChartAggregateSchema` instead of re-deriving it (#5020)
632
+
633
+ `<ObjectChart aggregate={{…}}>` is judged at publish time by
634
+ `validate-react-page-props`. That gate used to RE-DERIVE the aggregate's
635
+ declaration: a local `CHART_FUNCTIONS` copy of the function vocabulary and a
636
+ hand-written twin of the schema's count/field refinement. Two implementations of
637
+ one contract, each free to drift — and, because unknown-key handling is a
638
+ property of a **parse** rather than of a list of `if`s, a gate with no
639
+ unknown-key check at all. The rule now calls `ChartAggregateSchema.safeParse()`
640
+ on a statically resolvable literal, exactly as #5022 did for
641
+ `ChartDrillDownSchema` beside it, and both hand-derived copies are deleted:
642
+ `@objectstack/spec` is the single source of the vocabulary and the refinement
643
+ again.
644
+
645
+ **Newly reported (all `error`, all previously silent).** These are shapes the
646
+ schema, the published react-blocks type and objectui's renderer already agreed
647
+ were wrong; the old gate simply could not see them:
648
+
649
+ | authored | before | after |
650
+ | ------------------------------------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------- |
651
+ | `aggregate={{ field: 'total', groupBy: 'status' }}` (no `function`) | accepted | `aggregate.function: Invalid option: expected one of "count"\|"sum"\|"avg"\|"min"\|"max" (nothing is set there)` |
652
+ | `aggregate={{ field: 42, function: 'sum', groupBy: 'status' }}` | accepted | `aggregate.field: Invalid input: expected string, received number` |
653
+ | `aggregate={{ function: 'count', groupBy: 42 }}` | accepted | `aggregate.groupBy: Invalid input (received 42) — no accepted form matched: (1) … (2) …` |
654
+ | `aggregate="count"` / `aggregate={[]}` | accepted | `aggregate must be a configuration object, not string.` |
655
+
656
+ **Re-worded, same verdict.** Two messages now arrive from the schema rather than
657
+ from this rule's own copy. If you match on lint output, update the text:
658
+
659
+ - FROM `aggregate.function "median" is not an aggregation this chart can run.`
660
+ (hint: `Use one of: count, sum, avg, min, max.`)
661
+ TO `aggregate.function: Invalid option: expected one of "count"|"sum"|"avg"|"min"|"max" (received "median")`
662
+ — the vocabulary is the enum's own, and the author's value is echoed back from
663
+ the input (the one part zod does not put in the message).
664
+ - FROM `aggregate.function "sum" has no "field" to aggregate.`
665
+ TO `aggregate.field: aggregate.function "sum" needs a "field" to aggregate (only "count" may omit it).`
666
+ — verbatim from the schema's refinement.
667
+
668
+ The rule id (`react-chart-aggregate-invalid`) and the severity are unchanged for
669
+ both.
670
+
671
+ **`aggregate.groupBy` missing is a NEW `warning`, deliberately not an error.**
672
+ It is the one violation the platform does not agree with itself about:
673
+ `ChartAggregateSchema` and the published react-blocks type both declare `groupBy`
674
+ **required**, while objectui's `ObjectChart` honours its absence
675
+ (`schema.aggregate?.groupBy || schema.xAxisKey`) and this protocol's own
676
+ `chartAggregateCategoryKey` documents the ungrouped single-row result. Gating it
677
+ would break a working authoring shape to enforce a declaration the platform does
678
+ not keep, so the finding explains the situation and does not fail
679
+ `os lint`/`validate`/`compile`. Whether the schema loosens or the renderer
680
+ tightens is decided on #5583.
681
+
682
+ **What this does NOT fix yet.** `ChartAggregateSchema` and `ChartGroupBySchema`'s
683
+ object arm are still STRIP-posture, so the parse this gate now runs **drops** an
684
+ unknown key rather than reporting it: `groupby` for `groupBy` and
685
+ `dateGranularty` for `dateGranularity` still degrade a chart to one ungrouped
686
+ point with the build green. Wiring the parse is the precondition for closing
687
+ that, not the closing — `.strict()` is a property of a parse, and until now there
688
+ was no parse to make strict. The spec-side tightening is **#5583**; the tolerance
689
+ is pinned by name in this rule's tests so a wired gate cannot be mistaken for a
690
+ closed one (#4583).
691
+
692
+ - bf1edef: feat(formula,lint): wire ADR-0056 D4's RLS authoring gate, from the runtime's own predicate (#4983)
693
+
694
+ `isSupportedRlsExpression` has carried the same docblock since ADR-0056 D4:
695
+ "exposed so an authoring-time gate (`objectstack compile`) can REJECT a
696
+ predicate the runtime would silently drop … A `false` here means 'this
697
+ predicate will never enforce'." It had **no non-test consumer anywhere** — the
698
+ function written to fix declared-but-never-read was itself declared and never
699
+ read. This lands the consumer, in two steps that had to happen in this order.
700
+
701
+ **1. `sqlPredicateToCel` and `isSupportedRlsExpression` move FROM
702
+ `@objectstack/plugin-security` (`src/rls-compiler.ts`) TO `@objectstack/formula`
703
+ (`src/rls-predicate.ts`), and are exported from its root.** Executable code
704
+ unchanged — a change of address, not of behaviour; `plugin-security` now imports
705
+ them from `@objectstack/formula` and keeps no copy, so there is still exactly
706
+ one definition. No import path outside the two packages changes: neither symbol
707
+ was ever exported from `@objectstack/plugin-security`'s entry point. The move is
708
+ what makes step 2 possible at all — `@objectstack/lint` may depend on
709
+ `@objectstack/spec` and never on a runtime, so with the predicate living in a
710
+ runtime the gate's only other door was copying the SQL→CEL bridge, whose
711
+ boundary conditions (quoted literals are never rewritten; canonical CEL passes
712
+ through unchanged) _are_ the gate's red/green line. A fork drifting by one
713
+ character rejects policies the runtime executes correctly — the false-positive
714
+ direction, which is worse than the gap. ADR-0058 D1 asks for a single canonical
715
+ shape gate; the bridge is part of that gate.
716
+
717
+ **2. New `@objectstack/lint` rule `validateRlsPredicateEnforceability`,
718
+ `error`, on all three authoring commands**, over
719
+ `permissions[].rowLevelSecurity[].using` and `.check`:
720
+
721
+ - **`rls-predicate-unenforceable`** — parses as CEL, outside the pushdown
722
+ subset: a function call (`size(...)`, `has(...)`), arithmetic, a ternary, a
723
+ cross-object path (`record.account.region`).
724
+ - **`rls-predicate-unparseable`** — does not parse as CEL even after the legacy
725
+ SQL bridge (`=` → `==`, `IN` → `in`): SQL `AND` / `OR` / `LIKE`, a subquery.
726
+ Its own id because the fix is different — write CEL (`&&`, `||`), not a
727
+ different shape.
728
+
729
+ What the gate prevents, measured through `plugin-security` rather than inferred:
730
+ `RLSCompiler` drops the policy and logs one request-time WARN. On the read path,
731
+ when it is the only applicable policy, `compileFilter` returns the
732
+ `RLS_DENY_FILTER` sentinel instead, which is AND-ed onto the where clause — so
733
+ every select / update / delete on the object matches **zero rows**. On the
734
+ ADR-0058 D4 write path the post-image `check` becomes that same sentinel, which
735
+ no record satisfies, so every insert / update fails with `PermissionDeniedError`.
736
+ The runtime fails closed, which is why this was survivable: the result is not a
737
+ hole but a policy that reads as an authorization and behaves as a blanket
738
+ refusal, with nothing at authoring time pointing at the line that caused it.
739
+
740
+ Fix a flagged predicate by rewriting it inside the lowerable subset — `==` `!=`
741
+ `>` `<` `>=` `<=`, `in`, `&&` `||` `!`, `== null` / `!= null`, and
742
+ `startsWith` / `endsWith` / `contains` over single-column field paths (ADR-0058
743
+ D2), against a literal or a `current_user.*` value. Two specific migrations:
744
+ `has(x)` / `size(x) > 0` → `x != null` (a function call is correct in an object
745
+ _validation_ rule, which is interpreted, and wrong here, where the predicate is
746
+ compiled to a filter); and a related record's field → denormalise it onto this
747
+ object (formula/rollup) and test that column, since RLS cannot join (ADR-0055).
748
+
749
+ Same construction as the sharing-rule gate (#4698): the rule does not model the
750
+ consumer or grep for it — it calls `isSupportedRlsExpression`, the exact
751
+ function `RLSCompiler.compileFilter` consults to decide whether a dropped policy
752
+ earns its warning, so the two verdicts are one boolean by construction, pinned
753
+ in both directions over a shared corpus. Measured before shipping: every RLS
754
+ predicate declared anywhere in this repo — the `plugin-security` platform seeds,
755
+ the examples, the dogfood fixtures, the authoring skill — is supported, so the
756
+ gate turns nothing red that works today. Unlike the sharing-rule gate, CEL
757
+ _syntax_ is reported here rather than deferred to `expression-invalid`:
758
+ `validateStackExpressions` does not walk `rowLevelSecurity` at all, and could not
759
+ judge this field correctly if it did, because `owner_id = current_user.id` is a
760
+ CEL syntax error and a working RLS predicate at the same time.
761
+
762
+ - 73e576f: feat(lint,spec): SDUI 组件 props 接上解析闸门 —— `ComponentPropsMap` 不再是「声明了、从不被 parse」(#5068)
763
+
764
+ `PageComponent.properties` 是 `z.record(z.string(), z.unknown())` 这个开放口袋。
765
+ `PageComponentSchema` 自 ADR-0089 D3a 起是 `.strict()`,但**严格性不递归**:它守住
766
+ component 节点自己的键,`properties` 里面一个字都没人看。于是 `ComponentPropsMap`
767
+ 里 31 个 typed props schema 从来没有被任何东西 parse 过(#4001 批 17 的 `no gate`
768
+ 判定:载体活着、parse 缺席)。后果不是无害的 —— objectui 的 `SchemaRenderer` 会把
769
+ `properties` 整个 hoist 到节点上,再把 deny-list 之外的每个键 spread 成 React prop,
770
+ 所以一个拼错的键既不被拒绝也不被丢弃:它一路走到渲染器,在那里被忽略,而作者拿到
771
+ 的是一张成功回执。这正是 ADR-0078 要消灭的形状。
772
+
773
+ **新规则(两个诊断 id,均为 warning 级)**,落在 `@objectstack/lint`,按维护者对
774
+ #5068 的裁定走方向 A —— 在载体自己的授权门上分派解析,而不是改 `page` 协议的形状:
775
+
776
+ - **`component-props-unknown-key`** —— props schema 未声明的键,包括 props 包自身
777
+ 这一层和它底下每一个 strip 姿态的对象。走的是 `lintUnknownKeysAgainstSchema`
778
+ (本次从 `@objectstack/spec` 导出,即 `lintUnknownAuthoringKeys` 用在每个 metadata
779
+ 集合上的同一个 walker),所以 strip/strict/passthrough 的姿态规则与改名建议都是
780
+ 单一实现,这里不重新推导一遍。
781
+ - **`component-props-invalid`** —— props schema 拒绝的值:类型不对、必填缺失、枚举
782
+ 越界。
783
+
784
+ 配套的一条契约细节:走到第二层。`readonly`(#5176)挂在 `RecordHighlightsField`
785
+ 联合体的对象成员上,即 `fields[]` 数组项里面 —— authorable-surface walk 严格一层、
786
+ 到不了那里(#5607 的更正)。本闸门到得了,并且两个方向都钉了测试:声明过的
787
+ `readonly` 必须静默,拼错的 `readOnly` 必须报出来并指名正确拼法。
788
+
789
+ **未注册 type 一律跳过**,这是必须语义而不是宽松:`PageComponentSchema.type` 是
790
+ `z.union([PageComponentType, z.string()])`,光是仓内 example 语料就授权了 10 种本
791
+ map 不承载的类型、共 87 个节点(`flex`、`grid`、`object-metric`、`object-chart`、
792
+ `record:line_items` …),它们的契约在 objectui 注册表和 ADR-0080 manifest 里。拿一个
793
+ 不存在的 schema 去审判它们,只会把每一个都报成坏的。
794
+
795
+ **为什么本步只落 warning。**接上 parse 是执法的前置条件,不是执法本身(#5020 在隔壁
796
+ 表面上的同一课)。闸门落在真实语料上会报 52 条:其中 34 条是三个已发布平台页把
797
+ `{ en, 'zh-CN' }` 内联多语言 map 写进了声明为纯 `z.string()` 的 `I18nLabelSchema`
798
+ (#5728,裁定中),另有 8 条是 `element:text.content` 上同一形状。今天就 gate 掉它们,
799
+ 等于用平台自己都不遵守的声明去否掉平台自己的页面。warning 期的违例清单就是 error
800
+ 升级的验收基线,升级本身是独立一步。
801
+
802
+ 作者侧不变:`properties` 仍然照原样解析、原样保留,没有任何东西开始被拒绝 ——
803
+ `os validate` / `os build` / `os lint` 多了一类建议性诊断而已。存储路径(`saveMetaItem`
804
+ / REST `/meta`)仍然不校验 props 包,这一点被如实记录、未在本次修复。
805
+
806
+ - ddd6650: feat(lint): reject a sharing-rule condition the runtime can only skip (#4698)
807
+
808
+ #4698 reported the same failure shape three times in one app in one day: a key
809
+ that is authored, is schema-valid, reads as meaningful — and is never consumed
810
+ by the runtime. Every check verifies that what is declared is _well-formed_,
811
+ never that it is _read_. The issue's third measured instance is a sharing rule
812
+ whose CEL `condition` uses `has(...)`: the seeder cannot lower it, skips the
813
+ rule, and the only signal is one WARN line at boot. The rule exists in
814
+ metadata, is absent from `sys_sharing_rule`, and grants nothing.
815
+
816
+ **New rules, both `error`, on all three authoring commands:**
817
+
818
+ - **`sharing-rule-unlowerable-condition`** — the condition is outside the
819
+ pushdown subset: a function call (`has(...)`, `size(...)`), arithmetic, a
820
+ ternary, or a cross-object path (`record.account.region`).
821
+ - **`sharing-rule-runtime-variable-condition`** — the condition reads
822
+ `current_user.*`. Criteria sharing rules are materialised (one static
823
+ `criteria_json` per rule, from which grants are written), so there is no
824
+ "current user" at compile time. The fix is a different mechanism, not a
825
+ different spelling, which is why it has its own id.
826
+
827
+ Fix each by rewriting the predicate inside the lowerable subset — `==` `!=`
828
+ `>` `<` `>=` `<=`, `in`, `&&` `||` `!`, `== null` / `!= null`, and
829
+ `startsWith` / `endsWith` / `contains` over single-column `record.<field>`
830
+ paths (ADR-0058 D2). Two specific migrations: `has(record.x)` → `record.x !=
831
+ null` (`has()` is correct in an object _validation_ rule, which is
832
+ interpreted, and wrong here, where the condition is compiled); and a related
833
+ record's field → denormalise it onto this object (formula/rollup) and test
834
+ that column, or share the related object instead. For per-user access, use an
835
+ RLS policy (`rowLevelSecurity[].using`), where `current_user.*` _is_ resolved.
836
+
837
+ **Why this one surface and not "unread keys" in general.** "Is this key read?"
838
+ is only a lint question when the answer is computable from the authored
839
+ metadata alone, and usually it is not — a repo-wide grep for a reader is not
840
+ evidence of absence, and a consumer may live in another package, another repo,
841
+ or an uninstalled plugin. A sharing rule's `condition` is the case where the
842
+ predicate is exact: its one runtime consumer
843
+ (`bootstrapDeclaredSharingRules`) does exactly one thing with the key —
844
+ `compileCelToFilter(condition, { variables: {} })` — and a condition that does
845
+ not lower means the rule is skipped outright. So the lint calls that same
846
+ compiler, from the same package, with the same options, instead of modelling
847
+ the consumer; the verdict is identical to the seeder's by construction and is
848
+ pinned in both directions by a test over a shared corpus.
849
+
850
+ `error` rather than advisory, per the ADR-0078 claim `SharingRuleSchema`'s own
851
+ docblock makes ("the whole authorable surface is enforced — nothing here
852
+ validates and then silently does nothing"): there is no reading under which an
853
+ unlowerable condition does what it says. It fails closed, which is why it was
854
+ survivable, not why it was acceptable. Measured before shipping: every
855
+ sharing-rule condition declared anywhere in this repo lowers cleanly, so the
856
+ gate turns nothing red that works today.
857
+
858
+ CEL _syntax_ errors are deliberately left to `expression-invalid`, which
859
+ already gates this same field with a message written about syntax.
860
+
861
+ - 1e6ab15: feat(lint): uniqueness-scope rules speak the ADR-0120 vocabulary (#4986, D5a/D5b)
862
+
863
+ - **New rule `unique/unscoped-declared-index`** (warning, advisory): a declared
864
+ index with bare `unique: true` — the spelling whose scope is unstated, the
865
+ #4986 trap. Fires on the spelling alone (no tenancy/posture inference —
866
+ `organization_id` is kernel-injected at registration, so an authoring-time
867
+ guess would be wrong half the time; see #4698). The fix names both words:
868
+ `'global'` (installation-wide — exactly today's behavior) or
869
+ `'organization'` (one holder per organization). Protocol 18 rejects the
870
+ spelling (#5082). Exported as `lintUnscopedDeclaredIndexes` +
871
+ `UNIQUE_UNSCOPED_DECLARED_INDEX`, registered as its own AUTHORING_RULES
872
+ entry (validate/build) and called by `lintDataModel` for `os lint`, so all
873
+ three commands report it — each finding exactly once.
874
+ - **R10 `unique/double-declaration` rewritten as the four-quadrant scope
875
+ matrix** (ADR-0120 D5b): field `true`/`'organization'` × declared `'global'`
876
+ (or bare `true`, its deprecated spelling) on the same single column =
877
+ CONTRADICTION (the installation-wide index wins physically; the
878
+ per-organization intent is silently dead) — and the mirror, field `'global'`
879
+ × declared `'organization'`, likewise; same scope on both sides = REDUNDANCY
880
+ (the same index declared twice). The old field-`'global'` exemption is gone
881
+ (now reported as redundancy), and the fix text replaces the hand-written
882
+ `fields: ['organization_id', …]` advice with the `'organization'` spelling —
883
+ the hand-written composite is not NULL-safe (#5030).
884
+
885
+ ### Patch Changes
886
+
887
+ - c1e67e0: fix(lint): 扩 ADR-0105 D6 ② 的收件人词表至 ADR 原文范围 —— `unit_and_subordinates`
888
+ 也判红
889
+
890
+ `org-axis-cross-org-bu-grant`(D6 ②)此前只对 `sharedWith.type ===
891
+ 'business_unit'` 判红,而授权面**更大**的另一个业务单元收件人
892
+ `unit_and_subordinates`(一个 BU **加上其全部后代单元**,ADR-0057 D5 子树扩张)
893
+ 直接放行。两者的缺陷完全相同:平台级对象(`tenancy.enabled: false` /
894
+ `systemFields.tenant: false`)没有 organization 列可供 Layer 0 收口,BU 子树没有
895
+ 任何 organization 可供解析,授权因而跨到库里每一个 organization —— 正是 ADR 拒绝
896
+ 的"跨 org BU 巨树",从后门到达。
897
+
898
+ 漏掉的恰恰是 ADR-0105 D6 ② 自己点名的那一个:
899
+
900
+ > Every BU mechanism — `unit_and_subordinates` sharing, `adminScope`
901
+ > delegation, depth scopes — operates within one organization. There is no
902
+ > cross-org tree.
903
+
904
+ 判定改为收件人类型 ∈ `{ business_unit, unit_and_subordinates }`,诊断信息里点名
905
+ **实际写下的**类型并说明其触及范围(子树那一个额外写明 "AND every descendant
906
+ unit"),修复建议改为指向三个扁平收件人。
907
+
908
+ 词表与 spec 枚举 `ShareRecipientType` 的差集不再是隐式的:规则里以表格逐条写明
909
+ 拦截二者、放行 `user` / `team` / `position` 的理由(它们的运行时展开都不经
910
+ `BusinessUnitGraphService`,是 `tenancy.enabled: false` 平台级目录**被设计用来**
911
+ 共享的方式),并附一条测试断言两半恰好划分 `ShareRecipientType` —— 将来枚举加成员
912
+ 会在词表处失败,而不是无声地落进没人选过的那一桶。#4991 正是这条断言缺席的产物。
913
+
914
+ 这是 error 级门禁的扩张,因此复核了真实元数据:`examples/app-showcase` /
915
+ `app-crm` 是仓库里仅有的已声明 sharing rule(共 11 条),全仓无任何对象关掉
916
+ tenancy,扩张后 org-axis 红线数为 **0** —— 不产生新红。
917
+
918
+ - 641363a: fix(docs,lint): 修正两处裸引用公式样例,并给公式样例补一道 CEL 语义门 (#5116)
919
+
920
+ #5026 把字段公式校验从 `f.formula`(spec 按名拒绝的别名)收敛到声明的
921
+ `f.expression`,**激活了一条从未跑过的检查**。它的真实元数据扫描顺带发现文档和博客
922
+ 里有两处公式样例写的是**裸引用**:
923
+
924
+ - `content/docs/data-modeling/fields.mdx` — `'quantity * price * (1 - discount / 100)'`
925
+ - `content/blog/context-window-is-the-constraint.mdx` — `` cel`amount * probability` ``
926
+
927
+ 裸引用在 CEL 里不报错,而是**静默求值为 null**:公式表达式把记录绑定在 `record`
928
+ 命名空间下,顶层的 `quantity` 什么也解析不到。照这两行写出来的元数据,在 #5026
929
+ 之后会被 `os build` / `os validate` 判红 —— 文档教的写法和平台的门直接矛盾。两处
930
+ 都已改成 canonical 的 `record.` 前缀形式。
931
+
932
+ **新增 `@objectstack/lint` 的 `check:doc-formula-expressions`**,堵住让这两条长期
933
+ 存活的那个洞。`check:doc-authoring` 看的是字面量的**形状**,`check:skill-examples`
934
+ 对标记块跑 `tsc --noEmit` —— 两者之间,"能编译但 CEL 写错"的样例没有任何门:
935
+ `expression` 的类型就是 `string`,`'quantity * price'` 和
936
+ `'record.quantity * record.price'` 编译得一样好,而只有后者能用。
937
+
938
+ 判决直接 import `@objectstack/formula` 的 `validateExpression` —— 和 `os build`
939
+ 走的是同一个调用,不是仿制品。于是文档是被**规则本身**把关,而不是被规则的一种方言
940
+ 把关(Prime Directive #12)。
941
+
942
+ 门的难点不在判决而在**判据**:同一个 `expression:` 键在语料里承载至少三种互不相干的
943
+ 契约 —— 记录作用域的 CEL 公式、flow 的扁平作用域谓词(那里裸引用是**对的**)、以及
944
+ `schedule` 下压根不是 CEL 的 cron 串。按键名匹配会把后两类全部误判为红。所以它只认
945
+ **解析后的结构**,且只认两种不可能有歧义的形状:`Field.*({ expression })`,以及
946
+ `type: 'formula'` 与 `expression` 并列的对象字面量。无法提取的块**报错而不是跳过**
947
+ ("absence must be loud")。
948
+
949
+ 覆盖面是明说的,不含糊:只看 TS/TSX 代码块、只看能静态取出的表达式源、不做字段存在性
950
+ 校验(文档片段没有对象声明)、flow / action / validation 谓词**刻意不在范围内**。
951
+
952
+ - d4edb5d: fix(lint): the flow rule family now descends into `loop` bodies and every other nested region (#5383)
953
+
954
+ The flow anti-pattern rules read a flow's `nodes` / `edges` **flat off the top
955
+ level**, so every rule in the family was blind to anything authored inside an
956
+ ADR-0031 container — a `loop` body, a `parallel` branch, a `try_catch`
957
+ try/catch. Loop bodies are where a lot of real branching lives (a per-item gate
958
+ inside a sweep is the standard shape for a scheduled flow), so this was a large
959
+ share of authorable flow metadata that no flow rule inspected.
960
+
961
+ Measured in a real app: 8 `decision` nodes carried the inert singular
962
+ `config.condition` that `flow-inert-node-condition` exists to catch, all 8
963
+ inside a `loop` body, and `pnpm lint` reported none of them. The identical key
964
+ on a **top-level** decision in the same repo fired immediately — same key, same
965
+ node type, only the nesting depth differed. The blind spot also explains its own
966
+ survival: the gate visibly worked where it could see, so the top-level copies
967
+ got cleaned up while the nested ones read as approved.
968
+
969
+ Rules now reported at every depth: `flow-inert-node-condition`,
970
+ `flow-decision-unconditional-branch`, `flow-branch-label-unmatched`,
971
+ `flow-default-edge-with-condition`, `flow-multiple-default-edges`,
972
+ `flow-double-brace-interpolation`, `flow-bare-dollar-reference`,
973
+ `flow-date-equality-filter`, `flow-phantom-aggregation`,
974
+ `flow-error-label-not-fault`, and the `flow-approval-revise-*` family. Note the
975
+ severity asymmetry this closes: `flow-default-edge-with-condition` is a
976
+ build-stopping `error` that until now could not see a contradiction authored one
977
+ level down.
978
+
979
+ A finding inside a region carries the region scope in its `where`, so the
980
+ message still points at exactly one node — `flow 'x' · loop 'sweep' body ·
981
+ node 'y' (decision)`, matching the scope vocabulary the engine's registration
982
+ pass already uses. Findings on a flow's own graph are unchanged, byte for byte.
983
+
984
+ Two details worth knowing if you consume these findings:
985
+
986
+ - Each region is scanned against **its own** `edges`. The branch-routing rules
987
+ reason about a node together with its out-edges, and a region is a
988
+ self-contained sub-graph, so a nested decision's out-edges live in the
989
+ region's own edge list.
990
+ - `flow-double-brace-interpolation` / `flow-bare-dollar-reference` scan a node's
991
+ config recursively, and a container's config physically contains its
992
+ descendants'. A nested hit was therefore already _visible_ before this change
993
+ — but attributed to the enclosing `loop` rather than the node carrying the
994
+ string. Such a finding now names the right node, and is still reported exactly
995
+ once.
996
+
997
+ `flow-runas-unscoped` deliberately keeps looking at top-level nodes only:
998
+ widening a build-gating rule is its own change with its own blast radius, and is
999
+ tracked separately.
1000
+
1001
+ - eb26126: fix(lint): `flow-runas-unscoped` now sees data nodes nested in a `loop` body / `parallel` branch / `try_catch` region (#5633)
1002
+
1003
+ **This widens the coverage of a build-GATING rule.** `flow-runas-unscoped` is
1004
+ `severity: 'error'`, so a flow it newly catches goes from a green build to a
1005
+ failed one. That is the correct outcome — those flows cannot run at all — but it
1006
+ is a real blast radius and the reason this shipped as its own change rather than
1007
+ riding along with #5383.
1008
+
1009
+ **What was wrong.** #5383 gave the flow anti-pattern family a per-region walk and
1010
+ deliberately left this one rule reading the flow's **top-level** `nodes` only. Its
1011
+ data-node search is the rule's evidence that the flow _performs a data operation
1012
+ at all_ — and a data node inside a `loop` body is exactly as unscoped as one at
1013
+ the top level. So a scheduled flow that queried a set, looped it, and wrote per
1014
+ item passed `os build` / `os validate` clean and was then **refused at run time**:
1015
+ since #3760 a user-less run really does refuse the data operation rather than
1016
+ running it unscoped. Passing the build and then being unable to run is precisely
1017
+ what promoting this rule to `error` was for, and the shape it was missing —
1018
+ query, loop, write per item — is _the_ standard shape for a scheduled flow, so
1019
+ the write is almost always the nested node.
1020
+
1021
+ Measured, same flow with only the node's position changed:
1022
+
1023
+ ```
1024
+ update_record at TOP level -> 1 finding [error]
1025
+ update_record INSIDE loop body -> 0 findings (now: 1 finding [error])
1026
+ ```
1027
+
1028
+ **What changed.** The data-node search runs across `collectFlowGraphs(flow)` —
1029
+ every ADR-0031 region, at any depth — while the finding itself stays **flow-level**
1030
+ exactly as before: one per flow, `where` = `flow 'x' · runAs`, because `runAs`
1031
+ is a flow property and the region only supplies the evidence. The region is named
1032
+ in the **message** so you can find the node:
1033
+
1034
+ ```
1035
+ flow 'nightly_sweep' · runAs: schedule-triggered flow runs under `runAs:'user'`
1036
+ (the default when none is declared), but a schedule run has no trigger user — so its
1037
+ data node 'touch' (update_record), in loop 'loop_rows' body, has no identity to scope
1038
+ to and will be REFUSED at run time.
1039
+ ```
1040
+
1041
+ (The sentence's opening was re-worded by #5693 in this same release window; the
1042
+ sample above is the wording that actually ships.)
1043
+
1044
+ **Nothing about the top-level case moved.** A flow whose evidence is a top-level
1045
+ data node produces the same message with no region clause, and when a flow has
1046
+ data nodes at both altitudes the top-level one is still the node cited —
1047
+ `collectFlowGraphs` yields the flow's own graph before it descends. Both are
1048
+ pinned by tests.
1049
+
1050
+ **If this newly fails your build:** the flow was already broken at run time. Add
1051
+ `runAs: 'system'` to declare the elevation the sweep needs (a schedule /
1052
+ time-relative / API run has no user to scope to — there is none). See ADR-0049,
1053
+ ADR-0073 D5, #1888, #3760.
1054
+
1055
+ The repo's three example apps (`app-showcase`, `app-crm`, `app-todo`) are
1056
+ unaffected — `os validate` output is line-for-line identical before and after.
1057
+
1058
+ - be59695: fix(lint): `flow-runas-unscoped` stops telling an author they declared a `runAs` they never wrote (#5693)
1059
+
1060
+ The rule's message branched on whether `runAs` was **authored** or **defaulted**:
1061
+
1062
+ ```ts
1063
+ typeof flow.runAs === "string" ? `runAs:'user'` : `the default runAs:'user'`;
1064
+ ```
1065
+
1066
+ That distinction is real and useful — "you wrote something incoherent" is not
1067
+ "you inherited a default that does not fit a user-less trigger" — but the rule
1068
+ cannot observe it, and which arm an author got depended on the **surface** rather
1069
+ than on their file.
1070
+
1071
+ **On the CLI, only the explicit arm was reachable.** `FlowSchema.runAs` carries
1072
+ `.default('user')` and the registry wires this rule `input: 'parsed'`, so
1073
+ `flow.runAs` is the string `'user'` whether the author wrote it or not. `os lint`
1074
+ does not Zod-parse, and would have escaped that — except `defineStack` /
1075
+ `defineFlow` parse at _definition_ time, so the config module hands even the
1076
+ non-parsing command a stack with the default already filled in.
1077
+
1078
+ Measured on `examples/app-todo`, `overdue_escalation` with its `runAs` line
1079
+ deleted — the author declared nothing:
1080
+
1081
+ ```
1082
+ BEFORE — os validate
1083
+ flow 'overdue_escalation' · runAs: schedule-triggered flow runs as `runAs:'user'`, but a
1084
+ schedule run has no trigger user — so its data node 'get_overdue_tasks' (get_record) …
1085
+
1086
+ BEFORE — os lint
1087
+ ✗ flow 'overdue_escalation' · runAs: schedule-triggered flow runs as `runAs:'user'`, but a
1088
+ schedule run has no trigger user — so its data node 'get_overdue_tasks' (get_record) …
1089
+ ```
1090
+
1091
+ Both commands told someone who had written no `runAs` that their flow "runs as
1092
+ `runAs:'user'`" — which invites _"I never wrote that, the tool is confused"_ at
1093
+ exactly the moment the tool is right and the fix is one line away.
1094
+
1095
+ Meanwhile the **runtime publish gate** (#4463) judges the verbatim authored body,
1096
+ so it really did reach the other arm — the same flow was told two different
1097
+ things by two shipped surfaces.
1098
+
1099
+ **What changed.** One sentence, true of both authoring inputs, on every surface:
1100
+
1101
+ ```
1102
+ AFTER — os validate and os lint, identical
1103
+ flow 'overdue_escalation' · runAs: schedule-triggered flow runs under `runAs:'user'`
1104
+ (the default when none is declared), but a schedule run has no trigger user — so its
1105
+ data node 'get_overdue_tasks' (get_record) has no identity to scope to and will be
1106
+ REFUSED at run time.
1107
+ ```
1108
+
1109
+ The parenthetical is a statement about the **value**, not an accusation about the
1110
+ author, so it stays true for someone who did write `runAs:'user'`. This is the
1111
+ house pattern rather than a new one: `flow-draft-status-ambiguous` says `has
1112
+ status 'draft' (the default when none is authored)` for the same reason, on the
1113
+ same mechanism.
1114
+
1115
+ Only the wording moved: the same flows are flagged, with the same
1116
+ `severity: 'error'`, the same `where`, the same `hint`, and the same region
1117
+ clause when the evidence node is nested.
1118
+
1119
+ - b2e1057: fix(lint): report a `config.timeRelative` descriptor the sweep will refuse, at authoring time (#5496)
1120
+
1121
+ A flow start node declaring `config.timeRelative` got **zero** authoring-time
1122
+ diagnostics when its descriptor could not parse. The two rules that look at the
1123
+ slot each looked at something else: `lint-flow-patterns` decides "this is a
1124
+ time-relative flow" from `timeRelative != null` alone (never the shape), and
1125
+ `validate-flow-trigger-readiness`'s existing check reads only
1126
+ `timeRelative.object`, to compare it against the stack's objects. So
1127
+
1128
+ ```ts
1129
+ config: { timeRelative: { object: 'task', field: 'due_at', offsetDays: -1 } }
1130
+ ```
1131
+
1132
+ — three separate schema violations: `dateField` missing, `offsetDays` declared
1133
+ as an int **array** and written as a scalar, and `field` an unrecognized key —
1134
+ passed `os validate` silently. `TimeRelativeTriggerSchema` does reject it, but
1135
+ the only place that schema ran was **bind time**, inside
1136
+ `TimeRelativeTriggerPlugin.start()`, which warns and returns: the sweep is never
1137
+ installed, the flow reports itself armed, and the author's sole feedback is one
1138
+ line in a server log. For an AI author that line is outside the feedback loop
1139
+ entirely; `os validate` is what it reads.
1140
+
1141
+ **New rule — `flow-time-relative-descriptor-invalid` (warning).** A start node
1142
+ whose `config.timeRelative` is present runs that same schema at authoring time,
1143
+ and a failure is reported naming `config.timeRelative` with the schema's own
1144
+ issue list forwarded — so the diagnostic carries the missing key, the wrong type,
1145
+ and, for an unrecognized key, the "did you mean" the schema already computes
1146
+ (`field` → `dateField`) plus its wrong-layer guidance (a `schedule` written
1147
+ _inside_ the descriptor is told it belongs beside it). The list is rendered
1148
+ exactly as the bind-time warning renders it, so the two channels tell one story.
1149
+
1150
+ Nothing is shifted except **when** the schema runs. No shape knowledge is
1151
+ re-implemented in the rule and no consumer-side tolerance is added: the verdict
1152
+ and every word of its wording remain `TimeRelativeTriggerSchema`'s, so the rule
1153
+ tracks the descriptor's contract as it evolves instead of drifting from a second
1154
+ copy of it.
1155
+
1156
+ The rule and the existing object-name check decide different facts and cannot
1157
+ report the same one twice — only the stack knows whether an object name exists,
1158
+ and only the schema knows the descriptor's shape. A descriptor wrong in both ways
1159
+ gets both findings, at their own paths. Canonical descriptors are unaffected:
1160
+ every one shipped in the repo (the showcase `Task Due Reminder`, the
1161
+ `content/docs` examples) parses, so this adds no diagnostic to existing apps.
1162
+
1163
+ - ec6fad8: feat(lint): warn when a `multi: true` delete/update is bounded by nothing — the declared whole-object write (#5482)
1164
+
1165
+ A `delete_record` / `update_record` node that declares `multi: true` with no
1166
+ `filter` (or an empty one) writes the **whole object**: the executor forwards
1167
+ `where: {}` plus the bulk intent, the data engine classifies that as a legal
1168
+ `multi` call, and it lands on `driver.deleteMany` / `driver.updateMany` with no
1169
+ predicate. Every row, every run.
1170
+
1171
+ That path only became authorable with #5393, which gave these nodes a bulk
1172
+ declaration at all — before it the executor never passed `options.multi`, so the
1173
+ engine refused every predicate write (`Delete requires an ID or
1174
+ options.multi=true`) and "empty filter + bulk intent" was not a reachable shape.
1175
+ Since then it has been reachable and **silent**: `filter` is optional, `multi` is
1176
+ optional, nothing related the two, and the author's only feedback was the step's
1177
+ `acted` row count — reported after the rows were gone. The common way to get
1178
+ here is not malice but an omission: declaring the bulk intent and forgetting the
1179
+ constraint.
1180
+
1181
+ `os validate` / `os build` now report `flow-multi-write-unfiltered` for it:
1182
+
1183
+ ```
1184
+ flow 'nightly_purge' · node 'purge' (delete_record)
1185
+ declares `multi: true` with no `filter` key — this is a WHOLE-OBJECT write,
1186
+ by declaration: every row of 'lead' is deleted on every run. …
1187
+ ```
1188
+
1189
+ **A warning, not a gate.** An explicit whole-object purge is something the
1190
+ platform grants on purpose — the data engine's own dispatch case-set lists "bulk
1191
+ intent with no predicate at all" as a valid call — so the shape has a legitimate
1192
+ reading and the run-time path stays open. What was missing was only that the
1193
+ author hears about it _before_ the rows go. For the same reason the fix is not a
1194
+ schema `refine`: forbidding the shape would delete an intent the engine grants.
1195
+
1196
+ Two ways to satisfy the warning: write the constraint you mean into `filter`
1197
+ (the bounded-bulk reference shape is app-showcase's `showcase_inquiry_purge`), or
1198
+ confirm that emptying the object is the intent and keep it.
1199
+
1200
+ **It does not duplicate the #3810 run-time guard, which judges a different
1201
+ fact.** That guard refuses a node when a condition the author _wrote_
1202
+ interpolated to nothing (`{record.ownr}` — a typo — leaving `{}`), and it is
1203
+ deliberately keyed on "a written condition is gone" rather than on "the filter is
1204
+ empty", because losing one of two conditions also widens the blast radius. So:
1205
+
1206
+ | fact | judged by | when | verdict |
1207
+ | ----------------------------- | ------------------ | --------- | ------- |
1208
+ | a written condition vanished | #3810 filter guard | run time | refuse |
1209
+ | no condition was ever written | this rule | authoring | warn |
1210
+
1211
+ A node with `filter: { owner: '{record.ownr}' }` is silent for this rule (a
1212
+ condition _is_ written) and refused by that one; a node with no `filter` at all
1213
+ is warned about here and — correctly — allowed there. The diagnostic names the
1214
+ run-time guard so the two are not mistaken for one check.
1215
+
1216
+ Reported at every nesting depth, which matters because a scheduled sweep whose
1217
+ per-item work sits in a `loop` body is the standard janitor shape: a finding
1218
+ inside a region carries the region scope (`flow 'x' · loop 'sweep' body · node
1219
+ 'purge' (delete_record)`), on the traversal #5383/#5635 added to this family.
1220
+
1221
+ Deliberately out of range: an empty **combinator** array (`{ $and: [] }`,
1222
+ `{ $or: [] }`). #5322/#5134 ruled those and every driver implements the ruling —
1223
+ empty `$and` is TRUE (so it _is_ a whole-object write), empty `$or` is FALSE (so
1224
+ it matches nothing and must never be warned about) — but telling them apart
1225
+ requires the boolean-identity reduction, which already exists producer-side in
1226
+ each driver. A hand-written fourth copy inside a linter is how a scan and a
1227
+ validator come to answer with two different predicates, so that case is tracked
1228
+ separately instead.
1229
+
1230
+ - 7f1a635: fix(lint,spec,objectql): 编写期表达式与 `highlightFields` 校验识别注册表注入的系统列 (#5378)
1231
+
1232
+ 平台在每个业务对象上注入 `owner_id` / `created_at` / `organization_id` 等系统列,
1233
+ 文档也把 `ownership: 'user'` 写作 "injects reassignable owner_id"。但编写期的两处
1234
+ 校验只读**作者声明的** `fields`,于是注入列一律当作不存在:
1235
+
1236
+ - `validate-expressions.ts` 的 `buildFieldIndex` 让 `has(record.owner_id)` 直接
1237
+ 报错 `unknown field owner_id`;
1238
+ - `highlightFields` 存在性检查对 `['owner_id']` 发出 "is not a field on this
1239
+ object — it is silently skipped by every consumer"。
1240
+
1241
+ 也就是平台自己的 linter 否认平台自己的契约。结果是应用被迫**重声明系统列**才能通过
1242
+ 编写期校验:hotcrm#548 为此在全部 12 个业务对象上显式声明了 `owner_id`(6 个对象曾
1243
+ 报 `highlightFields` 警告,`contact_welcome` 触发器的 `has(record.owner_id)` 被硬
1244
+ 拒)。这正是本项目视为缺陷的形状:能力已声明(列确实注入且有文档),但执行层不认。
1245
+
1246
+ **权威来源只有一份。** 新增纯派生 `resolveInjectedSystemColumns()`
1247
+ (`@objectstack/spec/data`)回答"这个对象带哪些系统列",并由 registry 的
1248
+ `applySystemFields()` **消费**它——沿用 #3786 为审计字段族确立的分工:spec 声明
1249
+ **有哪些**列,registry 拥有**每列长什么样**。lint 通过同一派生取答案,因此编写期
1250
+ 判断与运行时注入不可能不一致(`@objectstack/lint` 的包契约是"只依赖 spec,绝不依赖
1251
+ 运行时",此前它根本无法读到权威)。两个消费面共用同一判定,不各写一份。
1252
+
1253
+ **并入是按对象有条件的**,不是无条件放行整张系统列名单:`ownership: 'org' | 'none'`
1254
+ 的对象没有 `owner_id`,那里的 `record.owner_id` 仍然是真错误并继续报;
1255
+ `tenancy.enabled: false` 无 `organization_id`;`systemFields: { audit: false }` 无
1256
+ 审计四列;`systemFields: false` / `managedBy: 'better-auth'` 什么都不注入(只剩驱动
1257
+ 提供的主键 `id`)。真正拼错的字段照旧被拒,并且注入列现在也进入 "did you mean?" 候选
1258
+ (`record.ownerid` → 提示 `owner_id`)。
1259
+
1260
+ 被解析的注入列在诊断与补全语义上与授权字段等同;类型健全性与 null-guard 两个索引
1261
+ **刻意**仍只读声明字段,原因写在各自注释里:列的 `type` 与可空性属于 registry 的列
1262
+ 定义,在 lint 侧另立一份就是本次要消灭的第二份副本,而 null-guard 喂的是会中断构建的
1263
+ 判定,擅自并入会让今天能构建的 stack 变红。
1264
+
1265
+ 注入行为本身零改动:`applySystemFields` 的输出在全条件矩阵上逐列不变(新增 parity
1266
+ pin 用实跑注入代码比对)。已显式重声明系统列的应用不受影响——重声明仍然合法,
1267
+ examples 三个 app 的 `os validate` 输出改动前后完全一致。
1268
+
1269
+ - 5d3ced9: fix(objectql,lint)!: a `json_schema` validation rule's `format` keyword is now ENFORCED — records that passed before can start failing (#5029)
1270
+
1271
+ > **⚠️ BEHAVIOUR CHANGE ON DEPLOYED DATA — READ BEFORE UPGRADING.** A `format`
1272
+ > keyword inside a `json_schema` validation rule used to enforce **nothing**.
1273
+ > It now enforces. If any deployed object carries such a rule, writes that
1274
+ > succeeded on the previous version can be **rejected** after this upgrade —
1275
+ > including writes from flows, seeds, imports and integrations, not just the UI.
1276
+ > Nothing about the metadata changed; the runtime simply started honouring what
1277
+ > the metadata always said. See "Before you upgrade" below.
1278
+
1279
+ ## What was broken
1280
+
1281
+ `packages/objectql/src/validation/rule-validator.ts` built its shared ajv as
1282
+ `new Ajv({ allErrors: true, strict: false })` and stopped there. In ajv 8 the
1283
+ `format` keyword is **not built in** — it ships in the separate `ajv-formats`
1284
+ package — and under `strict: false` an unregistered format is not an error: ajv
1285
+ logs one line at compile time and **drops the keyword**.
1286
+
1287
+ So this rule:
1288
+
1289
+ ```ts
1290
+ {
1291
+ type: 'json_schema',
1292
+ name: 'support_config_shape',
1293
+ field: 'support_config',
1294
+ message: 'Support config is invalid.',
1295
+ schema: {
1296
+ type: 'object',
1297
+ properties: { email: { type: 'string', format: 'email' } },
1298
+ required: ['email'],
1299
+ },
1300
+ }
1301
+ ```
1302
+
1303
+ compiled fine, ran on **every** write, enforced `type` and `required` — and
1304
+ enforced **nothing at all** for `format`. `{ email: 'not-an-email' }` was
1305
+ accepted, for every record, forever. The only signal was a stderr line at
1306
+ compile time naming no rule and no object.
1307
+
1308
+ This is the #4649 / #4762 family one level in, and the partial failure is what
1309
+ made it nasty: the rule visibly rejects a bad `type` / missing `required`
1310
+ payload in dev, so it reads as _working_ while the `format` half never fires.
1311
+ `format` is also one of the most reached-for JSON Schema keywords (`email`,
1312
+ `uri`, `uuid`, `date`, `date-time`, `ipv4`), so this was not an exotic corner —
1313
+ and it is exactly the shape an AI writing metadata reaches for first.
1314
+
1315
+ ## What changed
1316
+
1317
+ - **`@objectstack/objectql`** now depends on `ajv-formats` and registers it on
1318
+ the shared instance (`addFormats(ajv)`). The **default (full)** format set is
1319
+ used deliberately: `fast` mode trades correctness for speed on precisely the
1320
+ formats authors reach for most, and a format that "mostly" matches is the same
1321
+ declared ≠ enforced defect with a smaller hole.
1322
+ - **`@objectstack/lint`** — the #4762 publish gate
1323
+ (`validate-rule-compilability.ts`) compiles every `json_schema` rule with the
1324
+ SAME ajv environment the runtime uses, on purpose, so it registers the same
1325
+ plugin. This is not cosmetic parity: `ajv-formats` also installs the
1326
+ `formatMinimum` / `formatMaximum` keywords, so a gate without it treats them
1327
+ as unknown keywords (`strict: false` ⇒ silently ignored) and would publish a
1328
+ schema the runtime then refuses to compile — a rule that passes review and
1329
+ enforces nothing, which is the failure that gate exists to prevent. The parity
1330
+ test now reads the plugin registration out of the runtime's source, so the two
1331
+ cannot drift apart silently.
1332
+
1333
+ **Authoring is unchanged.** `format` stays a legal, publishable JSON Schema
1334
+ keyword; the publish gate does not refuse it (option 2 on #5029 was considered
1335
+ and rejected — refusing standard JSON Schema would push authors into private
1336
+ spellings). What changed is only that the declaration is now true.
1337
+
1338
+ ## Before you upgrade
1339
+
1340
+ 1. Find the rules at risk: any `object.validations[]` entry with
1341
+ `type: 'json_schema'` whose `schema` contains a `format` key, at any depth
1342
+ (including inside `$defs` / `$ref` and a `conditional`'s `then` /
1343
+ `otherwise` branch).
1344
+ 2. For each, audit the existing column against that format. Rows already stored
1345
+ are **not** re-validated — nothing is rejected retroactively, and no
1346
+ migration runs — but the **next write that touches the field** is checked,
1347
+ which includes an unrelated PATCH that merely resends the JSON blob.
1348
+ 3. If a format was aspirational rather than real, remove that `format` key (or
1349
+ relax it) _before_ upgrading. Deleting the keyword is now a meaningful,
1350
+ visible act rather than a no-op.
1351
+
1352
+ ## Known limitation, recorded deliberately
1353
+
1354
+ A **misspelled** format name is still ignored. `format: 'emial'` compiles under
1355
+ `strict: false` — ajv logs `unknown format "emial" ignored` and drops it — in
1356
+ both the runtime and the publish gate, so a typo still enforces nothing. That
1357
+ behaviour is unchanged here and pinned by test in both packages, so it is a
1358
+ known boundary rather than an oversight; closing it is an authoring-time
1359
+ decision of its own and is tracked separately.
1360
+
1361
+ - 4b50be4: fix(lint): a MISSPELLED `format` in a `json_schema` validation rule is refused at publish time (#5178)
1362
+
1363
+ #5029 registered `ajv-formats` so a `json_schema` validation rule's `format`
1364
+ keyword is really enforced. It did **not** close the other half, and said so:
1365
+ under `strict: false` — which is load-bearing, because author-written schemas
1366
+ legitimately carry vendor keywords — an **unrecognised** format name is a
1367
+ non-event. ajv logs one line at compile time and **drops the keyword**:
1368
+
1369
+ ```
1370
+ $ node -e "const Ajv=require('ajv'); const addFormats=require('ajv-formats');
1371
+ const ajv=new Ajv({allErrors:true,strict:false}); addFormats(ajv);
1372
+ const v=ajv.compile({type:'object',properties:{e:{type:'string',format:'emial'}}});
1373
+ console.log('validates {e: zzz} =', v({e:'zzz'}));"
1374
+ unknown format "emial" ignored in schema at path "#/properties/e"
1375
+ validates {e: zzz} = true
1376
+ ```
1377
+
1378
+ So an author who types `emial`, `e-mail`, `datetime` for `date-time`,
1379
+ `urireference` for `uri-reference`, `ipv_4` for `ipv4` or `Email` for `email`
1380
+ gets a rule that is declared, appears in the metadata, appears in every "what
1381
+ protects this object" listing, runs on every write, enforces `type` and
1382
+ `required` — and enforces nothing for the keyword they actually wrote. The
1383
+ record is **accepted**, which is the silent direction: nothing in the metadata,
1384
+ the UI or a test run says the constraint is inert. A typo is also the single
1385
+ most likely mistake in a hand-written or AI-generated JSON Schema.
1386
+
1387
+ **New gate — `validateRuleSchemaFormats`**, a `gating` entry in
1388
+ `AUTHORING_RULES`, so it runs on all three authoring commands (`os validate`,
1389
+ `os build`, `os lint`) with no per-command wiring. One rule id:
1390
+
1391
+ | id | fires when |
1392
+ | :------------------------------------------- | :---------------------------------------------------------------------------------- |
1393
+ | `validation-rule-json-schema-unknown-format` | a `json_schema` rule's schema names a `format` the runtime's ajv has not registered |
1394
+
1395
+ Each finding names the rule, the object, the **RFC 6901 JSON Pointer** to the
1396
+ offending keyword, and the **nearest registered name**:
1397
+
1398
+ ```
1399
+ objects.account.validations.support_shape.schema#/properties/email/format
1400
+ `json_schema` validation 'support_shape' on object 'account' names
1401
+ `format: 'emial'` at `#/properties/email/format`, which is not a registered
1402
+ format. … the schema compiles, the rule ships and runs on every write, its
1403
+ `type`/`required` keywords are enforced, and this constraint is enforced on
1404
+ no record, ever. The record is ACCEPTED, so nothing downstream reports the
1405
+ gap either.
1406
+ hint: Did you mean `format: 'email'`? The registered names are: binary, byte,
1407
+ date, date-time, … — the default `ajv-formats` set, the one
1408
+ `rule-validator.ts` registers (#5029).
1409
+ ```
1410
+
1411
+ **The vocabulary is enumerated, never written down.** The registered set is read
1412
+ off a live instance of the very ajv the publish gate builds to mirror the
1413
+ runtime (`registeredFormatNames()`), not from a hardcoded list. A list would be
1414
+ a third opinion that nobody updates: the day `ajv-formats` adds a name, it
1415
+ starts refusing a format the write path enforces — a gate that turns working
1416
+ metadata red gets switched off, and then protects nothing. Enumerating means the
1417
+ gate follows the plugin across an upgrade with no edit at all.
1418
+
1419
+ **The walk is JSON-Schema-aware, because `format` is not a magic word.** Every
1420
+ subschema position is visited — `properties`, `items` (both the 2020-12 schema
1421
+ form and draft-07's tuple array), `anyOf`/`allOf`/`oneOf`/`prefixItems`,
1422
+ `$defs`/`definitions`, `additionalProperties`, `patternProperties`,
1423
+ `if`/`then`/`else`, `not`, `contains`, `propertyNames`, `dependentSchemas`,
1424
+ draft-07 `dependencies` — at any depth, plus the rules nested in a
1425
+ `conditional`'s `then`/`otherwise`. Positions that hold arbitrary **data** are
1426
+ deliberately never read: a `format` inside `default`, `const`, `enum` or
1427
+ `examples` enforces nothing and was never meant to, so reporting it would invent
1428
+ a defect out of a legal document. A non-string `format` (`format: 42`) is left
1429
+ to `validation-rule-json-schema-uncompilable`, which already refuses it in
1430
+ ajv's own words.
1431
+
1432
+ **The runtime is untouched, and so is the #4762/#5029 compile parity.** Option 2
1433
+ on #5178 (make an unknown format a runtime compile error) was rejected: it fires
1434
+ at runtime and fail-**open**, since `checkJsonSchema` catches, logs and skips —
1435
+ trading one silent gap for another. And this is a separate judgement laid
1436
+ _beside_ the existing compile, never folded into it: `validateRuleCompilability`
1437
+ still compiles each schema in the runtime's exact environment and still
1438
+ **publishes** a typo'd format, because a typo'd format compiles there too. Its
1439
+ `#5029` pin ("a MISSPELLED format name is published, not refused") passes
1440
+ verbatim. Two questions, two rules — "does ajv accept this schema?" and "will
1441
+ this `format` keyword do anything?" — sharing one traversal and one ajv
1442
+ environment so they can never disagree about which rules exist or what
1443
+ "registered" means.
1444
+
1445
+ `ajv` / `ajv-formats` stay **lazy**, and this rule is lazier than its neighbour:
1446
+ the registered vocabulary is only fetched once a schema actually names a
1447
+ `format`, so a `json_schema` rule that names none loads neither package. Pinned
1448
+ by the package's `lazy-deps.test.ts` in all three of its layers.
1449
+
1450
+ **Upgrading:** if `os validate` / `os build` / `os lint` newly rejects a
1451
+ `json_schema` validation rule, the format name in it was never being enforced —
1452
+ fix the spelling to the name the finding suggests, or express the constraint
1453
+ with `pattern`, which is enforced. No metadata that was working changes
1454
+ behaviour.
1455
+
1456
+ - 461ccda: fix(lint): 收敛 `validateStackExpressions` / `validateSecurityPosture` 里读 spec 不声明键的 `??` 别名链 (#5017)
1457
+
1458
+ 两条规则都以 `input: 'parsed'` 注册,看到的是 `ObjectStackSchema` 解析后的产物。
1459
+ #4984 → #5009 清掉了 sharing rule 字段层和 org-axis 规则里的同形读法;这一轮是同族
1460
+ 第三轮,落在另外两个文件。议题点名五条,全包 grep 又找出同形的两条,一并处置:
1461
+
1462
+ | 原读法 | spec 事实 | 处置 |
1463
+ | :-------------------------------------------------------------------------- | :-------------------------------------------------------------------------------------------------- | :----------------------------- |
1464
+ | `rule.expression ?? rule.predicate ?? rule.condition ?? rule.formula`(两处) | 四个别名全是 `validation.zod.ts` 的 `aliases: { …: 'condition' }` **按名拒绝**的键;canonical 排第三 | 收敛为 `rule.condition` |
1465
+ | `obj.validations ?? obj.validationRules` | `ObjectSchema` 只声明 `validations`,strict 按名拒绝 | 收敛为 `obj.validations` |
1466
+ | `rule.condition ?? rule.criteria ?? rule.predicate` | `criteria` 是运行时编译产物 `criteria_json` 的拼法(#3896),`predicate` 直接拒绝 | 收敛为 `sharingRule.condition` |
1467
+ | `def.reference ?? def.referenceTo` | `field.zod.ts:331` 把 `referenceTo` 映射为 `reference` | 收敛为 `def.reference` |
1468
+ | `action.objectName ?? action.object` | canonical 是 `objectName`;`object` 按名拒绝 | 收敛为 `action.objectName` |
1469
+ | `obj.sharingModel ?? (obj.security)?.sharingModel` | **`ObjectSchema` 根本没有 `security` 键** —— OWD 三个拨盘是平铺的,且 strict:嵌套写法被整包拒绝 | **删除整个 fallback** |
1470
+ | `def.reference ?? def.reference_to` | 同 `referenceTo` | 收敛为 `def.reference` |
1471
+
1472
+ 对任何能解析的 stack,判定结果不变 —— 三个 example(crm / showcase / todo)与平台
1473
+ default permission sets 上,改动前后两条规则的 findings 逐字相同。
1474
+
1475
+ **其中一条不是死代码,是活着的错。** `rule.expression ?? … ?? rule.condition ?? …`
1476
+ 把 canonical 的 `condition` 排在两个被拒别名之后,所以一条同时写了 `condition` 和
1477
+ `expression` 的规则,lint 校验的是 schema 会拒绝的那个,而作者声明的那个**从头到尾
1478
+ 没被看过**:producer 和 consumer 对同一份元数据给出两套说法。测试里重建了旧链来演示
1479
+ 这个差异,而不是只描述它。
1480
+
1481
+ 真正的代价从来不是漏报,而是误导 —— `object.security.sharingModel` 出现在**安全
1482
+ linter**里,足以让下一位作者(人或 AI)相信对象级 `security` 信封是真实的授权面。
1483
+
1484
+ 同时补上两层结构性 meta-guard(#4992 模式,#5018 形状),让下一条死读法在 review
1485
+ 前就红:
1486
+
1487
+ - **declared-key guard** —— 规则源码里从每个 surface 上读的键,必须出现在该 surface
1488
+ 自己的 Zod `.shape` 里。扫源码不是扫行为是刻意的:不可达分支没有行为可断言。
1489
+ - **reachability guard** —— `validateSecurityPosture` 全部 15 个 `findings.push`
1490
+ 落点都必须被一条 schema **不报 `unrecognized_keys`** 的 fixture 触达。判据不是
1491
+ #5018 的 `safeParse` 全绿,而这正是这条规则的特点:它被文档明确设计为也跑在
1492
+ parse 前,好让 `os lint` 对 zod 会拒绝的**值**(`sharingModel: 'read'`)给出更
1493
+ 好的信息。被拒的**值**和被拒的**键**是两回事 —— 后者在 parsed 路径上压根到不了。
1494
+
1495
+ 七条读法各自做过变异测试:任意一条加回去,都至少有一条测试转红。
1496
+
1497
+ - 58f3220: null-guard 闸门改走 `@objectstack/formula` 的规范解析入口,并移除对 `@marcbachmann/cel-js`
1498
+ 的直接依赖(#4812)。
1499
+
1500
+ `validate-null-guards.ts` 此前自建了一个**不带 limits** 的 cel-js `Environment`,于是它会解析、
1501
+ 并进而判定平台自身拒绝的谓词 —— 超过 `maxAstNodes` (256) / `maxDepth` (32) /
1502
+ `maxListElements` (64) 的表达式在 lint 侧照常出 finding,在 `compile()` 侧却是
1503
+ `Exceeded max…`。两个解析入口,两个答案,而这个闸门握着更宽松的那个。
1504
+
1505
+ 改走 `parseCelToAst` 后两者合一。超界表达式不再由本闸门二次判定,而是交还给同一批调用点上
1506
+ 本就在跑的 `validateExpression` —— 它以 blocking error 报告边界错误,措辞面向自纠;作者修好
1507
+ 边界问题后,null-guard 判定自然回来。规则判定本身没有变化:#3306 的三元重写对本 pass 是
1508
+ verdict-neutral(重写仅在三元的某一支恰为 `null` 字面量时触发,而该支本就证明不出任何
1509
+ guard),已加测试钉住。
1510
+
1511
+ - f238970: fix(lint): 收敛 `validateRuleCompilability` 里读 spec 不声明键的 `??` 别名链 (#5096)
1512
+
1513
+ #4984 → #5009 → #5017/PR #5046 同族第八处,落在第三个文件
1514
+ (`validate-rule-compilability.ts:239`):
1515
+
1516
+ | 原读法 | spec 事实(对 live `.shape` + `safeParse` 实测) | 处置 |
1517
+ | :--------------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :----------------------- |
1518
+ | `obj.validations ?? obj.validationRules` | `ObjectSchema.shape` 只声明 `validations`,且 strict —— `validationRules` 被**按名拒绝**:`Unrecognized key(s) on this object: \`validationRules\`. … Did you mean \`validationRules\` → \`validations\`?`(#4001) | 收敛为 `obj.validations` |
1519
+
1520
+ 该规则以 `input: 'parsed'` 注册,canonical 排首位,所以别名 limb 对任何能解析的
1521
+ stack 都不可达 —— 三个 example(crm / showcase / todo,共 28 个对象、17 条验证规则)
1522
+ 上改动前后 findings **逐字相同**(两侧均 0 条)。
1523
+
1524
+ **代价从来不是漏报,而是误导。** 一个写在 consumer 里的别名 fallback,等于向后来的
1525
+ 读者、以及照着这份源码写元数据的 AI 宣称 `objects[].validationRules` 是一个真实的
1526
+ authoring 面;它把 schema 一句指名道姓的拒绝,降级成一条静默失效的分支。别名容忍属于
1527
+ producer 的拒绝面,不属于 consumer(Prime Directive #12)。
1528
+
1529
+ 同时给本文件补上两层结构性 meta-guard(#4992 模式,#5017 形状),让下一条死读法在
1530
+ review 前就红:
1531
+
1532
+ - **declared-key guard** —— 规则源码里从 `stack` / `obj` / `rule` 上读的每个键,必须
1533
+ 出现在对应 surface 自己的 Zod `.shape` 里,且 `expected` 精确匹配;另加一条 "covers
1534
+ every receiver" 元测试,以及一条针对 `flattenRules` 里 `rule[branch]` **计算属性**
1535
+ 读法的专项断言(点号扫描看不见它,而 `then` / `otherwise` 恰是本规则最有意思的读法)。
1536
+ - **reachability guard** —— 两个 `findings.push` 落点都必须被一条 `ObjectStackSchema`
1537
+ **完整 parse 通过**的 fixture 触达。这里用的是 #5018 的 `safeParse` 全绿判据,比
1538
+ `validate-security-posture` 只能要求"不报 `unrecognized_keys`"更严一档 —— 因为本规则
1539
+ 判的是"编译不过",而在 spec 眼里 `regex` 是任意字符串、`schema` 是任意 record,编不过
1540
+ 的产物依然完全 spec 合法:这条 gate 存在的理由正是 zod 看不见该缺陷,所以它永远不需要
1541
+ 一条 zod 会拒绝的 fixture。
1542
+
1543
+ 原测试 `reads \`validationRules\` too` 断言的正是被删掉的那条 limb,实测确实产出 1 条
1544
+ finding(不是空转),因此它被**替换**而不是改拼写:把 key 换成 canonical 只会留下一条
1545
+ 主题已不存在的绿测试。变异测试:别名 limb 加回去 → 2 条红;改成纯别名读 → 11 条红。
1546
+
1547
+ - 06fc07a: `os validate` now reports a `config.timeRelative` that is not a descriptor object
1548
+
1549
+ A flow start node whose `config.timeRelative` held a scalar — `timeRelative: 'daily'`
1550
+ is the natural mistake, from fusing the sweep's **cadence** with its **descriptor** —
1551
+ was accepted in complete silence at every layer. The node `config` slot is open by
1552
+ design (ADR-0018) so the schema parsed it; the engine routes a flow to the
1553
+ time-relative sweep only when `config.timeRelative` is an object, so the flow fell
1554
+ through that branch and, with no other trigger key on the node, bound to nothing and
1555
+ never fired. Not one diagnostic was produced anywhere — not even the single bind-time
1556
+ warn that an object-but-unparseable descriptor gets, because the trigger was never
1557
+ handed the flow at all.
1558
+
1559
+ A new authoring rule, `flow-time-relative-descriptor-unroutable` (warning), reports it
1560
+ at authoring time with the value, its type, and the consequence — and a hint that
1561
+ separates the two fused concepts: the descriptor says WHICH records to sweep
1562
+ (`{ object, dateField, and exactly one of withinDays | offsetDays }`), while HOW OFTEN
1563
+ is the sibling key `config.schedule`.
1564
+
1565
+ Nothing is made tolerant: a scalar is still not a descriptor and the runtime's
1566
+ behaviour is unchanged. The rule is a separate criterion from
1567
+ `flow-time-relative-descriptor-invalid` (#5496) rather than a widening of it, and the
1568
+ two partition the key along the engine's own routing predicate — a value the engine
1569
+ routes gets the schema's verdict, a value it routes nowhere gets this one, and never
1570
+ both. Arrays and `Date` are `typeof 'object'`, so they stay with the shape rule.
1571
+
1572
+ - 61fde5e: lint: warn when a form section declares a `label` but no `name` — the heading no translation key can ever address
1573
+
1574
+ `_sections` is keyed by the section's `name`, and every renderer that draws a
1575
+ section heading resolves it that way (`sectionLabel(objectName, section.name,
1576
+ authored)` — `plugin-form`'s `ObjectForm`/`ModalForm`, `plugin-detail`'s
1577
+ `record:details`), falling back to the authored label when there is no name.
1578
+ So a section authored with a `label` and no `name` is untranslatable **by
1579
+ construction**, and every gate we own was structurally blind to it:
1580
+
1581
+ - the reference validator reports keys a bundle carries that nothing declares —
1582
+ a nameless section produces no key, so there is no orphan to report;
1583
+ - the i18n coverage walk (#5405) emits one expected key per `sections[].name` —
1584
+ a section with no name contributes nothing to demand, so the report reads
1585
+ 100% while the heading renders in the source locale in every locale.
1586
+
1587
+ Measured on HotCRM: **70 of 70** form-view sections across all 14 view files are
1588
+ in exactly that state, with four locales at full declared coverage and zero
1589
+ warnings anywhere. It is also the real cause of the reported `Case / SLA /
1590
+ Resolution` English strip — that object's _detail page_ sections carry names and
1591
+ translate, while its _form view_ sections carry none.
1592
+
1593
+ `validateTranslatableSections` (rule id `translation-section-name-missing`) joins
1594
+ the reference-integrity suite, so it runs on `os validate`, `os lint` and
1595
+ `os compile` at once. It reads exactly the anchors the two landed halves already
1596
+ agree on: a view container's `sections`, its **default** `form.sections`, every
1597
+ `listViews.*` / `formViews.*` sub-container, the same three on views embedded in
1598
+ an object, and `record:details` sections nested anywhere in a page's component
1599
+ tree. `fieldGroups`-derived sections are out of range by construction — their
1600
+ heading is keyed by `fieldGroups[].key`, so they always have a name.
1601
+
1602
+ **Warning, and opt-in.** Nothing crashes and nothing is dead — one heading stays
1603
+ in the source locale — so the severity matches its sibling rules (ADR-0072 D1)
1604
+ and nothing that passed before starts failing. `os validate` over
1605
+ `examples/app-showcase` now reports 14 of these (6 from form views, 8 from
1606
+ `record:details` pages) and still exits 0. A section warns only when the
1607
+ object it renders under carries some translation of its own, which keeps the
1608
+ monolingual case silent exactly as the coverage gate already does.
1609
+
1610
+ The fix is a diagnostic at the **producer**, deliberately not tolerance at the
1611
+ consumer: deriving a lookup key by slugifying the label would fossilize a second
1612
+ de-facto contract next to the declared one, and would move the day anyone edits
1613
+ the heading text. The `name` the hint suggests is a suggestion for the author to
1614
+ write down, never a key anything resolves.
1615
+
1616
+ - 471839d: fix(spec): classify the 22 dashboard widget keys and refuse undeclared container inheritance (#4956)
1617
+
1618
+ The spec liveness ledger's `dashboard.widgets` entry carried one blanket `live`
1619
+ verdict plus a `note` asserting that the per-widget props were _"classified in
1620
+ the DashboardWidgetSchema subtree"_. **No such subtree ever existed.** The gate's
1621
+ walk drills one level and only through an explicit `children`, and `widgets`
1622
+ declared none — so all 22 authorable keys of the strict `DashboardWidgetSchema`
1623
+ were never classified, never counted as unclassified, and every run printed
1624
+ "all governed-type properties are classified" anyway.
1625
+
1626
+ That gap — not evidence — is what carried `widgets[].responsive` through the
1627
+ #3896 inert-key sweep that removed both its sibling `widgets[].performance` and
1628
+ its literal namesake `view.responsive`. `view` is drilled through `children`, so
1629
+ `list.responsive` got asked and went out; `widgets` was never asked. It was
1630
+ finally retired in #4876 / PR #4995, by hand, four days late.
1631
+
1632
+ **What changed for authors**
1633
+
1634
+ The `objectstack build` / `objectstack lint` advisory now covers dashboards, so
1635
+ five widget keys warn at build time (they never did before — `dashboard` was not
1636
+ in the lint's type collections, because until now its ledger warned on nothing):
1637
+
1638
+ | Widget key | Why it warns | What to do instead |
1639
+ | :----------------------- | :----------------------------------------------------------------------------------------------------------------------------- | :--------------------------------------------------------------------------------------------------------------- |
1640
+ | `widgets[].colorVariant` | no render path reads the top-level key — only the authoring panels do | move it under `options` (the inline metric card reads it there); the dataset-bound path has no colour affordance |
1641
+ | `widgets[].actionUrl` | no renderer draws a per-widget action button; every `actionUrl` the dashboard renderer reads belongs to `header.actions[]` | use `dashboard.header.actions[]` |
1642
+ | `widgets[].actionType` | pairs with the above | as above |
1643
+ | `widgets[].actionIcon` | zero readers in either repo | as above |
1644
+ | `widgets[].aria` | declared ARIA attributes never reach the DOM — the same false-compliance shape as the dashboard-level `aria` removed in 17.0.0 | delete it; the renderer emits its own `aria-*` |
1645
+
1646
+ Advisory only — the build never fails on these. **Nothing is removed and no
1647
+ runtime behaviour changes**: this records verdicts, it does not act on them.
1648
+ Enforce-or-remove (ADR-0049) for the five is tracked separately.
1649
+
1650
+ Two verdicts worth knowing because they cut the other way: `requiresService` is
1651
+ **live** — it reads as inert in the renderer repo but the REST layer strips
1652
+ widgets whose service is unregistered (ADR-0057 D10) — and `compareTo` is live
1653
+ on the inline chart path only; on the ADR-0021 dataset path the string arms are
1654
+ dropped and `{ offset }` fails in the analytics executor.
1655
+
1656
+ **What changed for the gate**
1657
+
1658
+ `pnpm --filter @objectstack/spec check:liveness` gains a third direction. A
1659
+ ledger entry sitting on a container property must now declare one of exactly
1660
+ three dispositions, all of them data: **drilled** (`children`), **deferred** (a
1661
+ `{ container, to }` row naming the coordinate that does classify the subtree),
1662
+ or **recorded** (a row in the shrink-only
1663
+ `scripts/liveness/undrilled-containers.baseline.json`). A container in none of
1664
+ the three fails, and so does a baseline row whose container has since been
1665
+ drilled.
1666
+
1667
+ A deferral is **resolved, not believed** — the target must exist (a governed
1668
+ type root, or a drilled `type/prop` coordinate) and classify exactly the
1669
+ container's child keys; a dangling or drifted target fails. That is the #4956
1670
+ claim itself, made checkable: pointing a deferral at `DashboardWidgetSchema`
1671
+ now produces a build failure naming it, where the same words in a `note` were
1672
+ believed for a release.
1673
+
1674
+ Every run reports both populations (today: 58 containers / 292 child keys
1675
+ classified nowhere, plus 6 resolved deferrals covering 248), `--undrilled`
1676
+ prints the worklist, and the success line no longer claims a completeness it
1677
+ does not have.
1678
+
1679
+ - b821b29: fix(lint): 清除 `validateOrgAxisRedLines` 里 spec 合法 stack 永远到不了的四条分支 (#5009)
1680
+
1681
+ `validate-org-axis-red-lines.ts` 是 `input: 'parsed'` 规则 —— 它看到的是
1682
+ `ObjectStackSchema` 解析后的产物。#4984 修掉了 sharing rule 字段那一层的 `??`
1683
+ 别名读法,但同一文件里还留着四条同形分支,每一条读的键 spec 都不声明:
1684
+
1685
+ | 原读法 | spec 事实 | 处置 |
1686
+ | :-------------------------------------------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------- | :------------------------ |
1687
+ | `cfg.permissions ?? cfg.permissionSets` | stack 根 **strip** 未声明键,`permissionSets` 解析后必为 `undefined` | 收敛为 `cfg.permissions` |
1688
+ | `cfg.sharingRules ?? cfg.sharing`(两处) | 同上 | 收敛为 `cfg.sharingRules` |
1689
+ | `str(rule.object ?? rule.objectName)` | `SharingRuleSchema` 是 `.strict()`,按名拒绝 `objectName`;`object` 又是必填 | 收敛为 `rule.object` |
1690
+ | `asArray(object.rowLevelSecurity ?? object.rls)` 整段(约 20 行) | **`ObjectSchema` 两个键都不声明**,且 `.strict()` —— 带对象级 RLS 的 stack 在 `os validate` / `os build` 直接被拒("Unrecognized key(s) on this object") | **删除** |
1691
+
1692
+ 对任何 spec 合法的 stack,判定结果不变:这些分支本来就永远不执行(反向验证 ——
1693
+ 新测试跑在改动前的实现上,29 条由 `safeParse` fixture 驱动的断言全绿)。真正的
1694
+ 代价从来不是漏报,而是误导:对象级 RLS **根本不是授权面**(`authorable-surface.json`
1695
+ 里只有 `security/PermissionSet:rowLevelSecurity` 一条),而那段死代码连
1696
+ `objects[N].rowLevelSecurity[M].using` 的诊断 path 都写好了,足以让下一位作者
1697
+ (人或 AI)相信它是真的并照着写更多代码 —— #5008 差点就这么做了。
1698
+
1699
+ 行为上唯一的差别落在 `os lint`(不 parse,跑 normalized 层):把别名拼法写进
1700
+ stack 的作者,不再从这条红线拿到诊断,而是从 schema 那里拿到一条指名道姓的
1701
+ 拒绝。别名容忍属于 producer 的拒绝,不属于 consumer(Prime Directive #12)。
1702
+
1703
+ 同时补上一层结构性 meta-guard(#4992 模式),让下一条死分支在 review 前就红:
1704
+
1705
+ - **declared-key guard** —— 规则源码里从 stack / permission set / RLS policy /
1706
+ object / sharing rule 上读的每一个键,都必须出现在对应 schema 自己的 `.shape`
1707
+ 里。扫源码而不是扫行为是刻意的:不可达分支根本没有行为可断言。
1708
+ - **reachability guard** —— 每个 `findings.push` 调用点都必须被至少一条过
1709
+ `safeParse` 的 fixture 触达;走不到的分支不允许存在。
1710
+ - 规则 ① 的 fixture 现在也走 `PermissionSetSchema.safeParse`(此前只有 sharing
1711
+ rule 和 object fixture 有这层保护)。
1712
+
1713
+ 四条分支各自被变异测试验证过:把任意一条加回去,都至少有两条测试转红。
1714
+
1715
+ - da1a64c: fix(lint): reject a validation rule whose regex or JSON Schema does not compile, at authoring time (#4762)
1716
+
1717
+ Two of the six object validation-rule types carry a **static artifact** that the
1718
+ write path hands to a real compiler, inside a `try/catch` that logs and returns
1719
+ `null`:
1720
+
1721
+ - `format` → `new RegExp(rule.regex)` → _"Validation rule '…' has an invalid regex — skipped"_
1722
+ - `json_schema` → `ajv.compile(rule.schema)` → _"Validation rule '…' has an uncompilable JSON Schema — skipped"_
1723
+
1724
+ "Skipped" means the rule is declared, appears in the metadata, appears in every
1725
+ "what protects this object" listing — and enforces nothing, on every record, for
1726
+ as long as the metadata is deployed, with a WARN line in a log nobody reads as
1727
+ the only signal. That is the shape #4649 was filed about one rule type over;
1728
+ #4761 flipped the CEL predicates to fail closed and deliberately left these two,
1729
+ because their blast radius differs (see below).
1730
+
1731
+ **New gate — `validateRuleCompilability`**, a `gating` entry in
1732
+ `AUTHORING_RULES`, so it runs on all three authoring commands (`os validate`,
1733
+ `os build`, `os lint`) with no per-command wiring. Two rule ids:
1734
+
1735
+ | id | fires when |
1736
+ | :----------------------------------------- | :----------------------------------------------------------- |
1737
+ | `validation-rule-regex-uncompilable` | a `format` rule's `regex` throws in `new RegExp(...)` |
1738
+ | `validation-rule-json-schema-uncompilable` | a `json_schema` rule's `schema` throws in `ajv.compile(...)` |
1739
+
1740
+ Each finding names the rule, the object and the config path, and carries the
1741
+ **compiler's own error text verbatim** — an author cannot act on "invalid
1742
+ regex", but can act on `Invalid regular expression: /([/: Unterminated character
1743
+ class`. Rules nested in a `conditional`'s `then` / `otherwise` are judged too
1744
+ (`evaluateRule` recurses into them and reaches the very same checkers), and the
1745
+ finding names the branch it is in.
1746
+
1747
+ **Detection is the real compilers, never a pattern that judges a pattern.** The
1748
+ regex is compiled with `new RegExp(source)` — the exact call `checkFormat`
1749
+ makes. The schema is compiled with ajv constructed with the **same options the
1750
+ runtime's shared instance uses** (`{ allErrors: true, strict: false }`), read
1751
+ back out of `rule-validator.ts`'s source by a parity test so the day those
1752
+ options change, this gate is told rather than left quietly disagreeing.
1753
+ `strict: false` is load-bearing in both directions: a gate running `strict: true`
1754
+ would reject author-written schemas carrying vendor keywords that the write path
1755
+ compiles happily — a gate that turns working metadata red gets switched off, and
1756
+ then protects nothing.
1757
+
1758
+ `ajv` is a new dependency of `@objectstack/lint`, loaded **lazily**: only a
1759
+ stack that actually declares a `json_schema` validation rule pays for it, pinned
1760
+ by the package's `lazy-deps.test.ts` alongside `typescript` and `sucrase`. The
1761
+ kernel boot path (`@objectstack/lint/runtime`) never loads it at all.
1762
+
1763
+ **The runtime half is deliberately unchanged.** `rule-validator.ts` still fails
1764
+ open on both, and the `#4649 — unchanged neighbours` pins that record it stand
1765
+ exactly as they are. A broken regex or schema is _static_ — decidable from the
1766
+ metadata alone, with no record in hand — so the authoring door closes the class
1767
+ outright without ever bricking a running deployment, whereas rejecting at write
1768
+ time would reject **every** write touching that field for as long as the bad
1769
+ metadata is deployed. Whether a runtime backstop is still wanted on top of a
1770
+ closed authoring door stays open on #4762.
1771
+
1772
+ - 5e3c83b: fix(lint): 七个规则 id 常量补进 barrel —— 消费者不必再对字面量,并加一条测试面门禁 (#5648)
1773
+
1774
+ 规则把自己的 id 写进每条 finding 的 `f.rule`,而那个字符串就是 `os lint --json` / `os validate` 递到消费者手上的东西:Studio 的 finding 渲染、下游按规则过滤/抑制、以及被授权元数据里的 `suppressWarnings: ['<rule-id>']`。规则文件为此导出同名常量,消费者本该比对常量而不是重敲 slug。
1775
+
1776
+ 但 `packages/lint` 的 `package.json#exports` 只开 `"."` 与 `"./runtime"`(`tsup.config.ts` 的 entry 也只有这两个),所以**没进 barrel 就不是「不好取」,而是完全取不到** —— 没有深路径可绕,消费者唯一的退路正是那个常量本来要消灭的字符串字面量。
1777
+
1778
+ #5648 报的是其中一个(`FLOW_TRIGGER_UNKNOWN_EVENT`,#3427/#3457/#3481 三条规则共用的 id)。它要求的全量清查又翻出**六个**,散在五个规则文件、成因都远早于它:`APPROVAL_APPROVER_TYPE_UNSUPPORTED`、`SECURITY_FLS_UNQUALIFIED_KEY`、`FIELD_GROUP_SHADOWED`、`WIDGET_LEGACY_ANALYTICS_SHAPE`、`WIDGET_LEGACY_ANALYTICS_UNRENDERABLE`、`REACT_CHART_DRILLDOWN_INVALID`。其中 `WIDGET_LEGACY_ANALYTICS_SHAPE` 最能说明代价:规则打给用户的提示原话就是 `Suppress with suppressWarnings: ['widget-legacy-analytics-shape']`,即它主动教消费者用这个 id,却不让消费者拿到承载它的常量。
1779
+
1780
+ 漏项之所以能一路静默:规则照常工作,它自己的单测**从规则文件直接 import 常量**(不经 barrel),于是唯一会发现的时刻是有人从包外去消费它。同一种一行漏项独立发生七次,不是「下次记牢」能解决的记性问题,而是缺一条判定。
1781
+
1782
+ **因此判定权移进 `packages/lint` 测试面**(`src/rule-id-barrel-exports.test.ts`):新增规则时忘了 barrel 那行,会在新规则自己的测试转绿的同一次 run 里失败。分层理由是这条不变量完全是包内的 —— 没有别处定义 lint 规则 id —— 且它要**真的 import** barrel 来核验取值,vitest 天然给得到;换成 `scripts/` 门禁则要么自己写一个 ES 解析器,要么先构建 dist,还会把反馈挪到另一个 job。
1783
+
1784
+ 门禁按两类假绿反向设计:发现面是对 `src/` 的**文件系统读取**而非手写清单(新规则文件一存在即被枚举),并对 id 条数压一条下限,避免将来改坏提取式后在空集上「全绿」;两个 entry 虽是静态列出(全动态 import 无法可靠打包),但另有一条用例从 `package.json#exports` 与 `tsup.config.ts` **各自独立**推导出同一集合并比对,新增第三个 entry 若不登记就会红,而不是悄悄不被检查。
1785
+
1786
+ 分类按**取值形状**而非发射位置判定:早先一版靠「定义旁边有 `rule: NAME`」来认,结果漏掉了经辅助函数参数发射的四个 `REACT_CHART_*` —— 恰好包含本次真实漏项之一。
1787
+
1788
+ 仅新增导出面,无行为变化:任何既有 `f.rule` 字符串都没有改动,原先对字面量的消费者继续可用。
1789
+
1790
+ - 20963e7: fix(lint): `translation-target-unknown` reads a view container's DEFAULT `form.sections` (#5415)
1791
+
1792
+ `validate-translation-references` derives the `_sections` names an object may
1793
+ legally be translated by from a list of anchors: `fieldGroups[].key`, the named
1794
+ sections on `listViews.*` / `formViews.*`, the named sections on a page's
1795
+ `record:details` component, and the view record's own `sections`. The list was
1796
+ missing one: the view CONTAINER's **default form** — the `form` that
1797
+ `defineView({ list, form, formViews })` declares and that `ObjectForm` renders
1798
+ when no named form view is asked for.
1799
+
1800
+ `collectViewRecord` iterated `['listViews', 'formViews']`, and `view.form` is
1801
+ neither of those nor the record's own `sections`, so `view.form.sections[].name`
1802
+ contributed **nothing** to the fact set. The renderer resolves those headings
1803
+ through exactly the same `sectionLabel(object, section.name, …)` convention as
1804
+ any named form view, so a bundle that correctly translated one of them was
1805
+ reported as keyed to a section "which nothing on object X declares", with a hint
1806
+ advising the author to delete a translation that renders. On the in-repo
1807
+ showcase contact surface — whose object declares `field.group` and no
1808
+ `fieldGroups[]`, so the default form is its **only** section anchor — all four
1809
+ headings were in that state, and the hint went as far as "declares no named
1810
+ section at all".
1811
+
1812
+ The default form now feeds the same section collector as `formViews.*`, bound
1813
+ by `bindingOf(view.form) ?? listBinding` — i.e. `form.data.object` first, then
1814
+ the record-level object, then the list beside it — which is the resolution the
1815
+ CLI i18n walker performs for the same surface, so the rule that DEMANDS a key
1816
+ and the rule that ACCEPTS one agree on which object a heading belongs to. Each
1817
+ anchor is now a call into one collector rather than its own copy of the loop.
1818
+
1819
+ Nothing tightens: an unnamed section is still untranslatable (it has no stable
1820
+ key to look up), and a `_sections` key no anchor declares is still reported —
1821
+ now with the real anchors enumerated in the hint.
1822
+
1823
+ - Updated dependencies [9fe9c1d]
1824
+ - Updated dependencies [d4e0809]
1825
+ - Updated dependencies [f724f69]
1826
+ - Updated dependencies [28ad90e]
1827
+ - Updated dependencies [f8644c7]
1828
+ - Updated dependencies [306ca50]
1829
+ - Updated dependencies [978fed2]
1830
+ - Updated dependencies [cfc293f]
1831
+ - Updated dependencies [de70b42]
1832
+ - Updated dependencies [fb3d99b]
1833
+ - Updated dependencies [cdfbee2]
1834
+ - Updated dependencies [29c6c9d]
1835
+ - Updated dependencies [d21c001]
1836
+ - Updated dependencies [f1cc3a3]
1837
+ - Updated dependencies [ddc2527]
1838
+ - Updated dependencies [553a47f]
1839
+ - Updated dependencies [a3a884d]
1840
+ - Updated dependencies [cfed092]
7
1841
  - Updated dependencies [2e284b2]
8
- - @objectstack/spec@17.0.0-rc.3
9
- - @objectstack/formula@17.0.0-rc.3
10
- - @objectstack/sdui-parser@17.0.0-rc.3
1842
+ - Updated dependencies [1b49eaf]
1843
+ - Updated dependencies [0161c7f]
1844
+ - Updated dependencies [e900015]
1845
+ - Updated dependencies [b5bdf48]
1846
+ - Updated dependencies [a019e52]
1847
+ - Updated dependencies [64fc6d5]
1848
+ - Updated dependencies [947d4f9]
1849
+ - Updated dependencies [eaaf03c]
1850
+ - Updated dependencies [d17df80]
1851
+ - Updated dependencies [7d0e7b5]
1852
+ - Updated dependencies [6513c17]
1853
+ - Updated dependencies [c142ced]
1854
+ - Updated dependencies [eda599e]
1855
+ - Updated dependencies [c001422]
1856
+ - Updated dependencies [77022a9]
1857
+ - Updated dependencies [52760bf]
1858
+ - Updated dependencies [5543020]
1859
+ - Updated dependencies [880d343]
1860
+ - Updated dependencies [6e82972]
1861
+ - Updated dependencies [4615a18]
1862
+ - Updated dependencies [7f62706]
1863
+ - Updated dependencies [667fa44]
1864
+ - Updated dependencies [37e38d1]
1865
+ - Updated dependencies [0f17114]
1866
+ - Updated dependencies [1eb13a0]
1867
+ - Updated dependencies [c52e608]
1868
+ - Updated dependencies [4dfd002]
1869
+ - Updated dependencies [77be690]
1870
+ - Updated dependencies [811c30c]
1871
+ - Updated dependencies [b49ccfd]
1872
+ - Updated dependencies [85d95e7]
1873
+ - Updated dependencies [168f60f]
1874
+ - Updated dependencies [244ca86]
1875
+ - Updated dependencies [546ab3c]
1876
+ - Updated dependencies [58f3220]
1877
+ - Updated dependencies [07f1822]
1878
+ - Updated dependencies [0b51bb6]
1879
+ - Updated dependencies [d9971d3]
1880
+ - Updated dependencies [abeb375]
1881
+ - Updated dependencies [ef4efa8]
1882
+ - Updated dependencies [cbb6a5c]
1883
+ - Updated dependencies [795b6e1]
1884
+ - Updated dependencies [175d789]
1885
+ - Updated dependencies [55dbbba]
1886
+ - Updated dependencies [72c3c86]
1887
+ - Updated dependencies [7f1a635]
1888
+ - Updated dependencies [502564d]
1889
+ - Updated dependencies [471839d]
1890
+ - Updated dependencies [b508244]
1891
+ - Updated dependencies [594508e]
1892
+ - Updated dependencies [1c625ca]
1893
+ - Updated dependencies [71f205d]
1894
+ - Updated dependencies [414395b]
1895
+ - Updated dependencies [26e1029]
1896
+ - Updated dependencies [108ba8d]
1897
+ - Updated dependencies [b4ad984]
1898
+ - Updated dependencies [a9f32df]
1899
+ - Updated dependencies [aeb9b27]
1900
+ - Updated dependencies [7d27da0]
1901
+ - Updated dependencies [089767f]
1902
+ - Updated dependencies [e4c8b6c]
1903
+ - Updated dependencies [acb10f6]
1904
+ - Updated dependencies [1c3da1f]
1905
+ - Updated dependencies [a34fd2e]
1906
+ - Updated dependencies [889ae47]
1907
+ - Updated dependencies [4f4c3fb]
1908
+ - Updated dependencies [7adc841]
1909
+ - Updated dependencies [4845f85]
1910
+ - Updated dependencies [bf1edef]
1911
+ - Updated dependencies [7b005b4]
1912
+ - Updated dependencies [94f7b6a]
1913
+ - Updated dependencies [5c94f83]
1914
+ - Updated dependencies [73e576f]
1915
+ - Updated dependencies [c5a5996]
1916
+ - Updated dependencies [ae490ef]
1917
+ - Updated dependencies [f61c8cf]
1918
+ - Updated dependencies [e3ef52b]
1919
+ - Updated dependencies [07f1822]
1920
+ - Updated dependencies [04fab5e]
1921
+ - Updated dependencies [efedd28]
1922
+ - Updated dependencies [5278e11]
1923
+ - Updated dependencies [23dba62]
1924
+ - Updated dependencies [ba98e26]
1925
+ - Updated dependencies [fc5f536]
1926
+ - Updated dependencies [f8cfbb4]
1927
+ - Updated dependencies [c89d18c]
1928
+ - Updated dependencies [aac90a5]
1929
+ - Updated dependencies [1e6ab15]
1930
+ - Updated dependencies [c87ef70]
1931
+ - Updated dependencies [3cb0618]
1932
+ - Updated dependencies [32a0874]
1933
+ - Updated dependencies [7055c22]
1934
+ - Updated dependencies [785a748]
1935
+ - Updated dependencies [3af0354]
1936
+ - Updated dependencies [866ff16]
1937
+ - Updated dependencies [5a85e67]
1938
+ - Updated dependencies [c183a12]
1939
+ - Updated dependencies [8064b07]
1940
+ - Updated dependencies [4a56dbd]
1941
+ - Updated dependencies [06df4fa]
1942
+ - @objectstack/spec@17.0.0-rc.4
1943
+ - @objectstack/formula@17.0.0-rc.4
1944
+ - @objectstack/sdui-parser@17.0.0-rc.4
11
1945
 
12
1946
  ## 17.0.0-rc.2
13
1947