@objectstack/lint 17.0.0-rc.0 → 17.0.0-rc.2

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 ADDED
@@ -0,0 +1,4315 @@
1
+ # @objectstack/lint
2
+
3
+ ## 17.0.0-rc.2
4
+
5
+ ### Minor Changes
6
+
7
+ - 430dcc2: fix(runtime,lint): `action.body` binds a handler only for `type: 'script'` (#4352)
8
+
9
+ `ActionSchema.body` has always described itself as "Only used when type is
10
+ `script`", and its JSDoc went further — "Only meaningful when
11
+ `type === 'script'`. When set, the runtime invokes the body inside the sandbox
12
+ … and ignores `target`." The runtime read none of it:
13
+ `actionBodyRunnerFactory` bound a handler the moment `body` parsed, and
14
+ `collectBundleActions` collected any named action. A `type: 'url'` action
15
+ carrying a leftover `body` was therefore registered in the action registry and
16
+ executed in the sandbox — reachable through
17
+ `POST /api/v1/actions/:object/:action` and through
18
+ `ql.object(o).execute(name)`, and counted by the governance inventory as a live
19
+ handler.
20
+
21
+ Declared ≠ enforced, in the shape that is hardest to debug: an author flips
22
+ `type` from `script` to `url`, reasonably concludes the body is now dead code,
23
+ and it keeps running with nothing anywhere saying so.
24
+
25
+ **Behaviour change.** `body` now runs only under `type: 'script'`:
26
+
27
+ | Action | Before | After |
28
+ | :------------------------------------------------------------- | :-------- | :----------------------------------------------------- |
29
+ | `type: 'script'` + `body` | body runs | unchanged — body runs |
30
+ | `type` omitted + `body` | body runs | unchanged — body runs (`ActionType.default('script')`) |
31
+ | `type: 'url' \| 'modal' \| 'flow' \| 'api' \| 'form'` + `body` | body ran | **no handler is bound**; the refusal is logged |
32
+
33
+ Only an action that **explicitly** declares a non-`script` type _and_ carries a
34
+ `body` changes behaviour. An omitted `type` still means `script`, because the
35
+ collectors walk raw bundle objects — a `strict: false` `defineStack` or a legacy
36
+ `manifest.actions[]` never passes through `ActionSchema`, so the schema's own
37
+ default has to be applied at the gate rather than assumed to have been applied
38
+ already.
39
+
40
+ **FROM → TO.** If you have an action whose body you want to keep running, set
41
+ `type: 'script'` and move the navigation/dispatch target elsewhere; if you want
42
+ the target behaviour, delete the now-inert `body`:
43
+
44
+ ```diff
45
+ {
46
+ name: 'open_portal',
47
+ - type: 'url',
48
+ + type: 'script',
49
+ target: '/portal',
50
+ body: { language: 'js', source: "await ctx.api.object('lead').update(…)", capabilities: ['api.write'] },
51
+ }
52
+ ```
53
+
54
+ The refusal is **not** silent — silence would only relocate the invisibility the
55
+ issue is about. `actionBodyRunnerFactory` logs a warning naming the action, its
56
+ declared `type`, and both fixes.
57
+
58
+ Authoring-time rejection of the same contradiction already shipped in #4438
59
+ (`ActionSchema` rejects `body` alongside a non-`script` `type`), so what remains
60
+ reachable here is data at rest published before that gate existed, plus bundles
61
+ that never parsed. This release closes that half. New tests also pin that the
62
+ **publish gate resolves to the rejecting schema** — through
63
+ `getMetadataTypeSchema('action')` and `ObjectSchema.actions` — so a re-point of
64
+ either registration cannot silently reopen the hole while the schema's own unit
65
+ tests stay green.
66
+
67
+ `@objectstack/lint`'s `validate-action-body-writes` filters by `type` again.
68
+ #4344 deliberately made that rule type-blind on the grounds that "the runtime
69
+ binds a handler from `action.body` alone … checking what executes beats checking
70
+ what the schema says should" — true then, and the comment predicted its own
71
+ revision. Execution and declaration are the same set again, so a non-`script`
72
+ body no longer produces write-set advice about writes that provably never
73
+ happen; the publish gate names that metadata's real defect (`type`) with its own
74
+ prescription.
75
+
76
+ `collectBundleActions` stays deliberately type-blind: it feeds governance
77
+ surfaces that must enumerate every declared action, bound or not, and the other
78
+ bind path (`engine.setDefaultActionRunner`, for Studio-authored actions) never
79
+ walks it. The gate lives at the single point where a `body` becomes an
80
+ executable handler, so there is no second copy of the rule to drift.
81
+
82
+ - 0800433: Lint an action nobody placed (ADR-0078 Phase 3, Tier-A `action-locations`).
83
+
84
+ New advisory rule `action-no-placement`: an action that declares no
85
+ `locations` and that no list view places by name renders on **no** surface —
86
+ it parses, publishes, and appears in Setup, while no user can ever click it.
87
+ ADR-0078 names this shape in its opening paragraph and Phase 3 asks for
88
+ exactly this rule; the shared completeness predicate it envisioned was never
89
+ built, so this lands standalone, one verified shape at a time.
90
+
91
+ What made it verifiable now: objectui#3142 collapsed four disagreeing
92
+ renderers onto one placement predicate. Before that, `action:bar` and the
93
+ record header rendered an _undeclared_ action anyway, so the shape only looked
94
+ inert on paper. As of objectui 17.1 it is measurably inert.
95
+
96
+ Two things are deliberately **not** flagged:
97
+
98
+ - **`locations: []`** — the documented headless action (callable over REST /
99
+ MCP / AI, no UI surface). ADR-0110 D3 refuses an undeclared handler, so a
100
+ headless declaration is the only legal way to expose one. The rule therefore
101
+ distinguishes "nowhere, deliberately" (`[]`) from an unstated placement (key
102
+ absent) and only reports the latter.
103
+ - **Actions a view places by name** — `bulkActions`, `bulkActionDefs`
104
+ (including `execution: 'aggregate'` defs, whose whole point is an action with
105
+ no single-record home) and `rowActions`, across all three list-view tiers:
106
+ `views[i].list`, `views[i].listViews.<key>` and the object-embedded
107
+ `objects[i].listViews.<key>`.
108
+
109
+ Advisory, never fatal — a view in another installed package may be the one
110
+ placing the action, the same reason `validateSemanticRoles` and
111
+ `lintLivenessProperties` warn rather than gate.
112
+
113
+ Also: the action form schema in `@objectstack/metadata-protocol` no longer
114
+ declares `shortcut` / `bulkEnabled`. Both were retired as `retiredKey()`
115
+ tombstones in spec 17, and this schema is what the Studio designer renders its
116
+ fallback form from — so advertising them handed authors two inputs that could
117
+ only ever produce an unsaveable draft (objectui#3145 removed the matching
118
+ dedicated controls). And `content/docs/ui/actions.mdx` now says which surface
119
+ is the exception to location filtering, instead of a blanket claim its own
120
+ showcase contradicted.
121
+
122
+ - 85a966f: Nav targets that are not object names (`page` / `report` / `dashboard`) are now checked at author time — closing a hole _inside_ an existing check.
123
+
124
+ `defineStack`'s `validateCrossReferences` already validates these three. But each arm is gated on the collection being non-empty:
125
+
126
+ ```ts
127
+ if (nav.type === 'page' && typeof nav.pageName === 'string'
128
+ && pageNames.size > 0 && !pageNames.has(nav.pageName)) { … }
129
+ ```
130
+
131
+ So a stack that declares **no `pages` at all** has its page-nav check silently switched off, and `{ type: 'page', pageName: 'anything' }` sails through. That is exactly the state a stack is in when the target was never written — the most likely way to reach this bug, not the least.
132
+
133
+ Note the asymmetry the guard creates. The `object` arm of the same block has no size gate: it errors unless the item carries `requiresObject`, an **explicit** opt-in to "another package provides this". Objects have to say so out loud; pages, reports and dashboards got an implicit exemption that depends on an unrelated property of the stack.
134
+
135
+ `validateNavTargetRefs` joins `REFERENCE_INTEGRITY_RULES` (16 → 17), so it runs on `validate`, `lint` and `compile` with no CLI rewiring. It reports **warning**, not error, and that ceiling is deliberate: `validate-object-references` can say ERROR for an unresolved _object_ because it resolves against the curated `PLATFORM_PROVIDED_OBJECT_NAMES` registry and knows which cross-package names are real. No such registry exists for pages, reports or dashboards, so "unresolved" cannot honestly be distinguished from "provided by a package we cannot see". Fixing the guard by tightening the parse-time throw was the other option and was rejected: a throw has no escape hatch for a legitimately cross-package page, and ADR-0072 D1's rule is that one dead finding costs more than a missed one. When `defineStack`'s check _is_ live it still hard-fails first; this rule is what speaks when that check has switched itself off, and it says so in the message.
136
+
137
+ **Three nav types are deliberately NOT covered, each verified rather than assumed:**
138
+
139
+ - **`action`** — already owned by `validate-action-name-refs`, which walks app navigation explicitly. Adding it here would double-report.
140
+ - **`component`** — a verified NON-rule. An unregistered `componentRef` does _not_ fail silently: `ComponentNavView` renders a named diagnostic ("Component not registered … Ensure the plugin that provides this surface is installed and has called `registerAppComponent()`"), and the registry exists precisely so plugin-provided surfaces may legitimately be absent. Flagging it would break valid plugin nav and prescribe a fix for something already reported better at runtime.
141
+ - **`url`** — external by definition.
142
+
143
+ Both NON-rules are pinned by tests, so "completing" the module by adding them fails there first.
144
+
145
+ **Scope honesty:** all 35 authored nav page/report/dashboard targets in this repo resolve, so this closes a latent hole rather than a shipped bug. The rule was proven to go red and then green through the real `validateReferenceIntegrity` entry point on a known-bad stack, not only in unit tests — a green check that has never been made to fail is the recurring defect this campaign keeps finding in its own instruments.
146
+
147
+ - a7163ea: The ADR-0078 completeness gate ships: a Zod-valid metadata instance that silently does nothing now fails at author time, on every authoring surface.
148
+
149
+ This closes the hole _between_ the platform's existing gates. An instance can be Zod-valid (gate 1 green), use only _live_ properties (gate 2 green), and a correctly-authored sibling can be proven to run (gate 3 green) — and still be dead, because it omits a config its consumer needs and the consumer silently no-ops. The founding case (cloud#687): an AI authored `{ type: 'summary' }` with no `summaryOperations`; the engine's index builder skips it, the field reads 0 forever, the dependent "occupancy rate" is stuck at 0 — and the agent reported the work done, because every gate it could see was green.
150
+
151
+ **Why this is worse than the unknown-key hole #4001 just closed.** There, the author wrote a key we don't know, and the parse now rejects it with a prescription. Here every key is one we know, the schema is satisfied, nothing warns, and the author gets a success. It manufactures false completion without the author mistyping anything — and the review step that catches a human's bare summary (seeing the field render `0`) is exactly the step AI authoring removes.
152
+
153
+ **One shared predicate, every surface — the ADR's core decision.** Instance-completeness checks previously existed _only_ in cloud's AI-build graph-lint, so a stack authored with `os` + a coding assistant, an MCP agent, `os validate` in CI, or by hand got none of them (`formula_without_expression` existed nowhere in the framework). The judgement now lives in `@objectstack/spec/kernel`'s `checkFieldCompleteness` / `checkViewCompleteness` — sibling of `isIncoherentAggregate`, the ADR-0019 pattern — consumed by the new `@objectstack/lint` `validate-functional-completeness` and registered as an author-time rule (28 → 29), so `os build` / `os validate` / `os lint` / MCP / hand authoring are all covered. Cloud graph-lint can re-home its duplicate rules onto the same predicate rather than drifting from it.
154
+
155
+ **Every rule cites the runtime line that makes it true**, because the completeness audit's scariest candidate — a "sharing rule fails open and shares every record" — collapsed on a three-file read, and #4001's last two batches shipped four confidently wrong prescriptions before learning the same thing:
156
+
157
+ | rule | the silent skip | severity |
158
+ | ------------------------------------------------------------- | ---------------------------------------------------------------------------------- | -------- |
159
+ | `field/summary-without-operations` | `engine.ts` — `if (!d.summaryOperations) continue` | error |
160
+ | `field/formula-without-expression` | `engine.ts` builds the formula plan only from fields that HAVE one | error |
161
+ | `field/relationship-without-reference` | `$expand` — `if (!referenceObject) continue` | error |
162
+ | `field/choice-without-options` (`select`, `radio`) | `record-validator.ts` — an empty option list disables server-side value validation | error |
163
+ | `field/choice-without-options` (`checkboxes`) | same branch, but shared with free-form | warning |
164
+ | `view/layout-without-binding` (`kanban`, `calendar`, `gantt`) | renderer falls back to literal default field names | warning |
165
+
166
+ **The deliberate NON-rules are pinned as hard as the rules.** `multiselect` without options is _not_ flagged: `record-validator.ts` says verbatim `// free-form (tags without options)`. The runtime blesses it as a mode, which makes it ADR-0078 case (3) "genuinely optional" — flagging it would be another false prescription, and the test is where that attempt fails first. `timeline` / `tree` views are likewise out of v1: they have config schemas, but their renderer behaviour has not had its verification pass.
167
+
168
+ **It found a real one on its first run against a real app.** `showcase_field_zoo.f_summary` was a bare `Field.summary({ label: 'Roll-up Summary' })` — one line below an `f_formula` that _is_ complete, in the object whose entire job is to show what each field type looks like. So the canonical example of a roll-up in this repo computed nothing. It could not be fixed by adding `summaryOperations`: a roll-up aggregates a child into its parent, and the zoo is a leaf (`f_master_detail` makes it a child of `showcase_project`, and nothing is a child of the zoo). Removed, with the working examples named — `showcase_invoice.total` for the plain sum, `showcase_expense_report.total_amount` / `approved_amount` for the `summaryOperations.filter` variant. The rule it broke was the file's own: "relationship types point at the other showcase objects so they have REAL targets."
169
+
170
+ Tracked in #4544. This is Phase 1; Phase 2 (the cloud authoring-path config-drop fix) is in the `cloud` repo, and Phase 3 lands the Tier-B shapes one verification pass at a time.
171
+
172
+ - e6e9379: ADR-0078 Phase 3: a webhook with no `triggers` now fails at author time — and the Tier-B candidate list is corrected to what verification actually supports.
173
+
174
+ **The rule.** `webhook/without-triggers`, error severity, in the shared `@objectstack/spec/kernel` predicate alongside the Phase 1 rules, walked by `@objectstack/lint`'s `validate-functional-completeness` over `stack.webhooks` in both collection spellings. A webhook that declares no trigger materializes into `sys_webhook`, renders in Setup looking armed, and delivers nothing.
175
+
176
+ **Why it needed two sources, and why the first one argued against it.** The runtime skip site reads:
177
+
178
+ ```
179
+ if (triggers.size === 0) {
180
+ // No dispatchable triggers (or a manual-only webhook with none) —
181
+ // skip auto-enqueue.
182
+ return null;
183
+ ```
184
+
185
+ That parenthetical _blesses_ the empty case as a deliberate mode — structurally identical to the `multiselect`-without-options NON-rule, where `record-validator.ts`'s `// free-form (tags without options)` is exactly why we do not flag it. On that evidence alone this candidate stays unenforced.
186
+
187
+ The mode it names does not exist. `webhook.zod.ts`'s #3196 note records that the `api` (manual/programmatic fire) trigger was _removed_ because "no manual fire path exists — the only webhook HTTP surface re-queues already-failed deliveries". There is no way to fire a webhook the auto-enqueuer dropped. Inert on every path, so: `error`.
188
+
189
+ > **The generalization, now written into the module and pinned by a test:** a runtime comment records what its author believed, and beliefs go stale when a sibling feature is deleted. A blessing has to be corroborated by something showing the blessed mode is still _reachable_ — otherwise it is a comment about a mode that no longer exists. The test asserts the finding carries both citations, so nobody demotes this rule on the strength of the comment alone.
190
+
191
+ `triggers: []` is flagged identically to an omitted `triggers`. Unlike an action's `locations: []` — the documented headless spelling — an empty array here carries no "I meant it" signal, because turning a webhook off has its own key (`isActive`). The repo's one real webhook (`showcase_task_changed`) confirms it: shipped inactive via `isActive: false`, with a full trigger list.
192
+
193
+ **The corrected Tier-B disposition.** Phase 3 was scoped from the 2026-06 audit's Tier-A/B catalog. Verifying each candidate before writing it — the discipline that caught four false prescriptions in #4001 — found most of the list already closed or misfiled:
194
+
195
+ | candidate | disposition |
196
+ | ------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- |
197
+ | A2 action without `locations` | **already shipped** — `validate-action-locations.ts`, which already exempts the documented `locations: []` |
198
+ | B approval empty/unresolvable approvers | **already shipped** — `validate-approval-approvers.ts` |
199
+ | B select/multiselect without options | shipped in Phase 1 |
200
+ | B write-side referential integrity | **not an authoring-lint item** — a runtime gap; no metadata omission to detect |
201
+ | B `unique:true` no-op on memory driver | **not an authoring-lint item** — a driver gap |
202
+ | B composite/repeater sub-field constraints | **not an authoring-lint item** — a runtime gap |
203
+ | B nav targets of type page/report/url/component/action | **genuine gap, different module** — the key is present but dangling, which is reference resolvability (ADR-0072), not completeness (ADR-0078) |
204
+ | B dataset with zero measures | **unverified — not shipped.** No runtime consumer in this repo; the dataset compiler lives elsewhere |
205
+ | B webhook without triggers | ✅ **this change** |
206
+ | B schedule trigger with invalid cron | **unverified — not shipped.** `normalizeSchedule` accepts any non-empty string, but the scheduler's behaviour on an invalid one was not traced |
207
+
208
+ Two candidates are deliberately left unshipped rather than written on the audit's stated confidence, and one is left for the module that actually owns it. The audit's own lesson stands: it produces _candidates_, not confirmed bugs — the scariest one collapsed on a three-file read.
209
+
210
+ Tracked in #4544.
211
+
212
+ - 459f925: feat(lint): `has(x)` 不是 null 守卫 —— 发布期直接拒绝未守卫的可空比较 (#4763)
213
+
214
+ CEL 的 `has(x)` 问的是**键是否存在**。自 #4649 起,谓词读到的记录对对象声明的每个
215
+ 字段都是**全量**的:一个声明了却存 `NULL` 的列同样"存在",所以
216
+ `has(record.end_date)` 对声明字段恒为 `true`,什么也没告诉作者。于是这个读起来
217
+ 像守卫的写法根本不是守卫:
218
+
219
+ ```text
220
+ has(record.start_date) && has(record.end_date) && record.end_date < record.start_date
221
+ ```
222
+
223
+ 它会走到 `null < null`,CEL 没有对应重载,整个谓词中断。#4761 之前中断被吞掉
224
+ (规则跳过,一条 WARN),也就是说**这一形状的规则在任何含 null 值的行上从未生效
225
+ 过**——它写在元数据里、读起来完全正确、却什么都没有强制执行。#4761 把运行时改成
226
+ fail-closed 之后,当场就在我们自己的两个示例对象里抓到了它。
227
+
228
+ 运行时拒绝是兜底,不是该学到这件事的地方:作者会在真实数据(很可能是生产数据)
229
+ 上收到一个 400,离写下规则可能已经过去几个月。而这个错误**仅凭元数据就可判定**
230
+ ——谓词的 AST 加上对象声明的字段类型,就足以判断某个操作数是否可能为 null。按
231
+ AGENTS.md PD #12(在创作期拒绝,不要在消费端容忍),它属于发布闸门。
232
+
233
+ **新增闸门(error,直接拒绝,没有降级开关)。** `os build` / `os validate` /
234
+ `os lint` 与运行时发布闸门共用的 `validateStackExpressions` 现在会拒绝这样的谓词:
235
+ 对**声明为可空**的字段(没有 `required: true`、没有 `defaultValue`、没有默认选项、
236
+ 不是 autonumber)应用**排序**(`< <= > >=`)或**算术**(`+ - * / %`,含一元 `-`)
237
+ 运算符,而该操作数没有被同一布尔分支内支配它的 `!= null` / `== null` / `!isBlank()`
238
+ 显式判空所守卫。`has(x)` **刻意不**计入守卫——这正是本规则存在的理由。错误信息点名
239
+ 规则、操作数与修法,收尾句逐字取自 `rule-validator.ts` 的 `unevaluableRuleError`,
240
+ 两道闸门措辞完全一致。
241
+
242
+ 覆盖面(有意划定,而不是含糊地覆盖一半):对象**校验规则**(含 `conditional` 规则
243
+ `then` / `otherwise` 里嵌套的谓词)与**生命周期 hook 的 `condition`** ——即真正由 CEL
244
+ 在全量记录上求值、会 fail-closed 的两类面。共享规则条件(下推成 SQL 过滤,`NULL > x`
245
+ 是三值逻辑,不会 fault)、flow 的扁平作用域条件(裸标识符可能是 flow 变量)与
246
+ `Field.formula`(有自己的 #3306 `guard ? value : null` 处理)不在此列。
247
+
248
+ 对**未声明**键的 `has()` 完全不受影响——那才是它的正当用途:区分"这次 PATCH 里
249
+ 根本没提到这个键"与"显式写了 null"。示例应用无需改动即通过新闸门。
250
+
251
+ - 8e53e5d: feat(lint): 视图 `searchableFields` 按运行时同一套判定做构建期校验 —— 一个 lookup 笔误不再等到 400 才暴露 (#4830)
252
+
253
+ 视图(list view)的 `searchableFields` 会被客户端逐字回显为 `$searchFields` 覆盖参数,而
254
+ REST 入口闸(#4254)会用 `resolveSearchFieldResolution`(`@objectstack/spec/data`)判定
255
+ 该对象的可搜索集合 —— 声明一个 lookup 等「不可搜索」字段,运行时会把**整条查询** 400
256
+ (`INVALID_FIELD`),列表工具栏搜索对全体角色彻底不可用。此前 `compile`/`validate` 只查
257
+ 字段**存在性**,这类笔误全绿放行,只能靠人肉点搜索框发现。
258
+
259
+ 新增规则 `searchable-field-unsearchable`(error 级,新导出常量同名):对每个视图级
260
+ narrowing(对象内建 `listViews`、`defineView` 的 `list`/`listViews`、react 页面的
261
+ `<ListView searchableFields>`)按**运行时同一个函数**(`resolveSearchFieldResolution`,
262
+ 非复制的类型清单,杜绝再度漂移)判定 declared = enforced:
263
+
264
+ - 对象未声明 `searchableFields`(auto 源):视图里出现 lookup/json/hidden/审计列等
265
+ auto-default 拒绝的字段 → 构建期 error,信息含类型与 400 后果,lookup 给出「镜像到本
266
+ 对象 text/formula 字段」的处方;
267
+ - 对象已声明(declared 源):视图条目超出对象声明集合 → 构建期 error(视图只能收窄、
268
+ 不能放宽,ADR-0061);
269
+ - 对象自身的 `searchableFields`(canonical)维持**只查存在性**:运行时 declared 分支按
270
+ 存在过滤、不按类型过滤,声明即被引擎执行,构建期拒绝会误伤运行时接受的元数据
271
+ (ADR-0072 D1);
272
+ - 注册表注入的系统列在 narrowing 中跳过判定(其运行时元数据对 linter 不可见,宁可漏报
273
+ 不可误报)。
274
+
275
+ 内部核心 `checkSearchableFieldList` / `indexObjectSearchTargets`(模块级导出,未入包
276
+ barrel)签名有变:索引值从 `Set<string> | null` 变为 `ObjectSearchTarget | null`,并新增
277
+ 可选 `role: 'canonical' | 'narrowing'`(默认 `'narrowing'`)参数。
278
+
279
+ - ebb209c: fix(spec,lint): withdraw the `record:*` blocks from the react tier — no renderer read the props it published (#4413)
280
+
281
+ The react-tier contract published `objectName` / `recordId` on
282
+ `<RecordDetails>`, `<RecordHighlights>`, `<RecordRelatedList>` and
283
+ `<RecordPath>`, and no renderer read either prop. All ten `record:*` renderers
284
+ take their record from `useRecordContext()`, which only the record route
285
+ (`RecordDetailView`) and the metadata editor's preview (`PagePreview`) ever
286
+ mount; the `kind:'react'` page renderer wraps the page in a
287
+ `SchemaRendererProvider` alone. So the blocks rendered their "bind a record to
288
+ preview" placeholder — or, for `record:related_list` (the one that does read
289
+ `schema.objectName`), refused to fetch because the parent id never arrived. A
290
+ page authored exactly to contract came back EMPTY with nothing reported
291
+ anywhere, including by `os validate`, which resolved those props' field names
292
+ against the object they named: lint standing guard over a binding that never
293
+ ran.
294
+
295
+ Withdrawn rather than implemented. The contract was not merely unimplemented,
296
+ it was the wrong SHAPE: per-block bindings describe four independent fetches of
297
+ one record, which is exactly the coupling the shared record context exists to
298
+ prevent (`record:details` drops the fields a mounted `record:highlights`
299
+ registered; one inline-edit save bar commits them all under a single
300
+ `ifMatch`). Honoring the props would have fossilized that (Prime Directive
301
+ #12). The naming of that primitive — a record SCOPE an author wraps around the
302
+ family, one fetch, shared context — is the open design question, filed as #4444.
303
+
304
+ `@objectstack/spec` drops the four blocks from `REACT_BLOCKS` and gains the
305
+ ledger for why, plus the working replacement per type. The family is derived
306
+ from `ComponentPropsMap`, so a record component added later is gated the day it
307
+ lands — including the six that were never in the contract but are just as
308
+ reachable through the registry-built react scope.
309
+
310
+ `@objectstack/lint` gains `react-block-needs-record-context` (error), which
311
+ rejects them on a react page by tag and through `<Block type="record:…">`
312
+ alike, quoting the block that does work: `<ListView filters={['<lookup>', '=',
313
+ parentId]}>` for a related list, `<ObjectForm mode="view" recordId={…}>` for a
314
+ field panel. A locally-declared component of the same name shadows the injected
315
+ scope and is left alone.
316
+
317
+ - 4b945fc: Author-time rules now gate the RUNTIME metadata write path, not just the CLI (#4463)
318
+
319
+ The 26 author-time rules `os validate` / `os build` / `os lint` share (#4409) ran on
320
+ those three commands and nowhere else. Every runtime metadata write — Studio's
321
+ designer, REST `/meta` item CRUD, an MCP/AI agent authoring a flow — reaches
322
+ `saveMetaItem`, which did a per-type Zod `safeParse` and stopped. For a tenant that
323
+ was not the weakest of four doors, it was the **only** door: a `sys_metadata`
324
+ overlay row is not in the CLI's config file, so there was no command they could run
325
+ instead. An approval flow whose `expression` approver is broken CEL
326
+ (`record.owner ==`) is Zod-valid, so it saved, registered, and failed at the node's
327
+ entry the first time it fired — the exact body `os lint` had rejected since #4409.
328
+
329
+ **One shared core, one runtime gate.**
330
+
331
+ - The rule registry moved from `packages/cli` into `@objectstack/lint`
332
+ (`AUTHORING_RULES`), and the CLI now calls it there. Five rule modules moved with
333
+ it (`lintFlowPatterns`, `lintLivenessProperties`, `lintAutonumberFormats`,
334
+ `lintViewRefs`, `data-model-rules`), unchanged. There is one table; a second one
335
+ cannot be introduced without failing `authoring-rule-wiring.test.ts`.
336
+ - New kernel-safe subpath export **`@objectstack/lint/runtime`** — the entry the
337
+ metadata write path imports. Running the gate loads neither `typescript` nor
338
+ `sucrase`, pinned by a new `runtime-lazy-deps.test.ts` alongside the existing
339
+ `lazy-deps.test.ts`, which is unchanged.
340
+ - Each registry entry now declares `surfaces` (`cli` / `runtime-publish`) plus
341
+ either the metadata `runtimeTypes` it judges or a written `surfaceReason`. The
342
+ ratchet fails an entry that answers neither.
343
+
344
+ **Behaviour**
345
+
346
+ - A `state: 'active'` `saveMetaItem` — and the draft→active promotion in
347
+ `publishMetaItem` — of a **flow** runs the flow / approval / expression /
348
+ reference rule families. A gating finding is refused with **422
349
+ `INVALID_METADATA`**, in the same structured envelope the Zod failure already
350
+ used, with `rule` / `path` / `where` / `message` / `hint` per issue.
351
+ - **Draft saves are never gated** — a draft is allowed to be half-finished and
352
+ cannot execute.
353
+ - Only the write is judged: the rules run twice (context with and without the
354
+ submitted item) and only findings the item _added_ can refuse it, so a
355
+ pre-existing violation in a stored row never blocks an unrelated save. Stored
356
+ rows keep being read.
357
+ - Escape hatch **`OS_ALLOW_UNLINTED_METADATA_WRITES=1`** turns the refusal into a
358
+ loud log for a migration window. Unset it once the metadata is fixed — the
359
+ runtime executes what it published.
360
+
361
+ Only `flow` writes are gated in this pass; every other metadata type carries a
362
+ recorded reason in the registry.
363
+
364
+ - 97faca3: feat(spec,lint)!: give `bulkActionDefs` a shape, and lint the aggregate name it references (#4457)
365
+
366
+ A selection-bar bulk action was declared as
367
+ `z.array(z.record(z.string(), z.any()))` — **no shape at all**. The real
368
+ contract lived in objectui's `BulkActionDef` interface and in the executor that
369
+ reads it, so every authoring mistake landed as a silent runtime downgrade:
370
+ `opeartion` parsed and the executor hit `Unknown operation: undefined` per row;
371
+ `excution: 'aggregate'` parsed and the def stayed per-record, so the endpoint
372
+ written for ONE `_selectedIds` call got N calls instead — the exact defect
373
+ objectui#3139 was filed to make expressible. That is ADR-0018's "second
374
+ vocabulary" smell (an action surface sharing none of `ActionSchema`'s checks)
375
+ crossed with ADR-0078's silently-inert metadata.
376
+
377
+ `ui/bulk-action.zod.ts` types it, with the same treatment `ActionParamSchema`
378
+ got in #3746/#4001: a **strict** def whose unknown-key error names the offending
379
+ key and the canonical spelling. Beyond spelling, it refuses the combinations the
380
+ executor never reads — `patch` outside an `update`, `execution` outside a
381
+ `custom`, `params` on a `delete`, `batchSize` on an aggregate — and refuses a
382
+ hand-written `actionDef`, which is attached by the renderer when it resolves the
383
+ def's `name` and which authored by hand would smuggle an action definition past
384
+ the action registry.
385
+
386
+ **One shape that parsed before is now rejected**: `operation: 'custom'` without
387
+ `execution: 'aggregate'`. `resolveBulkActions` attaches a dispatcher for exactly
388
+ one authored shape (the aggregate one); every other custom def falls to
389
+ `Promise.resolve()` per row — a button that reports success for every selected
390
+ record and does nothing. The error names both legal forms: `bulkActions:
391
+ ['<name>']` for per-record (promoted with the action's own label, params and
392
+ `visible`), `execution: 'aggregate'` for one call over the whole selection.
393
+
394
+ Two things are deliberately left open:
395
+
396
+ - **`params[]` is `.passthrough()`.** objectui's `BulkActionParam` declares a
397
+ `[key: string]: unknown` catch-all — widget config (min/max/step/format)
398
+ forwarded to the field renderer as-is. Locking it down would reject valid
399
+ config, so declared keys are typed and the rest rides through, the same call
400
+ `dashboard.zod.ts` makes for a widget's `config`.
401
+ - **The bulk-param / action-param spelling divergence** (`help`/`helpText`,
402
+ `default`/`defaultValue`, `object`/`reference`, plus `labelField`, which
403
+ `ActionParamSchema` has no counterpart for). objectui already owns a converter
404
+ for the promoted direction; converging the authored direction is a cross-repo
405
+ change with its own migration. Typing them as they are is what makes the
406
+ divergence visible rather than undocumented — the prerequisite for closing it.
407
+
408
+ `label` and the param/option labels are `z.string()`, not `I18nLabelSchema`:
409
+ an authored def reaches the grid verbatim (nothing resolves an `{ en, zh }` map
410
+ on this path) and the bar renders `def.label` as a React child, so blessing the
411
+ map form would trade a parse error for a blank screen. Localize by declaring a
412
+ real action and naming it in `bulkActions` — that path runs through the i18n
413
+ resolver.
414
+
415
+ **Lint**: `validate-action-name-refs` now covers `bulkActionDefs`. Only an
416
+ `execution: 'aggregate'` entry is a name reference (it is what
417
+ `resolveBulkActions` looks up); an `update`/`delete` def's `name` is a button id
418
+ and resolving it would be nonsense. The walk also reaches an **object's own
419
+ `listViews`** for the first time — an object has no top-level `list`, so that
420
+ tier had simply never been visited while the view-level ones were covered. And
421
+ the hint no longer tells a bulk-surface author to add a `locations` entry: the
422
+ selection bar is the one surface that does not filter on it, so naming the
423
+ action there is the whole placement.
424
+
425
+ Verified zero new findings against `app-showcase` / `app-crm` / `app-todo`.
426
+
427
+ ### Patch Changes
428
+
429
+ - f3141d8: fix(spec): a node that publishes no descriptor configSchema can now own an expression-ledger entry (#4439)
430
+
431
+ `FLOW_NODE_EXPRESSION_PATHS` is the #4027 ledger that tells `registerFlow` and
432
+ `objectstack validate` which config keys hold expressions, and in which dialect.
433
+ Its ratchet (`config-expression-ledger.test.ts`) derives what it expects from
434
+ descriptor `configSchema` `xExpression` markers, and fails in **both**
435
+ directions — an undeclared marker, or a ledger entry nothing declares.
436
+
437
+ `decision` / `script` / `subflow` publish **no** descriptor `configSchema` on
438
+ purpose: a published partial schema would drop the editors their hand-written
439
+ Studio forms need (the #4210 incident), so their contract lives in
440
+ `schemaless-node-config.zod.ts`. Those two rules compose into a hole — an
441
+ expression slot on a schemaless node is structurally unreachable by the ratchet,
442
+ and because the reverse direction rejects unclaimed entries, it cannot be
443
+ entered by hand either.
444
+
445
+ `decision.conditions[].expression` sat in that hole. Its own schema says
446
+ _"Bare CEL predicate deciding this branch"_ and its own comment names `{…}` as
447
+ the #1491 trap, and no validator walked it — so `{lead_record.status} ==
448
+ 'converted'` passed `tsc`, passed `objectstack validate`, passed registration.
449
+ #4414 made that fail loudly at run time; this makes it fail at build time,
450
+ which is the delay #4027 exists to remove.
451
+
452
+ ## The fix
453
+
454
+ The ratchet now reads **both** declaration channels:
455
+
456
+ - **descriptor `configSchema`** — unchanged, enumerated from the live registry;
457
+ - **`schemaless-node-config.zod.ts`** — the marker rides
458
+ `.meta({ xExpression })` through `z.toJSONSchema`, the same channel
459
+ `loop.collection` has used since objectui#2670.
460
+
461
+ Spec hands the second channel over as JSON Schema
462
+ (`getSchemalessNodeConfigJsonSchemas()`, memoized, `input` mode — the shape a
463
+ descriptor's `configSchema` already is), so the ratchet walks both with the
464
+ _same_ function. No second notion of "a declared expression property", which is
465
+ the duplication a ledger exists to remove, and no `zod` dependency added to
466
+ `service-automation`. Each channel is separately asserted non-empty, so a broken
467
+ derivation on one side cannot hide behind the other's results.
468
+
469
+ `SCHEMALESS_NODE_CONFIG_SCHEMAS` is also exported for anything else that needs
470
+ to reason about all node config contracts. Additive — objectui's
471
+ `flow-node-config` reconciliation imports each schema by name and is unaffected.
472
+
473
+ ## The sweep
474
+
475
+ The other schemaless slots were checked and deliberately carry no marker:
476
+ `script.template` is a template **id**, not a body; `script.inputs` /
477
+ `script.variables` / `subflow.input` are values that interpolate `{token}` —
478
+ text-with-holes, the shape essentially every node config string has, already
479
+ covered generically by `validate-flow-template-paths` and the CLI flow linter.
480
+ A `flow-template` ledger entry means something narrower: a _reference that must
481
+ resolve to a value_, like `loop.collection`. So `decision.conditions[]
482
+ .expression` is the only genuinely declared expression slot on the class — now
483
+ recorded in the ledger's header so it is not re-derived.
484
+
485
+ ## Docs corrected
486
+
487
+ The flows guide taught the **wrong dialect** for decision predicates in three
488
+ places (`'{order_amount} > 10000'`), plus a "braces missing in a decision
489
+ expression" warning that inverted after #4414 — and `FlowNodeSchema`'s own
490
+ `@example` did the same. All corrected to bare CEL, with the history stated so
491
+ an author with a braced predicate knows what changed and why their build now
492
+ fails. The dialect table drops from three dialects to two: predicates never take
493
+ braces, values always do.
494
+
495
+ Verified: 13 new/updated tests across the ratchet, the engine's registration
496
+ pass and `@objectstack/lint` (including the exact app-crm predicate rejected at
497
+ both `registerFlow` and `objectstack validate`); `pnpm build`, `pnpm typecheck`
498
+ (122 tasks), `pnpm lint` and `check:docs` clean.
499
+
500
+ - fd3013a: feat(spec,automation)!: converge `script` to a function call — retire the `actionType` branches — and parse `script` / `subflow` config at execute time (#4343)
501
+
502
+ A `script` node had four ways to name what it ran and only one of them ran anything.
503
+ Protocol 17 keeps that one and retires the rest.
504
+
505
+ - **`config.actionType: 'email' | 'slack'`** were **logger-backed stubs**. They wrote a
506
+ line, reported success, and delivered nothing — under any configuration, installed
507
+ messaging service or not. Every bundled example used one; none of them ever sent
508
+ anything.
509
+ - **`config.template` / `.recipients` / `.variables`** fed those stubs, so they addressed
510
+ a message no channel sent. (The examples did not even reach them: they passed the
511
+ payload in `inputs`, which the built-in branch never read.)
512
+ - **inline `config.script`** was recognized and **never executed** — the built-in runtime
513
+ has no server-side JS sandbox, so the node warned and completed as a no-op.
514
+ - **any other `actionType`** was shorthand for a registered-function name — a second
515
+ spelling of `config.function` — and `'invoke_function'` was a marker that named nothing
516
+ on its own.
517
+
518
+ What remains is what worked: `config.function` (now **required**) names a registered
519
+ function, `config.inputs` feeds it, `config.outputVariable` binds its return value.
520
+
521
+ **The replacements are three different mechanisms, not one rename.**
522
+
523
+ | Retired | Use instead |
524
+ | ----------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
525
+ | `actionType: 'email'` (+ `template` / `recipients` / `variables`) | a `notify` node — it delivers through the messaging service: the in-app inbox by default, real email once `@objectstack/plugin-email` is installed |
526
+ | `actionType: 'slack'` | a `connector_action` node with the Slack connector, or an `http` node posting to an incoming webhook — `notify` has no Slack channel |
527
+ | `actionType: 'my_fn'` (shorthand) | `function: 'my_fn'` — the conversion moves it for you |
528
+ | `script: '…'` (inline JS) | move the logic into a registered function and call it via `config.function` |
529
+
530
+ **Execute-time parse.** `script` and `subflow` now run their config through the contract
531
+ before executing, the seam #4277 gave the flat builtins — a violation refuses the node as
532
+ a **guard** (wrong metadata; no `fault` edge may route it, #3863). `script` could not join
533
+ that seam while its legal key set depended on `actionType`: a flat parse would either
534
+ reject valid shapes or wave everything through. Converging the node is what made the
535
+ contract fit. `subflow`'s hand-written `flowName` check became the same parse, so its
536
+ message is now `subflow 'n1': config does not satisfy the subflow contract —
537
+ config.flowName: …`. `decision` deliberately stays export-only: its one key is optional,
538
+ so a parse would check nothing.
539
+
540
+ **Migration.** `os migrate meta --from 16` rewrites stored sources; authoring one of these
541
+ keys in TypeScript is a compile error carrying the same prescription. A shorthand
542
+ `actionType` **converts into `function`** — that is what it named — unless `function` is
543
+ already set, in which case it was dead metadata the executor never reached. The other four
544
+ keys are dropped outright: nothing read them, so there is no value to preserve, and
545
+ rebuilding the intent is an authoring decision (the table above) rather than something a
546
+ mechanical rewrite can guess.
547
+
548
+ The keys leave the **load path** (`retiredFromLoadPath`) with the rest of the keys retired
549
+ for _misdescribing themselves_ rather than for being renamed: absorbing
550
+ `actionType: 'email'` silently would let an author keep believing the flow sends mail. The
551
+ one seam that still replays it is `registerFlow`, which rehydrates data at rest (#3903) —
552
+ a row in `sys_metadata` has no author for a tombstone to teach. So a stored email-stub node
553
+ arrives stripped of the keys nothing read and then **refuses for naming no callable**,
554
+ where it used to log a line and report success. That flip is the behavior change to expect.
555
+
556
+ **A build gap this surfaced, fixed here.** `FlowFunctionEntrySchema` now also accepts a
557
+ **lowered handler ref** (a non-empty string), the form `objectstack build` produces: the
558
+ CLI lowers every inline callable to a serialisable ref _before_ the stack is parsed (it
559
+ must — `z.function()` wraps callables and would break the ref mapping), so a built
560
+ manifest holds `{ myFn: 'myFn' }`, which neither previous member accepted. The result was
561
+ that `defineStack({ functions })` — a documented, first-class mechanism — could not
562
+ survive a build at all. Nothing had noticed because no bundled example used it; #4343
563
+ turns that from latent into blocking, since `config.function` becomes the only thing a
564
+ `script` node can run. `Hook.handler` already declared exactly this pair (`z.union([
565
+ z.string(), <function> ])`, "string, post-build / inline function, pre-build"), so this
566
+ brings `functions` onto the platform's established shape rather than inventing one. A
567
+ string carries no callable and `normalizeFlowFunctionEntry` still drops it by design — the
568
+ real functions ride in the sibling ESM module the build emits, merged by name — so
569
+ hand-authoring one registers nothing and fails loudly at execute ("no function named '…'
570
+ is registered"), never silently.
571
+
572
+ Also in this change: the retired constants `SCRIPT_BUILTIN_ACTION_TYPES`,
573
+ `SCRIPT_INVOKE_FUNCTION_ACTION_TYPE` and the `ScriptBuiltinActionType` type are removed
574
+ (they described the dispatch set that no longer exists); `os validate` names a retired key
575
+ and its replacement instead of reporting a generic missing callable; and the `#3796`
576
+ alias fixture, which carried `actionType: 'invoke_function'` through both sides, no longer
577
+ describes an end state protocol 17 can reach — the rename itself is untouched. No liveness
578
+ ledger row moves: the gate walks `FlowSchema`, whose `nodes[].config` is
579
+ `z.record(z.unknown())`, so these keys were never governed by one.
580
+
581
+ - Updated dependencies [430dcc2]
582
+ - Updated dependencies [e6ac4bd]
583
+ - Updated dependencies [80334c7]
584
+ - Updated dependencies [ce5242c]
585
+ - Updated dependencies [a7163ea]
586
+ - Updated dependencies [e6e9379]
587
+ - Updated dependencies [98877c9]
588
+ - Updated dependencies [98877c9]
589
+ - Updated dependencies [e6b1b69]
590
+ - Updated dependencies [ad047d2]
591
+ - Updated dependencies [2826d1e]
592
+ - Updated dependencies [5a84d41]
593
+ - Updated dependencies [20b1a9e]
594
+ - Updated dependencies [203a449]
595
+ - Updated dependencies [ac37fc6]
596
+ - Updated dependencies [4820f55]
597
+ - Updated dependencies [462d9c4]
598
+ - Updated dependencies [7d21581]
599
+ - Updated dependencies [f2445c9]
600
+ - Updated dependencies [23338c3]
601
+ - Updated dependencies [5b843fb]
602
+ - Updated dependencies [b4487aa]
603
+ - Updated dependencies [65ca83a]
604
+ - Updated dependencies [67bf2e2]
605
+ - Updated dependencies [c6d1cb4]
606
+ - Updated dependencies [36030ff]
607
+ - Updated dependencies [6117f7b]
608
+ - Updated dependencies [e533b0b]
609
+ - Updated dependencies [cdf4d9a]
610
+ - Updated dependencies [aee1806]
611
+ - Updated dependencies [c13350b]
612
+ - Updated dependencies [c13350b]
613
+ - Updated dependencies [9ca2d85]
614
+ - Updated dependencies [c13350b]
615
+ - Updated dependencies [891d345]
616
+ - Updated dependencies [a52e2ef]
617
+ - Updated dependencies [5293114]
618
+ - Updated dependencies [20bc357]
619
+ - Updated dependencies [5966c2a]
620
+ - Updated dependencies [2382580]
621
+ - Updated dependencies [d9fa683]
622
+ - Updated dependencies [3c7bcc0]
623
+ - Updated dependencies [4b6cac7]
624
+ - Updated dependencies [7631964]
625
+ - Updated dependencies [ac471a0]
626
+ - Updated dependencies [60ae58e]
627
+ - Updated dependencies [ce92674]
628
+ - Updated dependencies [9f601e8]
629
+ - Updated dependencies [51c5227]
630
+ - Updated dependencies [a4a85c8]
631
+ - Updated dependencies [07a4e26]
632
+ - Updated dependencies [ec975f1]
633
+ - Updated dependencies [eb4204b]
634
+ - Updated dependencies [4f13be2]
635
+ - Updated dependencies [61cc079]
636
+ - Updated dependencies [0e96e46]
637
+ - Updated dependencies [d52d4fe]
638
+ - Updated dependencies [742cebb]
639
+ - Updated dependencies [ce92674]
640
+ - Updated dependencies [cf2c9b7]
641
+ - Updated dependencies [0f9faa2]
642
+ - Updated dependencies [7cf42fe]
643
+ - Updated dependencies [5966c2a]
644
+ - Updated dependencies [f78dd83]
645
+ - Updated dependencies [a2cd18a]
646
+ - Updated dependencies [4638aaa]
647
+ - Updated dependencies [0222d3c]
648
+ - Updated dependencies [0a936ea]
649
+ - Updated dependencies [023c00b]
650
+ - Updated dependencies [155507e]
651
+ - Updated dependencies [7bba90b]
652
+ - Updated dependencies [7e05d8e]
653
+ - Updated dependencies [061406d]
654
+ - Updated dependencies [c1f344b]
655
+ - Updated dependencies [9c93465]
656
+ - Updated dependencies [ebb209c]
657
+ - Updated dependencies [63b33e6]
658
+ - Updated dependencies [2a44c1d]
659
+ - Updated dependencies [695cfbd]
660
+ - Updated dependencies [7445149]
661
+ - Updated dependencies [071d0dc]
662
+ - Updated dependencies [0848bea]
663
+ - Updated dependencies [d51bed2]
664
+ - Updated dependencies [b8b3c64]
665
+ - Updated dependencies [0c0fbd9]
666
+ - Updated dependencies [f3141d8]
667
+ - Updated dependencies [5a84d41]
668
+ - Updated dependencies [fd3013a]
669
+ - Updated dependencies [21676eb]
670
+ - Updated dependencies [e336549]
671
+ - Updated dependencies [d40f43a]
672
+ - Updated dependencies [e5e7ee0]
673
+ - Updated dependencies [a2ebea2]
674
+ - Updated dependencies [800bdb0]
675
+ - Updated dependencies [04f1182]
676
+ - Updated dependencies [5647006]
677
+ - Updated dependencies [38f7e4f]
678
+ - Updated dependencies [c57f3cf]
679
+ - Updated dependencies [97faca3]
680
+ - Updated dependencies [ad5fe25]
681
+ - Updated dependencies [ea90179]
682
+ - Updated dependencies [ce92674]
683
+ - Updated dependencies [5ef0b5b]
684
+ - Updated dependencies [48fbacb]
685
+ - Updated dependencies [355e951]
686
+ - Updated dependencies [dadb43f]
687
+ - @objectstack/spec@17.0.0-rc.2
688
+ - @objectstack/formula@17.0.0-rc.2
689
+ - @objectstack/sdui-parser@17.0.0-rc.2
690
+
691
+ ## 17.0.0-rc.1
692
+
693
+ ### Minor Changes
694
+
695
+ - 6a67d7a: feat(lint): L2 action-body writes to undeclared fields warn at author time (#4271)
696
+
697
+ The write-set lint that #4305 gave L2 hook bodies now covers the other surface
698
+ that carries one. An action body is the same artefact: the same
699
+ `HookBodySchema` union, parsed by the same `HookBodySchema.safeParse` in
700
+ `actionBodyRunnerFactory`, run in the same QuickJS sandbox. So it fails the
701
+ same way — `ctx.api.object('crm_deal').update({ stag: 'won' })` inside an
702
+ action reaches the driver unfiltered, and the outcome splits by driver: on SQL
703
+ the stray column fails the whole call with a driver-level error far from the
704
+ authoring site, and on a schemaless driver the stray key is persisted. Half
705
+ the surface was still blind.
706
+
707
+ **New rule — `action-body-write-unknown-field` (advisory).** Wired into
708
+ `REFERENCE_INTEGRITY_RULES`, so `os validate`, `os lint` and `os compile` all
709
+ report it; it never blocks a build. Both places the runtime reads actions from
710
+ are walked — top-level `actions` and `objects[].actions` — and a
711
+ `defineStack`-merged action, which lives in both, is reported once at its
712
+ authored path. That dedupe is by VALUE (bound object + name + body source), not
713
+ by object identity the way `collectBundleActions` can afford: the suite runs on
714
+ the schema-PARSED stack, and parsing rebuilds every node, so the two copies
715
+ arrive as distinct objects that are merely equal. An identity check passes a
716
+ shared-reference unit fixture and then reports the showcase app's one warning
717
+ twice — which is exactly what it did before the end-to-end run caught it.
718
+
719
+ **Only the `ctx.api` write family carries over, and that is the point.** An
720
+ action's `ctx.input` is its PARAMS bag (`input: unwrapProxyToPlain(actionCtx
721
+ ?.params)`), not a record, so resolving those names against object fields would
722
+ flag every correctly-named parameter — a pure false-positive machine, and a
723
+ false positive kills an advisory lint. `ctx.record` is not a write surface
724
+ either: the runner hands the body a plain snapshot and never writes it back, so
725
+ `ctx.record.x = …` is discarded for _declared_ and undeclared fields alike —
726
+ a different defect from "the unknown column vanishes", and flagging only its
727
+ undeclared half would imply the declared half persists.
728
+
729
+ So the rule ships a declared **partition** of the shared
730
+ `HOOK_BODY_WRITE_PATTERNS` rather than a second ledger:
731
+ `ACTION_BODY_WRITE_PATTERN_IDS` (today: `api-crud-literal`) and
732
+ `ACTION_BODY_WRITE_EXCLUSIONS` (`input-property-assign`,
733
+ `input-object-assign`), each exclusion carrying its reason. The two halves are
734
+ tested to cover the shared ledger exactly, so a fourth pattern landing on the
735
+ hook side fails this rule's test until someone classifies it — silence is not a
736
+ decision. Every applicable pattern is additionally proved end-to-end through
737
+ the full validator (prefilter, pattern filter and field check included), and
738
+ every exclusion is proved to be about applicability rather than an
739
+ unextractable shape: the shared extractor still sees it, and this rule still
740
+ reports nothing for it.
741
+
742
+ One extractor, one field index, one implicit-field set, shared with the hook
743
+ rule rather than copied. The action rule is the same check on the other body
744
+ surface, so a second copy of `IMPLICIT_FIELDS` would drift exactly the way the
745
+ five hand-copied system-field lists #4330 collapsed did.
746
+
747
+ The lint stays off the kernel boot path, and lands one notch tighter than the
748
+ hook side: the only applicable pattern is rooted at `ctx.api`, so an action
749
+ body that never mentions it does not even parse, let alone load the ~9 MB
750
+ TypeScript compiler. Guarded by `lazy-deps.test.ts`.
751
+
752
+ `@objectstack/spec`: `ScriptBodySchema` and `ActionSchema.body` now point at
753
+ the action-side rule and spell out that `ctx.input` (params) and `ctx.record`
754
+ (a discarded snapshot) are not record-write surfaces — doc comments only, no
755
+ schema or generated-artifact change.
756
+
757
+ - 0ecc656: feat(lint): an action body's discarded `ctx.record` write warns at author time (#4345)
758
+
759
+ `#4344` deliberately left `ctx.record` alone, and said why: an action's
760
+ `ctx.record` is a plain snapshot (`unwrapProxyToPlain(actionCtx?.record)`) that
761
+ `boundActionHandler` never writes back — the hook path's
762
+ `applyMutationsToInput` has no action-side counterpart — so `ctx.record.x = …`
763
+ is discarded for **declared and undeclared fields alike**. Reporting that
764
+ through the unknown-field rule would have been actively wrong: flagging only
765
+ the undeclared half implies the declared half persists, which is the false
766
+ completion this rule family exists to stop manufacturing. It needed its own
767
+ finding, and now has one.
768
+
769
+ **New rule — `action-record-write-discarded` (advisory).**
770
+
771
+ **It is not "flag every `ctx.record.<field>` assignment"** — that would be a
772
+ false-positive machine, because mutating the snapshot to build a payload is a
773
+ legitimate idiom:
774
+
775
+ ```js
776
+ ctx.record.stage = "won";
777
+ await ctx.api.object("crm_deal").update(ctx.record); // the write is LIVE
778
+ ```
779
+
780
+ So the finding requires the write to be **provably dead**: reported only when
781
+ `ctx.record` never escapes the body as a value. Property reads
782
+ (`ctx.record.id`) do not rescue a write and do not suppress the finding;
783
+ handing the object to anything — an argument, an assignment RHS, a spread, a
784
+ return — does. Aliasing (`const r = ctx.record`) reads as an escape, which is
785
+ the safe direction: it costs a missed finding, never a false one.
786
+
787
+ Truthiness and type tests are **not** escapes, and that distinction is what
788
+ makes the rule fire on real code rather than almost never. Running it against
789
+ the showcase app is what surfaced it: `mark_done` opens with
790
+ `ctx.recordId || (ctx.record && ctx.record.id)`, the defensive idiom action
791
+ bodies are actually written with, and counting that guard as an escape silenced
792
+ the finding on the one body in the repo that had a record write. A test reads
793
+ the reference and yields a boolean — or, for `&&`/`||`/`??`, yields the left
794
+ operand only when it is falsy, which is null or undefined and persists nothing.
795
+ Only the LEFT operand is a test: `x || ctx.record` really does evaluate to the
796
+ object, and still escapes.
797
+
798
+ **One suite member, two rule ids.** Both findings fall out of one parse of one
799
+ source on one surface, so `validateActionBodyWrites` reports both rather than
800
+ `REFERENCE_INTEGRITY_RULES` growing a second member that would parse every
801
+ action body again to say two things about the same walk. The alternative —
802
+ hand-wiring it into the three CLI commands — is the drift that suite exists to
803
+ end, and `validateReadonlyFlowWrites` is the standing proof: wired into
804
+ `validate` and `compile`, never into `lint`. The trade-off is written down at
805
+ both ends rather than left to be rediscovered.
806
+
807
+ **The ledger ratchet fired, as designed.** `record-property-assign` joins the
808
+ shared `HOOK_BODY_WRITE_PATTERNS` — the extractor's shape inventory, not any
809
+ one rule's — and both existing consumers had to classify it before it could
810
+ land. That was not cosmetic on the hook side: a `record-property-assign` write
811
+ carries no `object`, and `validateHookBodyWrites` branched on exactly that to
812
+ mean "a `ctx.input` write", so the new shape would have been reported as _"the
813
+ hook writes 'stage' to its input"_. The hook rule now declares its own
814
+ consumed subset (`HOOK_BODY_WRITE_PATTERN_IDS`) and its exclusion with a
815
+ reason — a hook sandbox context has no `ctx.record` at all
816
+ (`buildSandboxContext` never sets it), so the expression throws at run time
817
+ rather than silently no-op'ing, and a loud failure is not an advisory rule's
818
+ business.
819
+
820
+ `extractHookBodyWriteSet` is the new one-parse entry point, returning the
821
+ writes plus the `ctxRecordEscapes` signal; `extractHookBodyWrites` stays as a
822
+ thin projection of it.
823
+
824
+ **Boot path.** The action gate's prefilter widens from `api` to `api`-or-
825
+ `record`, so a body reaching neither still never loads the ~9 MB TypeScript
826
+ compiler. `lazy-deps.test.ts` pins it — and its header and two case names,
827
+ which still claimed every lazy dep waited on "a react page", now say which
828
+ trigger each one pins (typescript has also been loaded by the hook-body gate
829
+ since #4271).
830
+
831
+ `@objectstack/spec` / `@objectstack/runtime`: `ScriptBodySchema`,
832
+ `ActionSchema.body` and `ScriptContext.record` now state that
833
+ `ctx.api.object(...)` is the only path that persists anything, and that
834
+ `ctx.record` is read-only in effect. Doc comments only — no schema or
835
+ generated-artifact change. Whether the runtime should instead refuse or honour
836
+ a record write stays open on #4345.
837
+
838
+ - e4c61a7: Validate the expression slots a flow node's `configSchema` declares (#4027).
839
+
840
+ A node type's designer `configSchema` and the keys its validators traverse were
841
+ two unreconciled lists. Both the engine's `registerFlow` pass and the author-time
842
+ `objectstack validate` pass hardcoded `config.condition` / `edge.condition` and
843
+ assumed every other node string was a `{var}` template — so a declared expression
844
+ property outside that hardcoded set was validated by nobody.
845
+
846
+ That is how #3528 shipped. `screen.fields[].visibleWhen` has been on the `screen`
847
+ descriptor since #3304, typed `xExpression: 'expression'` (bare CEL) and offered
848
+ to authors in Studio, but no validator traversed it. An app authored the
849
+ predicate in the _other_ dialect — `'{createOpportunity} == true'` — and it passed
850
+ `tsc`, `objectstack validate` and registration in silence. Because `required` _is_
851
+ enforced, a field the author had made conditional rendered unconditionally and
852
+ blocked Submit on an input the user was never shown: the run paused forever and no
853
+ resume was ever issued.
854
+
855
+ Now:
856
+
857
+ - **`FLOW_NODE_EXPRESSION_PATHS`** (`@objectstack/spec`) is the declared ledger of
858
+ expression-bearing node config paths, each recording the dialect it takes.
859
+ - **Both validators read it.** A malformed `visibleWhen` is a located, quoted
860
+ error at `registerFlow` _and_ at `objectstack validate` — `node 'screen_1'
861
+ (screen) screen field visibleWhen at config.fields[1].visibleWhen`.
862
+ - **A reconciliation ratchet** derives the expression properties from the live
863
+ descriptors and fails CI in both directions: a new `xExpression` property with
864
+ no ledger entry, or a stale entry no descriptor declares. It walks every
865
+ registered builtin, not just `screen`.
866
+
867
+ Dialects are recorded rather than assumed because there are three, and two of them
868
+ disagree about braces: bare CEL (`{…}` is the #1491 brace-trap), single-brace
869
+ `{var}` flow interpolation (`{…}` is correct), and the ADR-0032 §3 double-brace
870
+ text template. Only bare-CEL slots are checked — `loop.collection` and
871
+ `map.collection` are recorded as `flow-template` and deliberately left alone,
872
+ since no validator implements their dialect and checking them under either of the
873
+ other two would reject every currently-valid flow.
874
+
875
+ `ActionDescriptor.configSchema`'s TSDoc no longer claims `registerFlow()`
876
+ validates `config` against it. It never did: `FlowNodeSchema.config` is
877
+ `z.record(z.unknown())`, so types, `required`, `enum` and unknown keys are still
878
+ unenforced. The doc now states exactly what is checked and what is designer-facing
879
+ only, so nothing relies on a guard that does not exist.
880
+
881
+ - cc60165: feat(lint): a flow `update_record` node writing an undeclared field gates the build (#4271)
882
+
883
+ The write-set family #4305 (hooks) and #4344 (actions) opened had a third
884
+ surface, and it was the one the docs had spent the longest recommending as the
885
+ safe alternative to the other two. A flow `update_record` node whose
886
+ `config.fields` names a field the target object never declares was caught by
887
+ **nothing**: `validate-readonly-flow-writes.ts` walks that exact map and
888
+ explicitly stepped over the unknown key (`if (!meta) continue; // a
889
+ form/field-layout lint concern` — a referral to a rule that does not check
890
+ writes), and `validate-flow-template-paths.ts` checks the `{record.<path>}`
891
+ READ tokens interpolated into node config, never the write-side key. So the
892
+ surface `hook-bodies.mdx` pointed authors at — "prefer a flow `update_record`
893
+ node, whose structural `fields` config is checked" — was the least checked of
894
+ the three.
895
+
896
+ **New rule — `flow-node-write-unknown-field`, and it is an `error`.** Wired into
897
+ `REFERENCE_INTEGRITY_RULES`, so `os validate`, `os lint` and `os compile` report
898
+ it at once (one more place than the hand-wired readonly rule next door reaches).
899
+
900
+ **Why it gates where its two siblings advise.** The hook and action rules are
901
+ advisory because they PARSE JavaScript: the finding is only as good as the
902
+ extractor, and a false positive kills an advisory lint. Nothing here is parsed —
903
+ `config.fields` is a literal map next to a literal `objectName`, the same
904
+ certainty `flow-update-readonly-field` already gates on one config key over. A
905
+ rule that errors on a write the engine _strips_ while only warning on a write
906
+ that names no column at all would be incoherent in the same `fields` map.
907
+
908
+ And the runtime consequence is not the benign "consumer skips the unknown name
909
+ and renders the rest" that keeps `page-field-unknown` / `form-field-unknown`
910
+ advisory. Both halves were measured, not inferred:
911
+
912
+ - Through the engine, an undeclared key reaches `driver.update` verbatim — the
913
+ flow executor calls the data engine directly, the UPDATE path strips only
914
+ readonly/readonlyWhen, and the SQL driver's `formatInput` /
915
+ `applyWriteColumnMap` pass an unrecognized key straight through (`m[k] ?? k`).
916
+ - On SQLite/knex it becomes `update "deal" set "name" = 'n2', "stagee" = 'won' …
917
+ → no such column: stagee`. The statement is rejected **whole**: `name` —
918
+ spelled correctly, in the same payload — does not land either, and the step
919
+ fails with a driver error naming a column, far from the authoring mistake.
920
+ - On a schemaless datasource nothing rejects it, so the stray key is persisted
921
+ into a column the object never declares, where no schema-driven read returns
922
+ it.
923
+
924
+ That is the call `validate-searchable-fields` makes for a stale entry and
925
+ `validate-flow-template-paths` makes for a filter-position token: gate when the
926
+ miss breaks or corrupts the operation, advise when it merely narrows the output.
927
+
928
+ **One field index and one implicit-field set across all three surfaces.**
929
+ `indexObjectFields` and `IMPLICIT_FIELDS` are imported from the hook rule rather
930
+ than copied, so the three rules cannot drift on what is writable without being
931
+ authored — the shape #4330 collapsed one package over.
932
+
933
+ Every skip exists so the gate only ever fires on a certainty, and each is
934
+ silent: a templated `objectName`, a non-literal `fields` map, an object this
935
+ stack does not define, an object that declares no fields at all (external /
936
+ datasource-introspected schemas, the same skip `validate-searchable-fields`
937
+ takes), and dotted keys (a nested-path write, not a top-level column). `runAs`
938
+ is deliberately NOT consulted, unlike the readonly rule that skips
939
+ `runAs:'system'` — an elevated identity bypasses the readonly strip, but no run
940
+ identity conjures a column.
941
+
942
+ **Scope is declared as data, not left as silence.** `FLOW_WRITE_NODE_TYPES`
943
+ (today `update_record`) and `FLOW_WRITE_NODE_TYPES_DEFERRED` (`create_record`,
944
+ with its reason) are partition-tested against the CRUD node types that carry a
945
+ `fields` write map — derived behaviourally from the spec's executor-written
946
+ config schemas, not restated — so a node type that grows one later fails that
947
+ test until someone classifies it.
948
+
949
+ `@objectstack/spec`: `ScriptBodySchema`'s "prefer a flow `update_record` node,
950
+ whose structural `fields` config is error-checked" note now names the rule that
951
+ makes it true. Doc comment only — no schema or generated-artifact change.
952
+
953
+ Docs: #4355 had just rewritten `automation/hook-bodies.mdx` to record this gap
954
+ honestly — "**Prefer a flow `update_record` node when the write set is fixed —
955
+ but not for _this_ check** … writing a field the object never declares is
956
+ currently reported by nothing at all. On that one axis an L2 body is now the
957
+ better-checked surface." That bullet, and the matching note in
958
+ `automation/hooks.mdx`, are the two sentences this change makes false. Both now
959
+ say the axis has flipped back — and why the flow side lands a level _stronger_
960
+ than the body side rather than merely level with it.
961
+
962
+ - c1d44f7: feat(lint): L2 hook-body writes to undeclared fields warn at author time (#4271)
963
+
964
+ An L2 (`language:'js'`) hook body that writes a field the target object never
965
+ declares — `ctx.input.amout = 0`, `ctx.api.object('deal').update({ stag: … })`
966
+ — runs clean in the QuickJS sandbox and reaches the driver **unfiltered**:
967
+ `applyMutationsToInput` is a plain `Object.assign`, and the write-path
968
+ validator walks declared fields on insert and skips a key it has no field def
969
+ for on update. What happens next depends on the driver, and neither half is
970
+ acceptable:
971
+
972
+ - **SQL** — the stray column enters the statement and the **whole write fails**
973
+ with a driver-level error (`table deal has no column named stagee`). The
974
+ write is lost, and the error surfaces far from the mistake that caused it.
975
+ - **Schemaless** (memory, MongoDB) — the driver spreads the payload, so the
976
+ stray key **is** persisted: an undeclared column nothing downstream reads.
977
+
978
+ No diagnostic anywhere, and nothing at the authoring site either way — the
979
+ #4001 "the mistake is invisible where it is made" family. The read side
980
+ (`hook.condition`) and the capability surface were already statically checked;
981
+ the write side was the one blind face, and `hook-body.zod.ts` carried it as an
982
+ **accepted gap**.
983
+
984
+ **New rule — `hook-body-write-unknown-field` (advisory).** `@objectstack/lint`
985
+ now parses each L2 body (TypeScript parser; parsed, never executed, never
986
+ type-checked) and resolves its literal writes against the target object's
987
+ declared + system fields. An unknown field warns with a did-you-mean. Wired
988
+ into `REFERENCE_INTEGRITY_RULES`, so `os validate`, `os lint` and `os compile`
989
+ all report it; it never blocks a build.
990
+
991
+ The recognized write shapes are declared as data — `HOOK_BODY_WRITE_PATTERNS`,
992
+ each entry carrying a canonical example that a reconciliation test round-trips
993
+ through the real extractor, so a pattern cannot be declared-but-unverified
994
+ (#3528's death). v1 ships three:
995
+
996
+ - `ctx.input.<field> = …` / `ctx.input['<field>'] ⟨op⟩= …` → the hook's own
997
+ target object(s); flat-input envelope keys (`id`/`options`/`ast`/`data`) are
998
+ never treated as record fields.
999
+ - `Object.assign(ctx.input, { <field>: … })` → same target.
1000
+ - `ctx.api.object('<object>').insert|create|update({…})` / `.updateById(id, {…})`
1001
+ → the named object, at the **real** `ObjectRepository` payload positions
1002
+ (`update(data)` — the payload is argument 0, not `update(id, data)`).
1003
+
1004
+ Everything statically unknowable is skipped silently, favouring missed findings
1005
+ over false ones: computed keys, spreads, non-literal payloads, dynamic object
1006
+ names, wildcard-target (`object:'*'`) input writes, cross-package targets,
1007
+ aliased input (`const doc = ctx.input`), and multi-target hooks where the field
1008
+ exists on _some_ target (the body may branch per object — only an
1009
+ everywhere-miss warns).
1010
+
1011
+ The lint stays off the kernel boot path: the TypeScript compiler loads lazily,
1012
+ only when a hook actually carries a JS body (same contract as the react-page
1013
+ gates, guarded by `lazy-deps.test.ts`).
1014
+
1015
+ `@objectstack/spec`: the `ScriptBodySchema` header's "write-set opacity —
1016
+ accepted static-analysis gap" note now points at the lint instead, and spells
1017
+ out what remains opaque so the warning's absence is not read as proof of
1018
+ correctness.
1019
+
1020
+ - 3eb1b2b: feat(lint): every field-bearing prop on a React page block resolves against the
1021
+ object it names
1022
+
1023
+ #4329 closed ONE of them — `<ListView searchableFields>` — by running the
1024
+ metadata rule's core from the gate that owns React block props. That prop was an
1025
+ instance, not the class: every other prop a `kind:'react'` page binds BY FIELD
1026
+ NAME shipped exactly as typed, the same silent drift `page-field-unknown`
1027
+ already closes for the page-component `properties` bag one surface over.
1028
+
1029
+ `validate-react-page-props` now resolves all of them:
1030
+
1031
+ - `<ListView>` `fields` / `columns` / `sort` / `grouping` / `userFilters` /
1032
+ `hiddenFields` / `fieldOrder` / `filterableFields`
1033
+ - `<ObjectForm>` `fields`, `initialValues` KEYS, `sections[].fields[]`
1034
+ - `<RecordHighlights>` / `<RecordDetails>` / `<RecordPath>` /
1035
+ `<RecordRelatedList>` — via the SAME `COMPONENT_FIELD_SPECS` table the
1036
+ metadata surface uses, keyed by the block's `schemaType`, so the two surfaces
1037
+ agree by construction rather than by two lists that happen to match
1038
+ - `<Block type="…">` — the escape hatch reaches the same table by the type the
1039
+ author writes, so it is checked instead of being a hole
1040
+
1041
+ Findings carry the metadata rule's id (`page-field-unknown`) at its advisory
1042
+ severity, because the consumer behaves the same way: an unknown name is skipped
1043
+ and the rest renders.
1044
+
1045
+ **A FILTER position gates instead.** `<ListView filters>` / `<ObjectChart
1046
+ filter>` name fields in a QUERY, and an unknown column there is not a skipped
1047
+ column: the predicate can never match, `SqlDriver` swallows the driver's
1048
+ "no such column" and returns `[]`, and the surface renders an empty list that
1049
+ looks exactly like "there is no data" — the silent zero `filter-token-unknown`
1050
+ and `validate-flow-template-paths`' filter-position call both gate on. Those
1051
+ are reported as `error`.
1052
+
1053
+ Filter positions are also resolved INDEPENDENTLY of each other, unlike every
1054
+ other value this gate reads. `filters={['status', '=', stage]}` — a static field
1055
+ beside a React-state value — is the shape a react page actually writes, and the
1056
+ all-or-nothing static reader skipped the whole array, including the one position
1057
+ that was knowable.
1058
+
1059
+ Everything else is unchanged: a value from a variable, a call, or behind a
1060
+ spread is unresolvable rather than wrong and is skipped silently (ADR-0072 D1),
1061
+ as are cross-package objects, objects with no authored field map, dotted
1062
+ relationship paths, and registry-injected system columns.
1063
+
1064
+ ### Breaking: `<RecordRelatedList objectName>` is the RELATED object, as the spec always said
1065
+
1066
+ `RecordRelatedListProps.objectName` is the related (child) object — that is what
1067
+ `record:related_list` means on every metadata surface, what
1068
+ `validate-page-field-bindings` resolves its `columns` against, and what the one
1069
+ registry component behind both surfaces consumes. The React overlay declared
1070
+ `objectName` a SECOND time and glossed it "The parent object", and the generated
1071
+ contract publishes the overlay's description in place of the schema's — so the
1072
+ react surface both contradicted the spec and lost any way to name the object it
1073
+ renders.
1074
+
1075
+ FROM → TO for a page authored against the old gloss:
1076
+
1077
+ ```diff
1078
+ - <RecordRelatedList objectName="account" recordId={id} relationshipField="account_id" columns={['name','total']} />
1079
+ + <RecordRelatedList objectName="invoice" recordId={id} relationshipField="account_id" columns={['name','total']} />
1080
+ ```
1081
+
1082
+ `objectName` names the CHILD object being listed; the parent record stays bound
1083
+ by `recordId`, and `relationshipField` is the child's field pointing back at it.
1084
+ The lint above reports the old spelling (the child's columns and its FK do not
1085
+ resolve against the parent). `objectName` is now also published as required, as
1086
+ the schema declares it.
1087
+
1088
+ The class is closed as well as the instance: `REACT_OVERLAY_SHADOWS` in
1089
+ `@objectstack/spec/ui` ledgers every overlay prop that restates a spec-schema
1090
+ prop, and a test asserts the ledger equals the real collision set — so the next
1091
+ overlay entry that silently redefines a schema prop fails a test instead of
1092
+ shipping a second dialect.
1093
+
1094
+ - 9555b07: feat(lint): `<ListView searchableFields>` on a react page is checked against
1095
+ the bound object's fields (#4329)
1096
+
1097
+ #4328's `searchable-field-unknown` gates a stale `searchableFields` entry on
1098
+ the metadata surfaces — an object's own ADR-0061 declaration, its built-in
1099
+ named list views, and a `defineView` aggregate's default `list` / named
1100
+ `listViews`. It did not cover the react page surface: `ListView` declares
1101
+ `searchableFields` as a dataProp, so a `kind:'react'` page could write
1102
+ `<ListView searchableFields={['renamed_field']}>` and nothing resolved the
1103
+ name. The failure is the one #4328 documents — the engine's
1104
+ `resolveSearchFields` silently filters the stale name out, so the search scans
1105
+ a narrower set than the page asked for, or (once every entry is stale) falls
1106
+ through to the auto-default and scans a wider one; and once the REST read path
1107
+ validates the `$searchFields` override (#4254), the prop objectui echoes
1108
+ verbatim becomes a `400 INVALID_FIELD` on that list.
1109
+
1110
+ The check lives in `validate-react-page-props` — the gate that already parses
1111
+ the page's real JSX — and runs on `<ListView>` usages whose `objectName` and
1112
+ `searchableFields` are static literals, under the same rule id and severity
1113
+ (`searchable-field-unknown`, `error`) as the metadata surfaces. It is not a
1114
+ re-implementation: `validate-searchable-fields` now exports its core
1115
+ (`indexObjectSearchTargets` + `checkSearchableFieldList`), and the react gate
1116
+ runs that, so the two surfaces agree on what counts as a field by construction
1117
+ — same three skips (an object this stack does not define, an object with no
1118
+ authored field map, registry-injected system columns derived from the spec's
1119
+ own declarations), same dotted-path strictness (search matches the field map
1120
+ by exact string, so `owner_id.name` is flagged, not exempted).
1121
+
1122
+ JSX-specific seams follow the gate's existing rules: a value that comes from a
1123
+ variable, a call, or a spread is not knowable at build time and is skipped
1124
+ silently — an unresolvable binding is not a wrong one (ADR-0072 D1).
1125
+
1126
+ - 7967133: feat(lint): a `searchableFields` entry naming no field is caught at authoring
1127
+ time, not at request time
1128
+
1129
+ `searchableFields` is `z.array(z.string())` in both `object.zod.ts` and the
1130
+ list-view schema, so nothing ever checked that an entry resolves to anything.
1131
+ Rename a field and the old name stays behind — Zod-valid, shipped, pointing at
1132
+ a column that no longer exists.
1133
+
1134
+ The engine tolerates it, which is exactly what kept the drift invisible:
1135
+ `resolveSearchFields` filters the declaration down to fields that exist
1136
+ (`searchableFields?.filter((f) => all[f])`) and says nothing. The tolerance
1137
+ fails in the direction nobody expects:
1138
+
1139
+ - **some entries stale** → `$search` scans a NARROWER set than the object
1140
+ declares. Records that should match do not, and the response is
1141
+ indistinguishable from "no such record";
1142
+ - **every entry stale** → the filtered set is empty, so resolution falls
1143
+ through to the AUTO-DEFAULT (name/title + short-text fields). A declaration
1144
+ whose whole purpose is to CHOOSE the searchable set ends up selecting one the
1145
+ author never wrote — the "asked narrower, answered wider" inversion #4226
1146
+ closed on the projection axis.
1147
+
1148
+ It also stops being quiet downstream. Clients echo the declaration verbatim as
1149
+ the `$searchFields` override (objectui's list search sends
1150
+ `schema.searchableFields`), so once the REST read path validates that override
1151
+ against the object (#4254), a stale entry the engine had been silently skipping
1152
+ becomes a `400 INVALID_FIELD` on every list search for that object — a
1153
+ request-time break whose cause is an authoring typo made long before.
1154
+
1155
+ **New rule — `searchable-field-unknown` (gating).** Wired into
1156
+ `REFERENCE_INTEGRITY_RULES`, so it runs on `os validate`, `os lint` and
1157
+ `os compile` with no CLI edit. It covers the object's own ADR-0061 declaration
1158
+ and the list views that narrow it (`objects[].listViews`, a `defineView`
1159
+ default `list`, and named `listViews`), resolving each entry against the bound
1160
+ object's declared fields.
1161
+
1162
+ `error`, not the advisory level the other field-existence rules use
1163
+ (`page-field-unknown`, `form-field-unknown`, `semantic-role-field-unknown` are
1164
+ all warnings). Those describe a consumer that SKIPS an unknown name and renders
1165
+ the rest; this describes a declaration that either selects the wrong set or
1166
+ refuses the request outright — the same call `validate-flow-template-paths`
1167
+ makes for a filter-position token, where the miss widens the query instead of
1168
+ shrinking the page.
1169
+
1170
+ Existence only: a field that exists but is an odd search target (a `json`
1171
+ column) is NOT flagged — an explicit `searchableFields` is authoritative, so
1172
+ declaring one is a choice, not drift. Three skips keep false positives near zero
1173
+ (ADR-0072 D1): an object this stack does not define, an object with no authored
1174
+ field map (external / datasource-introspected), and registry-injected system
1175
+ columns — the last derived from the spec's own `FIELD_GROUP_SYSTEM_FIELDS` and
1176
+ `SystemFieldName` rather than hand-copied, since this package already carries
1177
+ five slightly-different copies of that list.
1178
+
1179
+ Dotted paths are the one place this rule is stricter than its siblings. They
1180
+ skip `owner_id.name` because the query engine resolves the traversal; search
1181
+ does not — `resolveSearchFields` matches the field map by exact string, so a
1182
+ dotted entry is dropped exactly like a typo, and it is the spelling most likely
1183
+ borrowed from `select`/`sort`. It is flagged, with its own fix hint.
1184
+
1185
+ ### Patch Changes
1186
+
1187
+ - 78caf51: fix(lint): the write-set diagnostics describe what the runtime actually does (#4271)
1188
+
1189
+ `hook-body-write-unknown-field` and `action-body-write-unknown-field` told
1190
+ authors the undeclared column "silently never lands in the stored record".
1191
+ Measured on `main`, that is wrong in **both** directions. Nothing between the
1192
+ body and the driver filters the key — `applyMutationsToInput` is a plain
1193
+ `Object.assign`, and `validateRecord` walks declared fields on insert and
1194
+ `continue`s past a key with no field def on update — so the driver decides:
1195
+
1196
+ - **SQL** — the stray column enters the statement and the **whole write
1197
+ fails** with a driver-level error (`table deal has no column named stagee`).
1198
+ Nothing is stored, so the correctly-spelled fields of that row are lost too,
1199
+ and the error names a column far from the body that wrote it.
1200
+ - **Schemaless** (memory, MongoDB — both spread the payload without consulting
1201
+ the declared field set) — the stray key **is** persisted, as an undeclared
1202
+ column nothing downstream reads.
1203
+
1204
+ A lint that misdescribes the failure it is warning about teaches the wrong
1205
+ debugging instinct: an author told the value silently vanishes will not connect
1206
+ the driver error they actually see to the typo that caused it, and on a
1207
+ schemaless driver will not go looking for the stray key that is really there.
1208
+ All three messages now state the split, matching the "What still happens at
1209
+ runtime" description #4355 gave `content/docs/automation/hook-bodies.mdx`.
1210
+
1211
+ Both outcomes are pinned by a new integration test —
1212
+ `runtime/src/sandbox/undeclared-field-write-driver-split.integration.test.ts`.
1213
+ Its insert cases run the full chain (real QuickJS sandbox, real hook body, real
1214
+ engine, real driver against a real SQLite table), so "reaches the driver
1215
+ unfiltered" is proved rather than asserted: if anything on that path ever
1216
+ learns to filter, the SQL half stops throwing and the test goes red. The rule
1217
+ headers, the `ScriptBodySchema` / `ActionSchema.body` notes and the two
1218
+ still-unreleased #4271 changesets are corrected to match. #4355 fixed the
1219
+ prose docs; this is the same correction on the surfaces that ship in the
1220
+ packages — the diagnostic an author actually reads, and a test that pins it.
1221
+
1222
+ `@objectstack/spec`: doc comments only — no schema or generated-artifact change.
1223
+
1224
+ - 2e836de: chore(packaging): CHANGELOG.md ships in every npm tarball (#4261)
1225
+
1226
+ The AGENTS.md post-task checklist requires breaking changesets to carry their
1227
+ FROM → TO migration because "this text ships to consumers as `CHANGELOG.md`
1228
+ inside the npm package and is what an upgrading agent greps after the tombstone
1229
+ error." That delivery path was severed for 68 of the 69 publishable packages:
1230
+ npm packs `package.json` / `README*` / `LICENSE*` unconditionally but — unlike
1231
+ older npm versions — not `CHANGELOG.md`, and the canonical
1232
+ `"files": ["dist", "README.md"]` whitelist never named it. Measured on npm
1233
+ 10.9.7: `npm pack --dry-run` on `@objectstack/types` shipped 3 files while its
1234
+ 70KB `CHANGELOG.md` stayed behind. Only `@objectstack/spec` listed it
1235
+ explicitly.
1236
+
1237
+ The tombstone-error scenario is precisely the one where the repo is out of
1238
+ reach — the upgrading agent has `node_modules` and nothing else — so the
1239
+ migration text has to ride in the tarball. Every publishable package now
1240
+ declares `CHANGELOG.md` in `files`, and the canonical whitelist is
1241
+ `["dist", "README.md", "CHANGELOG.md"]`.
1242
+
1243
+ The other half is the gate: `check:published-files` gains a fifth invariant,
1244
+ COMPLETE — a whitelist that fails to cover `CHANGELOG.md` fails the
1245
+ always-required lint job, so the next package cannot silently sever the path
1246
+ again. `@objectstack/spec`'s per-package EXTRA_ENTRIES exemption dissolves
1247
+ into the canonical set.
1248
+
1249
+ Consumer-visible change: one more file per install (the package's changelog,
1250
+ e.g. 70.8KB for `@objectstack/types`), and `grep -r "removed key"
1251
+ node_modules/@objectstack/*/CHANGELOG.md` now finds the migration it was
1252
+ promised.
1253
+
1254
+ - 38182ff: feat(lint): `flow-node-write-unknown-field` covers `create_record` too (#4271)
1255
+
1256
+ #4369 shipped the flow write-set gate on `update_record` alone and parked
1257
+ `create_record` in `FLOW_WRITE_NODE_TYPES_DEFERRED` with its reason — a gating
1258
+ rule earning its severity one measured surface at a time, recorded as data
1259
+ rather than left as silence. This measures the other half and moves it across.
1260
+
1261
+ **The INSERT path fails the same way, one notch harder.** Same literal
1262
+ `config.fields` map, same `objectName` binding, same journey to the driver — the
1263
+ engine hands an undeclared key to `driver.create` verbatim, alongside the audit
1264
+ stamps. On SQLite/knex it becomes `table deal has no column named stagee` and
1265
+ the statement is rejected whole, so the correctly named fields in the same
1266
+ payload never land either. The extra harm is what does _not_ exist afterwards:
1267
+ the row is never created, so every later node reading `{<node>.id}` from that
1268
+ node's `outputVariable` is working from a record that was never written. An
1269
+ `update_record` failure at least leaves the record intact.
1270
+
1271
+ So the message now names that consequence on `create_record` and only there —
1272
+ "…and the record is never created at all" — instead of one sentence blurred to
1273
+ fit both.
1274
+
1275
+ Nothing else moves: same rule id, same `error` severity, the same silent bails
1276
+ (templated `objectName`, non-literal `fields`, cross-package objects, objects
1277
+ declaring no fields, dotted keys), and `runAs` is still not consulted. Each skip
1278
+ is now pinned on the create surface as well as the update one, so the two node
1279
+ types cannot drift into different behaviour.
1280
+
1281
+ **`FLOW_WRITE_NODE_TYPES_DEFERRED` is now empty and deliberately kept.** The
1282
+ partition test derives the full `fields`-write-map set behaviourally from the
1283
+ spec's executor-written config schemas, so a node type that grows one later
1284
+ belongs to neither list and fails that test until someone classifies it.
1285
+ Deleting the empty array would turn that forced decision back into a default.
1286
+
1287
+ Two non-members are now excluded on the shape of their failure rather than by
1288
+ omission, both stated in the module header and one pinned by a test:
1289
+ `get_record.fields` is a projection (`z.array(z.string())`) — a READ, where an
1290
+ unknown entry narrows the selection instead of breaking the statement — and
1291
+ `screen.defaults` is forwarded into the `ScreenSpec` the client renders, so an
1292
+ unknown key is a prefill the renderer ignores. That inert "skips it and renders
1293
+ the rest" case is exactly what this rule's `error` severity is defined against.
1294
+
1295
+ Verified against the repo's own apps: app-crm, app-todo and app-showcase all
1296
+ still validate clean with `create_record` covered — including crm's
1297
+ convert-lead flow, which creates an account and an opportunity before updating
1298
+ the lead.
1299
+
1300
+ - af5b96b: fix(lint): flow rules see into try_catch / loop / parallel regions (#4380)
1301
+
1302
+ Every lint rule that inspects flow nodes had hand-written the same one-liner —
1303
+
1304
+ ```ts
1305
+ const nodes = Array.isArray(flow.nodes) ? (flow.nodes as AnyRec[]) : [];
1306
+ ```
1307
+
1308
+ — and every one of them was therefore blind to the same thing.
1309
+ `FlowRegionSchema` holds a full `nodes: z.array(FlowNodeSchema)`, and four
1310
+ config slots carry one: `try_catch.config.try` / `.catch`, `loop.config.body`,
1311
+ and `parallel.config.branches[].nodes`. Regions nest arbitrarily. Move a node
1312
+ into any of them and the checking stayed behind.
1313
+
1314
+ Measured before the fix, the same bad nodes at the top level vs inside a
1315
+ `try_catch`:
1316
+
1317
+ | rule | severity | flat | nested |
1318
+ | :---------------------------------------------- | :------------ | :--- | :------------------ |
1319
+ | `flow-node-write-unknown-field` | error | 1 | **0** |
1320
+ | `flow-update-readonly-field` | error | 1 | **0** |
1321
+ | `approval-approver-*` | error/warning | 1 | **0** |
1322
+ | `flow-template-unknown-field` (filter position) | error | 1 | **1, as a warning** |
1323
+
1324
+ **The last row is the one a reader would not predict.**
1325
+ `validate-flow-template-paths` scans a node's whole `config` for string leaves,
1326
+ so it still _saw_ tokens inside a region — but its `filter`-position split only
1327
+ looks at the top level of the node it was handed. A nested filter token lost its
1328
+ position, so the #3810 finding ("this node cannot run — an erased condition
1329
+ WIDENS the query") silently degraded to an advisory warning, reported against
1330
+ the wrapping `try_catch` instead of the `get_record` that is broken:
1331
+
1332
+ ```
1333
+ FLAT error flow "f" node "get_record" flows[0].nodes[1]
1334
+ NESTED warning flow "f" node "try_catch" flows[0].nodes[1]
1335
+ ```
1336
+
1337
+ Being visible is not the same as being judged correctly. That is worse than a
1338
+ clean miss: a yellow line reads as "checked and merely advisory".
1339
+
1340
+ **One shared walk, not five.** `flow-walk.ts` — the flow-side counterpart of the
1341
+ existing `page-walk.ts`, and here for the same stated reason: getting the
1342
+ traversal right is subtle enough that duplicating it has already produced dead
1343
+ rules. `walkFlowNodes(flow, flowPath)` yields every node with its real config
1344
+ path (`flows[0].nodes[1].config.catch.nodes[0]`), a region breadcrumb for
1345
+ diagnostics (`try_catch "Guard" › catch`), and depth. Four rules now route
1346
+ through it: the two flow write rules, the template-path rule, and the approval
1347
+ rule.
1348
+
1349
+ Findings now land on the node that is actually wrong, which is the point — a
1350
+ path pointing at the container is not actionable in a flow with several regions.
1351
+
1352
+ **The double-count trap is handled, not left to each caller.** A container node
1353
+ is walked too (it has its own config worth checking — a `loop`'s `collection`, a
1354
+ `try_catch`'s `retry`), but its `config` physically contains every descendant,
1355
+ so a rule that scans config recursively would report each nested finding twice.
1356
+ `WalkedFlowNode.localConfig` is the container's config with region slots
1357
+ removed; the recursive scanner uses it, and a test pins that a nested token is
1358
+ reported once while the container's own `collection` token still is.
1359
+
1360
+ `REGION_SLOTS` is declared as data and pinned against the spec's own
1361
+ region-bearing config schemas — derived behaviourally (a slot is one that
1362
+ accepts `{nodes: […]}`), not restated — so a fifth construct fails that test
1363
+ instead of becoming a fifth silent blind spot. A `MAX_REGION_DEPTH` cap keeps a
1364
+ hand-authored (pre-parse) stack from hanging a lint.
1365
+
1366
+ Verified end to end: nested now matches flat on every rule, including the
1367
+ restored `error` severity. app-showcase ships an `update_record` inside a
1368
+ `catch` branch (`showcase_resilient_sync`) that had never been checked by
1369
+ anything — it is correct, so validation stays clean, and breaking its field name
1370
+ on purpose now fails `os validate` with
1371
+ `flows[24].nodes[1].config.catch.nodes[0].config.fields.sync_statuss` and the
1372
+ region trail `try_catch "Push with retry" › catch › node "Flag Sync Failure"`.
1373
+
1374
+ - 7d80695: fix(lint): an object declaring no fields is unjudgeable, not "has no such field" (#4383)
1375
+
1376
+ `hook-body-write-unknown-field` and `action-body-write-unknown-field` reported
1377
+ **every** field write to an object that declares no `fields` — an external
1378
+ object, or a datasource-introspected schema whose columns are resolved at
1379
+ runtime. Measured before the fix:
1380
+
1381
+ ```
1382
+ hook : ["hook-body-write-unknown-field / warning"] ← false
1383
+ action: ["action-body-write-unknown-field / warning"] ← false
1384
+ flow : [] ← correct
1385
+ ```
1386
+
1387
+ `indexObjectFields` returns an **empty Set** for such an object rather than
1388
+ `undefined`, and both rules only asked "is this object in the stack?" —
1389
+ `targetSets.every((s) => s !== undefined)` and `if (!known) continue`. An empty
1390
+ Set is neither undefined nor falsy, so it became the answer to `has(field)`,
1391
+ and the answer is always `false`.
1392
+
1393
+ That field map is not empty, it is **unknown**. The distinction already existed
1394
+ in two other rules of the same family, each with its reason written down —
1395
+ `validate-searchable-fields` skip #2 and `validate-flow-node-writes` (#4369,
1396
+ which added the guard because it gates). Two of four had it; the drift shape
1397
+ #3583 and #4330 exist to remove.
1398
+
1399
+ **Fixed once, not twice.** The guard now lives in a shared
1400
+ `judgeableFieldsOf(index, objectName)` that returns the declared names only when
1401
+ they are a sound basis for a "resolves to nothing" judgement, and `undefined`
1402
+ for both unjudgeable cases — cross-package objects and fields-less ones. All
1403
+ three write-set rules route their lookups through it, so a fourth cannot repeat
1404
+ the omission. It is internal to the family (not re-exported from the package
1405
+ barrel), same as `indexObjectFields` and `IMPLICIT_FIELDS`.
1406
+
1407
+ One semantic call worth naming: a **multi-target** hook where only _some_
1408
+ targets are judgeable is now skipped entirely. The `ctx.input` finding fires
1409
+ only when a field is missing from EVERY target, and an unjudgeable target is one
1410
+ the field might well exist on — so judging the remainder would assert "missing
1411
+ everywhere" on evidence that does not cover everywhere. Consistent with the
1412
+ rule's stated asymmetry: prefer a missed finding to a false one.
1413
+
1414
+ No behaviour change for objects that declare fields: an unknown field on a
1415
+ normal object still warns exactly as before, pinned by a test placed next to
1416
+ each new skip so the guard cannot swallow the real finding.
1417
+
1418
+ - ade7be4: fix(lint): the seven system-field exemption lists derive from the spec's declarations (#4330)
1419
+
1420
+ Five rules in `@objectstack/lint` each carried their own hand-copy of
1421
+ "registry-injected columns present on almost every object but absent from
1422
+ authored `fields`" — and they had already drifted from one another (two more
1423
+ copies had appeared by the time the fix landed). This is the shape #3786
1424
+ removed from the audit-provenance family, rebuilt one package over: the same
1425
+ list, maintained in parallel, each under a comment asking to be kept in sync
1426
+ with one of the others.
1427
+
1428
+ The package now has one module, `system-fields.ts`, whose `SYSTEM_FIELDS` is
1429
+ DERIVED from the spec's two declarations — `FIELD_GROUP_SYSTEM_FIELDS`
1430
+ (`@objectstack/spec/data`) and `SystemFieldName` (`@objectstack/spec/system`)
1431
+ — and all seven field-resolving rules consume it. A pin test holds the
1432
+ boundary in both directions: the set contains exactly the two declarations'
1433
+ union, and none of the rule-local exemptions.
1434
+
1435
+ Two deliberate behavior consequences, both in the permissive direction the
1436
+ rules' own comments argue for (over-inclusion costs at worst a missed
1437
+ warning; under-inclusion costs a false one):
1438
+
1439
+ - `widget-bindings`, `page-field-bindings` and `react-page-props` now also
1440
+ exempt `is_deleted`;
1441
+ - `flow-template-paths` now also exempts `user_id`.
1442
+
1443
+ Names that are NOT system columns in the spec's sense (`name`, `owner`,
1444
+ `record_type`, and the legacy physical spellings `_id` / `space`) stay
1445
+ rule-local next to the reason each rule exempts them, instead of widening
1446
+ every rule: `name` in particular is an ordinary authored field on most
1447
+ objects, and exempting it package-wide would stop the field-existence rules
1448
+ from catching a reference to a field the object genuinely does not have.
1449
+
1450
+ - 8db4587: fix(lint,cli): `os lint` / `os compile` 不再放行一个 `os validate` 会拒绝的 react 页面
1451
+
1452
+ `validateReactPageProps` 只手工接在 `os validate` 上,另外两个命令从来没跑过它。
1453
+ 在 showcase 的 react 页面上植入一处 gating 违规(`<ListView filters={['no_such_col','=',stage]}>`
1454
+ —— 谓词命中不了任何行,列表回空,和「本来就没数据」无法区分)实测:
1455
+
1456
+ ```
1457
+ os lint os compile os validate
1458
+ 修复前 exit 0 放行 exit 0 放行 exit 1 拒绝
1459
+ 修复后 exit 1 拒绝 exit 1 拒绝 exit 1 拒绝
1460
+ ```
1461
+
1462
+ 这条规则在 #4340 之后已经是**整个 react 页面表面唯一**的字段解析闸门:
1463
+ `<ListView>` 的 columns/fields/sort/grouping/userFilters、`<ObjectForm>` 的
1464
+ fields/initialValues/sections/subforms、`record:*` 一族(与元数据表面共用同一张
1465
+ `COMPONENT_FIELD_SPECS`)、`<ObjectChart>` 的 aggregate/axes、以及 `searchableFields`。
1466
+ 漏接不是少几条警告 —— 而是这些绑定在 build 路径上**完全没人看**,包括其中会 gate 的那些。
1467
+
1468
+ 现接入 `REFERENCE_INTEGRITY_RULES`,`os validate` 里那处手工接线随之删除,三个命令的
1469
+ 答案由构造保证一致。这正是 suite 设立要终结的漂移(#3583 §5 D5),也是
1470
+ `validateReadonlyFlowWrites` 在 #4394 里刚走过的同一条路 —— 那次的教训是
1471
+ 「一张 map、两个检查、两套命令集合」,这次是「一次 JSX parse、七个 rule id、
1472
+ 一套命令集合」。
1473
+
1474
+ 规则行为零变化:id、严重级、文案都不动;喂进去的输入也不变(`os validate` 原本就
1475
+ 传 `result.data`,suite 拿到的是同一个)。`#4402` 的接线守卫会在下一次有人想再手工
1476
+ 接一条规则时直接报错。
1477
+
1478
+ `validateReactPageProps` 沿用 `validateHookBodyWrites` / `validateActionBodyWrites`
1479
+ 的惰性约定:只有真的存在 `kind:'react'` 页面时才加载 TypeScript 编译器。
1480
+
1481
+ - 7fec5d6: fix(lint,cli): `os lint` no longer passes a flow the other two commands refuse
1482
+
1483
+ `validateReadonlyFlowWrites` was hand-wired into `os validate` and `os compile`
1484
+ and never into `os lint`. Measured on the showcase app with one planted
1485
+ violation — a `runAs:'user'` `update_record` writing a static-`readonly` field:
1486
+
1487
+ | | `os lint` | `os validate` |
1488
+ | ------ | ------------------- | ---------------- |
1489
+ | before | **exit 0 — passed** | exit 1 — refused |
1490
+ | after | exit 1 — refused | exit 1 — refused |
1491
+
1492
+ That rule **gates** (a static `readonly` + literal field is a certain no-op:
1493
+ the engine strips it from the UPDATE payload while the step still reports
1494
+ success, #2948/#3425), so the divergence was not a missing warning — `os lint`
1495
+ green-lit a build `os validate` stops.
1496
+
1497
+ It now joins `REFERENCE_INTEGRITY_RULES`, and both hand-wired call sites are
1498
+ deleted with it, so the three commands share one answer by construction rather
1499
+ than by three people remembering. This is the drift the suite was created to end
1500
+ (#3583 §5 D5) and which its own header cited this rule as the standing proof of.
1501
+
1502
+ Two things made the wiring indefensible rather than merely untidy:
1503
+
1504
+ - `validateFlowNodeWrites` (#4369) walks the **same** `config.fields` map to ask
1505
+ the other half of the question — "does this field exist?" against "is it
1506
+ writable?" — and is already a suite member. One map, two checks, two different
1507
+ command sets.
1508
+ - The two hand-wired sites did not even agree with each other on their input:
1509
+ `validate` passed the PRE-parse `normalized` stack, `compile` the POST-parse
1510
+ `result.data`. Verified equivalent for this rule before collapsing them onto
1511
+ the suite's post-parse input, so no finding is lost.
1512
+
1513
+ No rule behaviour changes: same ids, same severities, same messages.
1514
+
1515
+ - 31e0be9: Flow metadata is canonicalized inside structured regions, not just at the top level (#4347).
1516
+
1517
+ `registerFlow` canonicalizes a stored flow through three passes — the ADR-0087 conversion
1518
+ table, `FlowSchema.parse`, and the ADR-0032 predicate validation — and every one of them
1519
+ walked `flow.nodes` / `flow.edges` only. An ADR-0031 container keeps a whole sub-graph in
1520
+ its open `config` (`loop.config.body`, `parallel.config.branches[]`,
1521
+ `try_catch.config.try`/`.catch`), so all three stopped at the container and metadata came
1522
+ out **position-dependent**: the same node converted at the top level and did not one level
1523
+ in, and the same predicate was stored as a `{ dialect: 'cel', source }` envelope on a
1524
+ top-level edge and left a bare string on a loop-body edge.
1525
+
1526
+ The reporting app shipped three sweeps whose gates never opened. Each run reported
1527
+ `success: true`, queried correctly, selected exactly the right records, and then did
1528
+ nothing — which is indistinguishable from "this sweep had no work to do" unless you assert
1529
+ on records written.
1530
+
1531
+ - **`mapFlowNodes` recurses into regions**, to any depth. Every conversion in the table now
1532
+ reaches a nested node, which matters most for the two that change behaviour rather than
1533
+ spelling: a `webhook` / `http_request` callout inside a loop body kept a type no executor
1534
+ owns (the run failed), and a `delete_record` kept `config.filters`, leaving the canonical
1535
+ `filter` the executor reads absent — the erased-condition hazard
1536
+ `flow-node-crud-filter-alias` exists to prevent. Notice paths carry the region
1537
+ (`flows[0].nodes[3].config.body.nodes[1].config.filter`), so the warning points at the
1538
+ node to edit.
1539
+ - **New `normalizeControlFlowRegions`**, called at the load seam after
1540
+ `validateControlFlow`: each region is parsed through its own schema (recursively — regions
1541
+ nest), so nested edges and nodes carry the same canonical shapes as top-level ones. A
1542
+ region that does not parse is left untouched; rejecting one stays `validateControlFlow`'s
1543
+ job, so which flows register is unchanged.
1544
+ - **New `collectFlowGraphs`** yields a flow's own graph plus every nested region, each with
1545
+ a scope label. Both predicate validators iterate it instead of `flow.nodes` — the engine's
1546
+ `validateFlowExpressions` and `@objectstack/lint`'s author-time
1547
+ `validateStackExpressions` — so the `{record.x}` brace-trap they exist to catch is now
1548
+ caught inside a loop body too, naming the region (`loop 'sweep' body · edge 'b1' …`). It
1549
+ used to pass `objectstack validate`, pass registration, and fail at run time with the
1550
+ diagnostic suppressed.
1551
+
1552
+ The container executors already parse their own config at run time (`parseNodeConfig`,
1553
+ #4277), so a nested predicate did evaluate correctly on current `main` — what was still
1554
+ wrong is everything that reads a region _without_ re-parsing it (the Studio designer,
1555
+ `getFlow`, the version history), and every conversion, none of which the executors replay.
1556
+
1557
+ Also hardened, per the issue's secondary finding: `evaluateCondition`'s legacy `{var}`
1558
+ template path **refuses an unresolved dotted reference** instead of comparing it as a
1559
+ string. `'oppRecord.amount > 500000'` was compared `'oppRecord.amount' > '500000'` — `'o'`
1560
+ against `'5'` — so it was constantly true regardless of the amount: silently wrong in the
1561
+ _true_ direction, a gate that reports success while never gating. It now throws with the
1562
+ source and the fix (a CEL envelope, or brace the reference if the `{var}` dialect was
1563
+ meant), the same "never swallow a broken predicate" rule ADR-0032 §1c set for the CEL path.
1564
+ The `try { … } catch { return false }` around that block went with it: nothing in it throws,
1565
+ so it guarded nothing and would have swallowed the new refusal straight back into the silent
1566
+ wrong answer. Bare-word comparisons (`'{status} == active'`) and `{var}` templates are
1567
+ unchanged — only dotted references, which substitution can never leave behind, are refused.
1568
+
1569
+ - 4bfd455: One declaration of where ADR-0031 regions live (#4401).
1570
+
1571
+ A region is a sub-graph inside `FlowNodeSchema.config`, an open `z.record`. Nothing in the
1572
+ type system says which key on which node type holds one, so every pass that needs to reach
1573
+ a region node has to be told — and within one week three of them were told separately, by
1574
+ two changes that were each correct on their own:
1575
+
1576
+ | pass | package | table it carried |
1577
+ | --------------------------------------------------------------------------- | ------- | ------------------- |
1578
+ | `mapFlowNodes` (ADR-0087 conversions) | `spec` | `FLOW_REGION_SLOTS` |
1579
+ | `validateControlFlow` / `normalizeControlFlowRegions` / `collectFlowGraphs` | `spec` | `regionSlotsOf` |
1580
+ | `walkFlowNodes` (lint flow rules) | `lint` | `REGION_SLOTS` |
1581
+
1582
+ Each pinned its own copy with its own reconciliation test. So every copy was protected from
1583
+ drifting away from the schemas, and **nothing would have failed if the copies drifted from
1584
+ each other** — while adding a fourth construct meant editing three places, and missing one
1585
+ reproduces exactly the silent blind spot #4347 and #4380 were both filed about.
1586
+
1587
+ - New `@objectstack/spec/automation` export `FLOW_REGION_SLOTS` (plus the
1588
+ `FLOW_REGION_SLOTS_BY_TYPE` / `FLOW_REGION_CONFIG_KEYS` views) is now the only statement
1589
+ of the fact. It lives in an **import-free** module so `spec/conversions/walk.ts` can read
1590
+ it and stay the pure shape walker it was written as; mapping a slot onto the Zod schema
1591
+ its value parses as stays in `control-flow.zod.ts`, which is schema business.
1592
+ - The three reconciliation tests collapse into one, `region-slots.test.ts`, keeping the
1593
+ strongest of them: it derives each construct's region keys **behaviourally**, by asking
1594
+ the config schema what it actually accepts in a region shape, rather than reading names
1595
+ off `.shape`. It also probes every other exported `*ConfigSchema`, so a new
1596
+ region-bearing construct cannot be added without either declaring its slots or failing
1597
+ here.
1598
+
1599
+ The three **walks** are deliberately left separate. They take different inputs (parsed
1600
+ `FlowNodeParsed` vs raw authored records), yield different units (a graph, a node, a
1601
+ copy-on-write rewritten tree), and the lint one formats human diagnostic trails from node
1602
+ labels — consumer logic, not protocol (Prime Directive #2). Merging them would trade a
1603
+ duplicated four-line table for a walker that serves nobody well. Only the fact they all
1604
+ need is shared.
1605
+
1606
+ No behaviour change: every existing test passes unchanged, which is the point of the
1607
+ exercise.
1608
+
1609
+ - 1bd2795: feat(spec,lint): the `ui` vocabularies admit what the renderers implement, and derive instead of restating (objectui#2945)
1610
+
1611
+ Additions-only follow-up to the vocabulary audit
1612
+ (objectstack-ai/objectui#2901, #2945). Nothing here narrows a vocabulary, so no
1613
+ already-stored metadata changes meaning — three of the four `ui/` enums that had
1614
+ drifted from what is actually implemented, plus the fork that drift had made
1615
+ invisible.
1616
+
1617
+ **`ChartTypeSchema` admits `combo`.** The taxonomy could not name the one chart
1618
+ family the rest of `chart.zod.ts` is written for: `ChartSeriesSchema.type`
1619
+ exists to override a series' type — its doc comment literally says _"combo
1620
+ charts"_ — and `ChartSeriesSchema.yAxis` binds a series to the left or right
1621
+ axis, which is only meaningful for mixed marks. objectui's renderer draws it
1622
+ distinctly (mixed bar/line/area on dual axes, per-series type) and had to carry
1623
+ `combo` in a local fork of this list, whose own comment claimed to mirror it.
1624
+
1625
+ **`WidgetActionTypeSchema` is `ActionType`.** The two disagreed by one member,
1626
+ `form`, and the disagreement was backwards: a dashboard header or widget action
1627
+ button dispatches through the same `ActionRunner` that implements `form` —
1628
+ objectui's `DashboardRenderer` deliberately routes everything except a raw `url`
1629
+ into it, so a `flow` header action works (#3528). The narrower enum therefore
1630
+ rejected at validation exactly what the shared dispatcher then executes.
1631
+ Derived, so the next type the runner implements needs one edit, not two.
1632
+
1633
+ **`ListChartConfigSchema.chartType` is `ChartTypeSchema.extract([...])`.** Same
1634
+ five members as before — a de-duplication, not a widening. A member renamed in
1635
+ the taxonomy now fails at build time instead of leaving a second list quietly
1636
+ disagreeing.
1637
+
1638
+ **`@objectstack/lint`'s chart-family set is derived from the taxonomy.**
1639
+ `validate-widget-bindings` decides which widgets need a `chartConfig` measure
1640
+ mapping from a hand-written list of families, and its omissions fail in the
1641
+ worst direction: an unlisted family reads as _"not a chart"_, so a widget
1642
+ missing its mapping **passes** validation. `combo` was exactly that case —
1643
+ verified by pinning the old list back, where a `combo` widget with no
1644
+ `chartConfig` produced zero findings. The set is now the taxonomy minus an
1645
+ explicit `MEASURE_EXEMPT_CHART_TYPES` (single-value and tabular families), so a
1646
+ family added to the spec is covered without editing the rule.
1647
+
1648
+ Guards: `packages/spec/src/ui/vocabulary-derivation.test.ts` asserts both
1649
+ derivations still hold (a restated list fails silently — it keeps validating,
1650
+ just not what the other list says), and the lint suite now walks every
1651
+ multi-series family in the taxonomy rather than a list of its own.
1652
+
1653
+ A third ratchet already existed and did its job: `app-showcase`'s coverage test
1654
+ requires a gallery widget for every distinctly-renderable `ChartType`, and it
1655
+ failed the moment `combo` was admitted. The Chart Gallery dashboard now
1656
+ demonstrates it — a task count as bars on the left axis, an average as a line on
1657
+ the right, which is the configuration `series[].type` / `series[].yAxis` exist
1658
+ for.
1659
+
1660
+ `ActionType` deliberately does **not** gain `navigation`, which the audit
1661
+ suggested. `ActionRunner.executeNavigation` is a strictly weaker
1662
+ `executeUrl` — no `${param.X}` interpolation, no `apiBase` promotion, no
1663
+ `openIn` — differing only by a `replace` option, and its one live producer is
1664
+ the SDUI `element:button` `action` prop, which `ElementButtonPropsSchema` does
1665
+ not model at all. Promoting the name would add a second spelling of _navigate_
1666
+ to a closed authorable vocabulary (members cannot be removed later) without
1667
+ closing the gap that actually exists. Tracked separately.
1668
+
1669
+ Verified: `@objectstack/spec` **6944 tests / 267 files**, `@objectstack/lint`
1670
+ **544 tests / 37 files**, both green; `tsc --noEmit` clean on both.
1671
+
1672
+ - Updated dependencies [6a67d7a]
1673
+ - Updated dependencies [0ecc656]
1674
+ - Updated dependencies [06772eb]
1675
+ - Updated dependencies [270650f]
1676
+ - Updated dependencies [3aef718]
1677
+ - Updated dependencies [1ea6bce]
1678
+ - Updated dependencies [c1dcacd]
1679
+ - Updated dependencies [ad303ed]
1680
+ - Updated dependencies [32ccb23]
1681
+ - Updated dependencies [f5a4ef0]
1682
+ - Updated dependencies [2d3e255]
1683
+ - Updated dependencies [7d7521f]
1684
+ - Updated dependencies [5dc4d02]
1685
+ - Updated dependencies [05154a1]
1686
+ - Updated dependencies [9b6fe7c]
1687
+ - Updated dependencies [8c711fb]
1688
+ - Updated dependencies [09e4547]
1689
+ - Updated dependencies [91f4c78]
1690
+ - Updated dependencies [820eff9]
1691
+ - Updated dependencies [8d895ff]
1692
+ - Updated dependencies [f6472d7]
1693
+ - Updated dependencies [78caf51]
1694
+ - Updated dependencies [62a789b]
1695
+ - Updated dependencies [789ad63]
1696
+ - Updated dependencies [2af1988]
1697
+ - Updated dependencies [2e836de]
1698
+ - Updated dependencies [12a19a8]
1699
+ - Updated dependencies [41dcda3]
1700
+ - Updated dependencies [c8124e5]
1701
+ - Updated dependencies [a1a4140]
1702
+ - Updated dependencies [217e2e6]
1703
+ - Updated dependencies [86a71d1]
1704
+ - Updated dependencies [d5c75e2]
1705
+ - Updated dependencies [03d26f7]
1706
+ - Updated dependencies [4384921]
1707
+ - Updated dependencies [3c628ce]
1708
+ - Updated dependencies [7cb922e]
1709
+ - Updated dependencies [1d22114]
1710
+ - Updated dependencies [b5f9397]
1711
+ - Updated dependencies [ed77493]
1712
+ - Updated dependencies [58a03d2]
1713
+ - Updated dependencies [dc530b4]
1714
+ - Updated dependencies [e59786e]
1715
+ - Updated dependencies [bcf1112]
1716
+ - Updated dependencies [9774b78]
1717
+ - Updated dependencies [b07d829]
1718
+ - Updated dependencies [a648e96]
1719
+ - Updated dependencies [a47ac06]
1720
+ - Updated dependencies [e4c61a7]
1721
+ - Updated dependencies [cc60165]
1722
+ - Updated dependencies [081aa6f]
1723
+ - Updated dependencies [91f4c78]
1724
+ - Updated dependencies [e8d0c21]
1725
+ - Updated dependencies [c1d44f7]
1726
+ - Updated dependencies [ab9fb5c]
1727
+ - Updated dependencies [f985b3f]
1728
+ - Updated dependencies [9a4932a]
1729
+ - Updated dependencies [f9fc874]
1730
+ - Updated dependencies [011b386]
1731
+ - Updated dependencies [7777e8f]
1732
+ - Updated dependencies [507b92a]
1733
+ - Updated dependencies [7309c81]
1734
+ - Updated dependencies [20bc1ec]
1735
+ - Updated dependencies [90c2b15]
1736
+ - Updated dependencies [42eeb7d]
1737
+ - Updated dependencies [01e124d]
1738
+ - Updated dependencies [7ce02eb]
1739
+ - Updated dependencies [a13827e]
1740
+ - Updated dependencies [7733604]
1741
+ - Updated dependencies [40e420f]
1742
+ - Updated dependencies [d13004a]
1743
+ - Updated dependencies [cc2de0e]
1744
+ - Updated dependencies [5b47ab5]
1745
+ - Updated dependencies [b09d8d9]
1746
+ - Updated dependencies [b09d8d9]
1747
+ - Updated dependencies [8675db6]
1748
+ - Updated dependencies [b09d8d9]
1749
+ - Updated dependencies [3eb1b2b]
1750
+ - Updated dependencies [59b85c0]
1751
+ - Updated dependencies [6e357ed]
1752
+ - Updated dependencies [d6938bf]
1753
+ - Updated dependencies [31e0be9]
1754
+ - Updated dependencies [4bfd455]
1755
+ - Updated dependencies [ffd2ce2]
1756
+ - Updated dependencies [62f8017]
1757
+ - Updated dependencies [a831df1]
1758
+ - Updated dependencies [f752ee3]
1759
+ - Updated dependencies [a1b61e0]
1760
+ - Updated dependencies [cd6b9f2]
1761
+ - Updated dependencies [2cb6d3c]
1762
+ - Updated dependencies [af2a095]
1763
+ - Updated dependencies [ec796d5]
1764
+ - Updated dependencies [e87fea1]
1765
+ - Updated dependencies [c65e529]
1766
+ - Updated dependencies [3ca34c1]
1767
+ - Updated dependencies [239c3a3]
1768
+ - Updated dependencies [94a0bbc]
1769
+ - Updated dependencies [d6bfb3d]
1770
+ - Updated dependencies [a2266a6]
1771
+ - Updated dependencies [d25a0ec]
1772
+ - Updated dependencies [667b83e]
1773
+ - Updated dependencies [627b188]
1774
+ - Updated dependencies [8d4eae7]
1775
+ - Updated dependencies [65a3a84]
1776
+ - Updated dependencies [ccd9397]
1777
+ - Updated dependencies [bca935b]
1778
+ - Updated dependencies [c54c822]
1779
+ - Updated dependencies [8dcc0f5]
1780
+ - Updated dependencies [75b9e51]
1781
+ - Updated dependencies [0a2f233]
1782
+ - Updated dependencies [8621cdd]
1783
+ - Updated dependencies [6f23667]
1784
+ - Updated dependencies [5d21a48]
1785
+ - Updated dependencies [19365b7]
1786
+ - Updated dependencies [b7ed26d]
1787
+ - Updated dependencies [b3a3d83]
1788
+ - Updated dependencies [7a55913]
1789
+ - Updated dependencies [35accbf]
1790
+ - Updated dependencies [6038de7]
1791
+ - Updated dependencies [eb95d97]
1792
+ - Updated dependencies [e4c2dc8]
1793
+ - Updated dependencies [1bd2795]
1794
+ - Updated dependencies [8186a70]
1795
+ - Updated dependencies [a329cca]
1796
+ - Updated dependencies [6eec18c]
1797
+ - Updated dependencies [4d7bebf]
1798
+ - Updated dependencies [821ac7a]
1799
+ - Updated dependencies [8f81731]
1800
+ - Updated dependencies [4965bfa]
1801
+ - Updated dependencies [8b50cb3]
1802
+ - Updated dependencies [8c2db68]
1803
+ - Updated dependencies [22b5e54]
1804
+ - Updated dependencies [0166bd5]
1805
+ - Updated dependencies [9b702dc]
1806
+ - Updated dependencies [ab16331]
1807
+ - @objectstack/spec@17.0.0-rc.1
1808
+ - @objectstack/formula@17.0.0-rc.1
1809
+ - @objectstack/sdui-parser@17.0.0-rc.1
1810
+
1811
+ ## 17.0.0-rc.0
1812
+
1813
+ ### Minor Changes
1814
+
1815
+ - 14252d3: feat(approvals): cross-organization approver targeting — a plant document can
1816
+ require a group-side sign-off (ADR-0105 D9)
1817
+
1818
+ One organization id used to decide three different things at once in
1819
+ `openNodeRequest`: where the request row lives, where its inbox index rows
1820
+ live, and **where its approvers are looked up**. The first two are the
1821
+ request's own organization by definition. The third is not — a group CFO holds
1822
+ her `cfo` position in the GROUP organization while the purchase order she signs
1823
+ off lives in the PLANT organization. `expandPositionUsers('cfo', <plant>)`
1824
+ matched nobody, the slot fell back to the dead `position:cfo` literal, and a
1825
+ group escalation could not be expressed at all.
1826
+
1827
+ An approver may now declare which organization's directory resolves it:
1828
+
1829
+ ```yaml
1830
+ approvers:
1831
+ - { type: position, value: plant_manager, group: plant }
1832
+ - { type: position, value: cfo, organization: $root, group: finance }
1833
+ behavior: per_group
1834
+ ```
1835
+
1836
+ - **`$root` / `$parent`** walk D6's `parent_organization_id` tree, so the two
1837
+ common intents need **no deployment knowledge** — flow metadata is portable
1838
+ across environments while organization ids are minted per deployment. A slug
1839
+ covers what the symbols cannot, notably a **sibling** organization (a
1840
+ shared-services centre approving payables for every plant).
1841
+ - Declared **per approver**, so one node can require a plant manager and a
1842
+ group CFO in parallel. A node-level form cannot express that without
1843
+ splitting into serial nodes, which changes the semantics.
1844
+ - **Bounded, not free:** the target must share a `parent_organization_id` root
1845
+ with the request's organization. The rule reads only the organization tree —
1846
+ never the submitter — so one flow routes identically for everyone.
1847
+
1848
+ Everything else fails loudly rather than quietly:
1849
+
1850
+ - a non-`group` posture **refuses** the declaration (a `group` → `isolated`
1851
+ migration must not silently reroute approvals);
1852
+ - an approver type with no org-scoped directory (`user` / `field` / `manager` /
1853
+ `team`) refuses it too, and a new `approval-approver-cross-org-unsupported`
1854
+ lint catches that at author time;
1855
+ - a targeted approver holding no membership in the request's organization is
1856
+ dropped with a warning naming them — D2's union wall would otherwise hide the
1857
+ request from someone already routed to, so the node's existing
1858
+ `onEmptyApprovers` policy takes over instead of leaving an unopenable task.
1859
+
1860
+ Nothing changes for an approver without `organization`: same resolution, same
1861
+ queries, no extra reads.
1862
+
1863
+ - 879ea13: ADR-0105 Phase 0 + Phase 1: group tenancy posture; organization scope as a
1864
+ first-class authorization dimension.
1865
+
1866
+ > This release carries BREAKING spec removals (see "Enforce-or-remove" below)
1867
+ > but is recorded as `minor`: every publishable package is in the Changesets
1868
+ > lockstep group, so one `major` would promote the whole monorepo. Breaking
1869
+ > changes ship as `minor` during the launch window — the migration notes below
1870
+ > are what reach consumers in `CHANGELOG.md`.
1871
+
1872
+ ## Tenancy is now a spectrum (D1)
1873
+
1874
+ `single | group | isolated`, resolved by the `tenancy` service and selected with
1875
+ the new `OS_TENANCY_POSTURE` env var. Existing deployments are unchanged:
1876
+ `OS_TENANCY_POSTURE` unset derives the posture from `OS_MULTI_ORG_ENABLED`
1877
+ (`true` ⇒ `isolated`, else `single`). An unrecognized value throws at boot
1878
+ rather than silently landing in a posture with no organization wall.
1879
+
1880
+ - `single` — no wall (unchanged).
1881
+ - `group` — **new.** Organizations are membership boundaries over one shared
1882
+ dataset; Layer 0 becomes `organization_id IN accessible_org_ids` (union / MOAC
1883
+ semantics). Enforced by the OPEN engine.
1884
+ - `isolated` — today's `multi`, renamed. Behavior, enterprise `org-scoping`
1885
+ probe and degraded-boot handling all unchanged.
1886
+
1887
+ ## Organization scope is a first-class context field (D2)
1888
+
1889
+ `ExecutionContext.accessible_org_ids` — every organization the caller holds a
1890
+ currently-valid membership in (ADR-0091 validity windows) — is resolved once by
1891
+ `resolveAuthzContext` and carried by every transport. The `group` wall reads it
1892
+ directly; RLS policies may reference it as
1893
+ `organization_id IN (current_user.accessible_org_ids)`. An empty or absent set
1894
+ fails the wall closed.
1895
+
1896
+ Only the Layer 0 PREDICATE widens. Composition is untouched: the wall is still
1897
+ computed independently of the RLS compiler, AND-composed outermost, and
1898
+ crossable only by a true `PLATFORM_ADMIN` on a posture-permitting object — so
1899
+ ADR-0095's W1/W2 invariants hold in every posture.
1900
+
1901
+ ## Two P0 correctness fixes (D3, D4) — behavior changes
1902
+
1903
+ **D3 — app-authored org-scoped RLS policies are no longer silently dropped**
1904
+ (finding F1, framework#3539). `collectRLSPolicies` used to strip any policy whose
1905
+ `using` contained the substring `current_user.organization_id` when isolation was
1906
+ inactive, which swallowed app-authored policies as well as the platform's own.
1907
+ Stripping is now decided by PROVENANCE (identity against the shipped
1908
+ declaration). **Upgrade impact:** in a deployment with no organization wall, an
1909
+ app-authored policy referencing the active organization is now RETAINED and
1910
+ fails closed (zero rows) with a one-time warning, where it previously vanished
1911
+ and the object read unscoped. `getReadFilter` shared the defect, so analytics and
1912
+ raw-SQL consumers were affected too. If a policy was only ever meant for
1913
+ multi-org, delete it or install `@objectstack/organizations`.
1914
+
1915
+ **D4 — `viewAllRecords`/`modifyAllRecords` never cross an organization
1916
+ boundary** (finding F2, framework#3540). Under a wall-less posture nothing
1917
+ bounded the wildcard superuser bits `organization_admin` carries, so a
1918
+ deployment that accumulated organizations (personal orgs on signup) made every
1919
+ owner/admin an environment-wide superuser. `auto-org-admin-grant` now grants a
1920
+ de-VAMA'd `organization_admin_no_bypass` variant when no wall is enforced, and
1921
+ revokes the superseded variant whenever the posture changes. **Upgrade impact:**
1922
+ in `single` posture an org owner/admin keeps full CRUD but loses the blanket
1923
+ ownership/sharing/RLS bypass. Deliberate deployment-wide visibility remains
1924
+ available through `admin_full_access` or an explicitly authored permission set —
1925
+ it just stops being a side effect of a better-auth membership role.
1926
+
1927
+ ## Engine-owned organization stamping (D5)
1928
+
1929
+ Under any wall-enforcing posture the engine stamps `organization_id` from the
1930
+ caller's active organization on an insert that omits it, and validates every
1931
+ supplied value against the wall. Idempotent with the enterprise auto-stamp
1932
+ (neither overwrites a supplied value). This also closes a real hole: the
1933
+ pre-existing post-image check required a non-array payload, so a BULK insert
1934
+ could carry a forged `organization_id` per row. One forged row now denies the
1935
+ whole write.
1936
+
1937
+ ## Group structure, extension fields and red-line lints (D6, D7)
1938
+
1939
+ - `sys_organization` gains `parent_organization_id` and `sort_order` — a
1940
+ **reporting dimension only**.
1941
+ - New lint `validateOrgAxisRedLines` (`org-axis-permission-inheritance`,
1942
+ `org-axis-cross-org-bu-grant`), wired into `os lint` / `os compile` /
1943
+ `os validate`: an RLS policy or sharing rule that walks the org tree is an
1944
+ error, as is a business-unit grant on a platform-global object.
1945
+ - Extension fields on better-auth-managed objects ride the existing ADR-0092
1946
+ whitelist. A new guard derives better-auth's real field surface from
1947
+ `getAuthTables()` at the pinned version and fails the build on any name
1948
+ collision, so a library upgrade cannot silently take ownership of a column.
1949
+
1950
+ ## Enforce-or-remove (D11) — BREAKING
1951
+
1952
+ Both removals are of surface that had **zero runtime consumers**, so no
1953
+ behavior changes; authoring them is now a no-op instead of a lint warning.
1954
+
1955
+ - **`PermissionSet.contextVariables` — REMOVED.** The RLS compiler never read
1956
+ it. FROM → TO: a set a policy needs as `field IN (current_user.<key>)` is now
1957
+ supplied by a registered membership resolver (below); a constant belongs in
1958
+ the policy itself as a literal (`status = 'published'`).
1959
+ - **`Territory` / `TerritoryModel` / `TerritoryType` (`security/territory.zod.ts`)
1960
+ — REMOVED.** No runtime object, stack field or resolver existed. FROM → TO:
1961
+ matrix requirements are served by multi-position × business-unit anchoring; a
1962
+ generalized dimension-security module will arrive with its own ADR.
1963
+ - **`ExecutionContext.rlsMembership` — PRODUCTIZED.** The bag the compiler has
1964
+ merged since ADR-0056 finally has a producer: register an
1965
+ `IRlsMembershipResolver` (`@objectstack/spec/contracts`) under the
1966
+ `rls-membership-resolver` service, declaring the keys it owns. Fail-closed by
1967
+ construction — an unresolved key makes its policies drop out. Kernel-owned
1968
+ keys (`accessible_org_ids`, `org_user_ids`, …) are reserved and cannot be
1969
+ overwritten from this seam.
1970
+
1971
+ ## Edition boundary (D12)
1972
+
1973
+ The `group` posture's enforcement primitives ship OPEN — the union wall,
1974
+ `accessible_org_ids` resolution, D5 stamping/validation, the D3/D4 correctness
1975
+ fixes and the D6 lints — because the correctness of a wall is never a paid
1976
+ feature (cloud ADR-0016 铁律「强制免费、治理收费」). `isolated` keeps its existing
1977
+ enterprise `org-scoping` probe, so the current commercial boundary for
1978
+ legal-entity isolation is unchanged by this release.
1979
+
1980
+ - e2616e0: feat(spec,lint)!: remove `agent.tools[]`, lint agent authoring, and resolve `action_<name>` only when it actually materialises (#3820, ADR-0109 accepted)
1981
+
1982
+ **Breaking — `agent.tools[]` is removed.** ADR-0064's central invariant is
1983
+ "an agent's tool set is the union of its surface-compatible skills' tools;
1984
+ nothing falls through to the global registry", and this legacy inline slot
1985
+ was the one seam that broke it: the runtime resolved `agent.tools[].name`
1986
+ against the **full** tool registry with no surface check, so an `ask`-surface
1987
+ agent could name an authoring tool and get it. Removing the field makes the
1988
+ invariant structural — there is no second slot to disagree with the skills —
1989
+ rather than a rule every reader has to remember (ADR-0049 "design+enforce or
1990
+ remove"). `AIToolSchema` / the `AITool` type go with it.
1991
+
1992
+ _Migration:_ attach capability through `skills`. An agent authoring `tools` is
1993
+ not a parse error — Zod strips the unknown key — so existing stacks keep
1994
+ parsing, but the slot no longer does anything.
1995
+
1996
+ **`validate-ai-tool-references` now models AI exposure.** The rule previously
1997
+ resolved `action_<name>` against every declared action. The runtime is far
1998
+ stricter (ADR-0011): it materialises a tool only when the action opts in with
1999
+ `ai.exposed: true` + `ai.description` **and** has a headless path (type
2000
+ `script`/`api`/`flow` with a target or body — `url`/`modal`/`form` are
2001
+ UI-only). Resolving against all actions therefore blessed references the agent
2002
+ could never call — the exact failure the rule exists to catch. Unresolved
2003
+ `action_*` references now get their own message and fix, since "the action
2004
+ isn't exposed" and "the name is fictional" need different answers.
2005
+
2006
+ **New rule `validate-ai-agent-authoring`** (`agent-authoring-withdrawn`,
2007
+ warning): flags a stack that declares `stack.agents`. Tenant/app-package
2008
+ agents were withdrawn in ADR-0063 §2 — the runtime filters them from the
2009
+ catalog and refuses to load them — but `defineStack` still accepted the array,
2010
+ so an app could ship agents that parse, validate, and never run. This is the
2011
+ authoring-time signal that was missing (ADR-0078: loud at the producer,
2012
+ tolerant at the consumer). Joins `REFERENCE_INTEGRITY_RULES`.
2013
+
2014
+ ADR-0109 is now **Accepted — implemented (Phase 1)**, and the AI docs teach
2015
+ the zero-tool-record default path, including the three conditions that decide
2016
+ whether `action_<name>` exists and why a `modal` action staying human-driven
2017
+ is a design answer rather than a gap.
2018
+
2019
+ - 33f5e23: feat(lint): `validate-ai-surface-affinity` — skill ↔ agent surface affinity is now linted (#3820)
2020
+
2021
+ An agent binds a product surface (`'ask'` | `'build'`, ADR-0063 §1) and a skill
2022
+ declares which surface it belongs to (`'ask'` | `'build'` | `'both'`, §3). The
2023
+ runtime refuses an incompatible binding with a **load error at chat time** —
2024
+ after parse, validate, and deploy all passed cleanly. The new rule reports that
2025
+ contradiction statically, and joins `REFERENCE_INTEGRITY_RULES`, so
2026
+ `objectstack validate`, `lint`, and `compile` all pick it up with no CLI
2027
+ changes.
2028
+
2029
+ Scope is deliberately narrow (zero false positives by construction): only
2030
+ bindings where **both** the agent and the skill are declared in the same stack
2031
+ are checked. `agent.skills[]` names that don't resolve in-stack (kernel skills
2032
+ are runtime-registered and statically invisible) are skipped — resolving those
2033
+ namespaces is #3820 D0/D2, decided by ADR-0109 (Proposed).
2034
+
2035
+ The spec side is doc-truth only, no schema shape changes:
2036
+
2037
+ - `stack.agents` is documented as **platform-internal** (ADR-0063 §2 — the
2038
+ kernel ships exactly two agents; third parties extend via skills), replacing
2039
+ prose that still described the withdrawn ADR-0040 per-app-copilot model.
2040
+ - `stack.tools` is documented as declaration-only pending the ADR-0109 tool
2041
+ authoring model.
2042
+ - `app.defaultAgent` is re-documented as a surface-binding knob (`'ask'`
2043
+ implicit / `'build'` for authoring surfaces), not a custom-agent slot.
2044
+ - `SkillSchema` now states that a per-skill `permissions` field deliberately
2045
+ does not exist (ADR-0049) — authoring one is silently stripped; access is
2046
+ gated by `agent.access` / `agent.permissions` and per-tool authz.
2047
+
2048
+ - 259af21: feat(spec,lint): ADR-0109 Phase 1 — platform tool-name registry + advisory `skill.tools[]` reference lint (#3820 R7)
2049
+
2050
+ ADR-0109 (revised) settles the AI tool authoring model: **the default
2051
+ third-party path needs no tool records at all.** A skill's `tools[]` names
2052
+ either a platform-registered tool or a tool the runtime materialises from the
2053
+ app's own declarative actions (`action_<name>`) — the executable, its authz,
2054
+ and its audit trail stay on the action/flow the app already ships. Tool
2055
+ records are demoted to an optional AI-presentation refinement layer (Phase 2,
2056
+ gated on acceptance).
2057
+
2058
+ Phase 1, shipped here:
2059
+
2060
+ - **`PLATFORM_PROVIDED_TOOL_NAMES`** (`@objectstack/spec/system`) — curated
2061
+ registry of every statically-named tool the cloud AI runtime registers,
2062
+ grouped by owning package, plus `PLATFORM_TOOL_FAMILY_PREFIXES` for the
2063
+ materialised `action_` family and `isPlatformProvidedToolName()`. The
2064
+ `PLATFORM_PROVIDED_OBJECT_NAMES` precedent, applied to tools; conformance
2065
+ tests live in the owning cloud packages.
2066
+ - **`validate-ai-tool-references`** (`@objectstack/lint`) — the #3820 R7
2067
+ `skill.tools` branch, wildcard-aware, resolving against declared
2068
+ `stack.tools` ∪ the registry ∪ the materialised action family. Severity
2069
+ **warning** (ADR-0078 advisory-first ratchet): the registry cannot see
2070
+ third-party runtime plugins. Joins `REFERENCE_INTEGRITY_RULES`, so
2071
+ `validate`, `lint`, and `compile` all pick it up. On the HotCRM corpus it
2072
+ reports exactly the 10 fictional tool references (0 false positives on the
2073
+ 6 that resolve).
2074
+ - **`composeStacks` no longer drops `tools`** — the slot joins the
2075
+ concatenated array fields, so a declared record survives composition.
2076
+ - `stack.tools` / AI-slot docs updated to the ADR-0109 model.
2077
+
2078
+ - 474fe39: feat(approvals): declare approver value bindings; retire `queue` approver authoring (#3508)
2079
+
2080
+ - `@objectstack/spec` exports `APPROVER_VALUE_BINDINGS` — the single declaration of how a
2081
+ designer must source each approver row's `value`: `user`/`team`/`department`/`position`
2082
+ are DATA-record lookups on the system directory objects (`sys_user` / `sys_team` /
2083
+ `sys_business_unit` / `sys_position`; `position` commits the machine **name**, the
2084
+ others the row id), `org_membership_level` is a closed enum (`ORG_MEMBERSHIP_LEVELS`),
2085
+ `manager` is auto-resolved, `field` names a trigger-object field, and `queue` is
2086
+ unsupported. Also exports `NON_AUTHORABLE_APPROVER_TYPES`.
2087
+ - `queue` approver type is deprecated-for-authoring: it still parses (stored flows keep
2088
+ loading and rendering) but is published in `xEnumDeprecated`, so designers stop
2089
+ offering it — the runtime has no queue resolution and the slot routes to nobody. The
2090
+ approver `value` xRef now also maps `manager`, so designers can render its
2091
+ auto-resolved state. No authored key is removed; nothing to migrate. If a flow carries
2092
+ `{ type: 'queue' }`, replace it with `team` / `department` / `position` (or a concrete
2093
+ `user`) until a real ownership-queue implementation lands.
2094
+ - `@objectstack/plugin-approvals` now warns at resolution time when a stored `queue`
2095
+ approver is skipped.
2096
+ - `@objectstack/lint` adds `approval-approver-type-unsupported` (warning) for approver
2097
+ types that are declared but not implemented by the runtime.
2098
+
2099
+ - 2fa4ca1: Dynamic approver routing for approval nodes (#3447 P2) — three new declarative capabilities:
2100
+
2101
+ **`expression` approvers.** A new approver type whose CEL expression resolves WHO approves at node entry, over exactly three roots: `current.*` (the record's live state), `trigger.*` (the submit-time snapshot) and `vars.*` (flow variables, incl. upstream node outputs). `record` and bare field names are rejected before evaluation — on this platform `record` always means "the record at event time", which is ambiguous at an approval node — with error messages that prescribe the correct spelling. The optional `resolveAs: 'user' | 'department' | 'position' | 'team'` re-expands each resolved id through the same graph lookups the static types use; with `behavior: 'per_group'` each intermediate value (e.g. each returned department) forms its own sign-off group. A missing key fails the node loudly; only a present-but-empty result counts as an empty slate.
2102
+
2103
+ **`onEmptyApprovers` policy.** What an empty resolved slate does, node-level, for all approver types: `admin_rescue` (default — request opens for privileged takeover, the #3424 behaviour), `fail` (node fails), or `auto_approve` (skip the request, continue down the `approve` edge with `output.autoApproved = true`). To support auto-approve, the automation engine now honours `NodeExecutionResult.branchLabel` on the synchronous completion path — the field existed but was only ever consumed via resume signals.
2104
+
2105
+ **Decision outputs.** `decide(..., { outputs })` hands structured data from the approver to the flow: the author declares allowed keys on the node (`decisionOutputs`), approvers fill values only, and accepted outputs resume the run as `<nodeId>.<key>` variables — a later approval node's expression can read `vars.<nodeId>.picked_departments`, closing "the previous approver picks the next step's approvers" without a record-field detour. Undeclared keys reject the decision; `decision`/`requestId` are reserved. Multi-approver tallies now always pin to the open-time approver snapshot (previously unanimous re-resolved at each decision against the payload snapshot).
2106
+
2107
+ Also: `collectCelRootIdentifiers` is exported from `@objectstack/formula` (shared by the new `os lint` rules and the runtime pre-check, so they can never drift), resolution inputs are audited on the request snapshot as `__resolvedFrom`, and three new lint rules gate expressions, empty-slate policies and reserved output keys at author time.
2108
+
2109
+ - b0e5a37: fix(lint,cli): a filter reference that cannot resolve fails the build, not the run (#3426, #3810)
2110
+
2111
+ `validateFlowTemplatePaths` reported every `{record.<path>}` miss as **advisory**,
2112
+ on the reasoning that an unresolved token renders a blank and the run still
2113
+ completes. Since #3810 that reasoning no longer holds in one position: inside a
2114
+ CRUD node's `filter`, an unresolved token does not blank a value, it **deletes
2115
+ the condition** — and a removed condition matches MORE rows, not fewer. Those
2116
+ nodes now refuse to execute rather than run a widened query.
2117
+
2118
+ So the rule was warning about metadata whose runtime is already decided: `os
2119
+ validate` printed a yellow line, exited 0, and shipped a flow that cannot run.
2120
+ Severity now follows the runtime consequence, by position:
2121
+
2122
+ - **`filter` of `get_record` / `update_record` / `delete_record` → `error`.**
2123
+ These are the three nodes whose filter `resolveNodeFilter` guards. The finding
2124
+ says what the runtime will do ("the node refuses to run at execution time")
2125
+ and why the build gates rather than warns (an absent condition _widens_ the
2126
+ query). `os validate` exits 1.
2127
+ - **Every other position → `warning`, unchanged.** A message body, an `http`
2128
+ url, an `update_record` write payload: the token still renders a blank, the
2129
+ run still completes, and the head object may legitimately come from another
2130
+ installed package. `create_record` is deliberately excluded from the gating
2131
+ set — it writes a payload and has no filter to widen.
2132
+
2133
+ Both rules split this way (`flow-template-unknown-field` and
2134
+ `flow-template-lookup-traversal`), so a typo and a lookup hop are gated wherever
2135
+ the runtime refuses them. A reference used in both positions on one node is
2136
+ reported **once, at error severity**.
2137
+
2138
+ **`os validate` now enforces it.** The command filtered this rule's findings for
2139
+ `severity === 'warning'` and dropped everything else on the floor, so an error
2140
+ from it would have been invisible. It now gates on errors first — printing rule
2141
+ id and config path, and emitting them under `errors` in `--json` — mirroring the
2142
+ `validateReadonlyFlowWrites` step directly below, which makes the same
2143
+ shift-left split (a certain runtime failure gates; a state-dependent one
2144
+ advises).
2145
+
2146
+ Verified against the shipped examples: 33 flows across app-todo, app-crm and
2147
+ app-showcase produce **no new errors**; the four pre-existing lookup-traversal
2148
+ warnings sit in `script` / `notify` / `subflow` / `parallel` positions and keep
2149
+ their advisory severity.
2150
+
2151
+ No authoring change is required for a correct filter. A filter that this rule
2152
+ now fails is one the runtime would have refused anyway — the difference is that
2153
+ you find out at `os validate` instead of at 3am.
2154
+
2155
+ - fd7cfde: fix(lint,cli): the flow-template-path rule reaches `os lint` and `os compile`, not just `os validate` (#3583, #3810)
2156
+
2157
+ `validateFlowTemplatePaths` was wired by hand into `os validate` and nowhere
2158
+ else. That is precisely the drift `REFERENCE_INTEGRITY_RULES` exists to end
2159
+ (#3583 §5 D5): the same stack, checked by a different rule subset depending on
2160
+ which command the author happened to run.
2161
+
2162
+ It mattered more after #3861 gave the rule a gating severity. A `{record.<path>}`
2163
+ token in a CRUD node's `filter` that names an unknown field — or hops through an
2164
+ un-expanded relation — makes the runtime **refuse the node** (#3810). `os
2165
+ validate` failed on it; `os lint` and `os compile` did not look, so a CI job
2166
+ running either one would build and ship a flow that cannot execute.
2167
+
2168
+ **The rule is now a suite member.** It belongs by the suite's own admission
2169
+ criterion: a `{record.<field>}` token is a name written in metadata, resolved
2170
+ against the bound object's declared fields. One line in
2171
+ `REFERENCE_INTEGRITY_RULES` reaches all three commands, and the hand-wiring in
2172
+ `validate.ts` is deleted rather than duplicated.
2173
+
2174
+ Before landing this, the rule was run against all three stack shapes the suite
2175
+ is handed — raw `config` (`os lint`), `normalizeStackInput` output, and
2176
+ schema-parsed `result.data` (`os validate` / `os compile`) — across `app-todo`,
2177
+ `app-crm` and `app-showcase`. All three agree finding-for-finding, so moving the
2178
+ call site does not change what is reported.
2179
+
2180
+ Verified end-to-end on `app-showcase`: all three commands pass unchanged on the
2181
+ real stack (the four pre-existing lookup-traversal warnings still print, still
2182
+ advisory), and with one filter token corrupted to `{record.idd}` **all three now
2183
+ exit 1** — where previously only `validate` did.
2184
+
2185
+ **Also fixed, in the same file.** On a clean run, `os validate --json` never
2186
+ reported the reference-integrity suite's warnings: `refWarnings` was assembled,
2187
+ printed to the console, and included in the _failure_ payload, but omitted from
2188
+ the success-path `warnings` array. Adding the rule to the suite would have
2189
+ silently dropped its warnings from `--json` for JSON consumers, so `refWarnings`
2190
+ now appears there — which also surfaces the other five rules' warnings that were
2191
+ being discarded. Same shape of bug as the dropped errors #3861 fixed: computed,
2192
+ then thrown away.
2193
+
2194
+ - 9bf4588: feat(lint): flag never-firing record trigger tokens at authoring time (#3427)
2195
+
2196
+ New `flow-trigger-unknown-event` rule in `validateFlowTriggerReadiness`: a flow
2197
+ start node whose `triggerType` is record-lifecycle-shaped
2198
+ (`record-before|after-<op>`) but names an op the record-change trigger cannot map
2199
+ — e.g. a typo like `record-after-updated` — binds to the record-change trigger
2200
+ yet maps to no ObjectQL hook and never fires, with only a runtime warning. The
2201
+ rule surfaces that never-fire defect at `os validate` time. Warning severity;
2202
+ bare `record-<noun>` shapes (e.g. `record-change`) are out of scope.
2203
+
2204
+ - f022c4d: refactor(lint): one entry point for the reference-integrity suite (#3583 D5)
2205
+
2206
+ Six rules that answer the same question — "does this name resolve to anything?"
2207
+ — were wired by hand into three CLI commands, so landing a rule meant editing
2208
+ `validate`, `lint` and `compile`, and forgetting one meant the same stack got a
2209
+ different verdict depending on which command the author ran.
2210
+
2211
+ New public API on `@objectstack/lint`:
2212
+
2213
+ - `validateReferenceIntegrity(stack)` — runs every reference-integrity rule and
2214
+ returns the concatenated findings.
2215
+ - `REFERENCE_INTEGRITY_RULES` — the ordered list behind it (`validateObjectReferences`,
2216
+ `validateActionNameRefs`, `validatePageFieldBindings`, `validateChartBindings`,
2217
+ `validateNavAccess`, `validateTranslationReferences`).
2218
+ - `ReferenceIntegrityFinding` / `ReferenceIntegrityRule` / `ReferenceIntegritySeverity`
2219
+ — one finding type instead of a six-way union.
2220
+
2221
+ Adding a rule to that list reaches `validate`, `lint` and `compile` with no
2222
+ further wiring. The individual rule exports are unchanged, so nothing that
2223
+ imports them directly needs to move.
2224
+
2225
+ Behaviour-preserving: identical findings on the three example apps (zero) and
2226
+ on the HotCRM corpus (24, unchanged per rule). `os doctor` is deliberately not
2227
+ converted — it runs only `validateWidgetBindings` and is an environment health
2228
+ check rather than an authoring gate.
2229
+
2230
+ - 2343099: feat(lint): translation-bundle reference integrity + option-key validation (#3583)
2231
+
2232
+ The i18n gate only ever ran forward: `os i18n check` asks which keys the
2233
+ metadata expects that no bundle carries. Nothing asked the reverse — which keys
2234
+ a bundle carries that no metadata claims — even though the spec already names
2235
+ the answer (`TranslationDiffStatus 'redundant'`, `TranslationCoverageResult.redundantKeys`,
2236
+ both declared with no producer).
2237
+
2238
+ That direction ships two failure modes, both found in the HotCRM audit: bundles
2239
+ keyed to fields an object no longer declares (a rename that left the translation
2240
+ behind), and select-option translations keyed by the option's **display label**
2241
+ or a variant spelling of its value (`direct-mail` for `direct_mail`, `planned`
2242
+ for `planning`). Neither breaks anything — which is the problem. The resolver
2243
+ finds nothing and renders the source string, so the screen looks translated and
2244
+ one field or one picklist value quietly does not.
2245
+
2246
+ New rule `validateTranslationReferences` walks every bundle in
2247
+ `stack.translations` against the stack it ships with, wired into `os validate`,
2248
+ `os lint`, and `os compile`:
2249
+
2250
+ | Key | Must name |
2251
+ | ----------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
2252
+ | `objects.{object}` | an object this stack defines, or a platform object |
2253
+ | `objects.{object}.fields.{field}` | a field that object declares |
2254
+ | `objects.{object}.fields.{field}.options.{key}` | an option's stored `value` |
2255
+ | `objects.{object}._views` / `._actions` / `._sections` / `._actions.*.params` | a view `name` / bound action / `fieldGroups[].key` or named section / param `name` |
2256
+ | `apps.{app}` / `.navigation.{id}` | an app `name` / navigation item `id` |
2257
+ | `dashboards.{dash}` / `.widgets.{id}` / `.actions.{actionUrl}` | dashboard `name` / widget `id` / header `actionUrl` |
2258
+ | `globalActions.{action}` | an action with no `objectName` |
2259
+
2260
+ Every finding is a **warning** (`translation-target-unknown`,
2261
+ `translation-option-key-unknown`): an orphan key is inert, not broken, and the
2262
+ severity should say so. Diagnostics carry the declared names to choose from,
2263
+ name the stored value when a key turns out to be the display label, and suggest
2264
+ a namespace-segment match (`task` → `todo_task`) that edit distance alone misses.
2265
+
2266
+ Cross-package objects follow the existing ladder: a registered platform object
2267
+ is skipped wholly (its fields are not visible from a stack lint), a
2268
+ platform-prefixed name no package registers is reported once on the object key,
2269
+ and the subtree is never half-checked. `messages`, `validationMessages`,
2270
+ `settings`, `settingsCommon` and `metadataForms` are deliberately not judged —
2271
+ their keys are owned by application code, plugins, and the platform's own
2272
+ metadata-type registry, so no enumerable universe exists to resolve against.
2273
+
2274
+ - f2b8ac9: Navigation reachability vs. granted access (issue #3583, assessment R5)
2275
+
2276
+ `validate-nav-access` joins what an app's navigation exposes against
2277
+ `buildAccessMatrix` — the first lint consumer of the ADR-0090 D6 matrix, which
2278
+ previously only backed `os compile`'s snapshot gate. An object in the menu that
2279
+ no permission set grants read on renders as an entry and then fails
2280
+ permission-denied when opened: it works while you browse as an administrator
2281
+ (the platform's built-in `admin_full_access` carries a wildcard grant) and
2282
+ breaks for exactly the users the app ships permission sets for.
2283
+
2284
+ Advisory severity — a grant can legitimately come from a permission set another
2285
+ installed package ships. Quiet by construction in three cases: platform-provided
2286
+ objects (their own packages grant them), stacks that declare no permission sets
2287
+ at all (permissions managed elsewhere, so flagging every entry says nothing),
2288
+ and any stack where a set carries a wildcard `objects: { '*': … }` grant — the
2289
+ shape `admin_full_access` itself uses, which the access matrix records under the
2290
+ literal key `*`.
2291
+
2292
+ Wired into `os validate`, `os lint`, and `os compile`.
2293
+
2294
+ - 2a5f04a: `<ObjectChart>` aggregate result-column naming is now a contract, and its axis bindings are validated (issue #3701)
2295
+
2296
+ Split out of #3583 Phase 2 (#3684), which extended ADR-0021 axis checking to
2297
+ report charts, list-view charts, and dataset-bound page chart components but had
2298
+ to leave the react `<ObjectChart>` block out: it is OBJECT-bound (`objectName` +
2299
+ an inline `aggregate`), `aggregate` existed in the contract only as the
2300
+ description string `'{ field, function, groupBy }'`, and nothing in the repo said
2301
+ what the aggregated result columns were called. Without that, `xAxis`/`yAxis` had
2302
+ nothing to resolve against, and guessing a convention would have manufactured
2303
+ false positives (ADR-0072 D1).
2304
+
2305
+ **The convention, recorded rather than invented.** Every path that can serve an
2306
+ object-bound chart already agreed — the engine's structured-`groupBy` aggregate
2307
+ (whose alias objectui sets to `field || function`), the legacy analytics query
2308
+ (which remaps its measure key back to `field`), the client-side fallback, and the
2309
+ console's own chart-view wiring (`xAxisKey: groupBy`, `series[].dataKey: field`).
2310
+ `packages/spec/src/ui/chart-aggregate.ts` writes it down and exports it:
2311
+
2312
+ - an object-bound aggregate returns rows keyed by the **raw field names** —
2313
+ `groupBy` for the category column, `field` for the value column, the literal
2314
+ `count` for a fieldless count, plus `<field>__comparison` under a comparison
2315
+ overlay;
2316
+ - `chartAggregateCategoryKey` / `chartAggregateValueKey` / `chartAggregateResultKeys`
2317
+ derive those columns so producers and checkers cannot re-derive them apart;
2318
+ - `ChartAggregateSchema` replaces the description string with a real Zod schema
2319
+ and rejects a non-`count` function with no `field` (which used to reach the
2320
+ renderer as `sum(undefined)` and render blank).
2321
+
2322
+ This is the deliberate opposite of the dataset path, whose rows are keyed by the
2323
+ declared measure `name` (`sum_amount`) — the trap `chart-measure-unknown` catches.
2324
+ Only the dataset path has an author-chosen name to key by.
2325
+
2326
+ **`<ObjectChart>`'s contract now names the props it actually reads.** The block
2327
+ consumes `xAxisKey` and `series[].dataKey`; `ChartConfig`'s `xAxis`/`yAxis`/`series`
2328
+ shapes reached it and were silently dropped, which ADR-0078 forbids. They are
2329
+ removed from the block's `dataProps`; `chartType`, `xAxisKey`, and `series` are
2330
+ declared in the React overlay where the other bindings live.
2331
+
2332
+ **`validate-react-page-props` now reads attribute VALUES**, not just names, for
2333
+ `<ObjectChart>`:
2334
+
2335
+ - `react-chart-field-unknown` (error) — `aggregate.field` / `aggregate.groupBy`
2336
+ naming a field the bound object does not declare;
2337
+ - `react-chart-aggregate-invalid` (error) — an unimplemented aggregation
2338
+ function, or a non-`count` function with nothing to aggregate;
2339
+ - `react-chart-axis-unknown` (error) — `xAxisKey` / `series[].dataKey` naming a
2340
+ column the aggregate does not return (including a dataset-style `sum_total`),
2341
+ or a category axis bound to the value column;
2342
+ - `react-chart-axis-inert` (warning) — the `xAxis` / `yAxis` shapes this block
2343
+ never reads.
2344
+
2345
+ Value reading is opt-in per block and evaluates only static literals: a prop
2346
+ driven by React state or a variable, a usage carrying a `{...spread}`, a chart
2347
+ given inline `data`, and objects another package defines are all skipped
2348
+ silently — an unresolvable binding is not a wrong one.
2349
+
2350
+ - 4f740b0: `<ObjectChart>`'s author contract is the spec `ChartConfig` shape again (issue #3729)
2351
+
2352
+ #3701 trimmed `xAxis`/`yAxis`/`series` out of the `<ObjectChart>` contract
2353
+ because the renderer read `xAxisKey`/`series[].dataKey` and silently dropped the
2354
+ ChartConfig shapes — an honest record of the runtime gap, not the target state.
2355
+ objectui#2880 closed the gap the other way round (the renderer now honors
2356
+ `ChartConfig` through one normalization boundary), so the contract follows the
2357
+ protocol again (ADR-0082 D1: the spec schema IS the protocol).
2358
+
2359
+ **Contract.** `type`, `xAxis`, `yAxis`, `series`, `subtitle`, `showDataLabels`,
2360
+ `annotations` and `interaction` are published from `ChartConfigSchema`; the
2361
+ internal `chartType`/`xAxisKey`/`series[].dataKey` spellings leave the author
2362
+ contract. `annotations` and `interaction` gained the `.describe()` they never
2363
+ had, so the generated contract stops publishing bare `object[]` with no meaning.
2364
+
2365
+ **The `type` exception.** `ChartConfig.type` is the chart family, but on any
2366
+ surface that flattens chart config into a props bag `type` is already the SDUI
2367
+ envelope's component discriminator — an author writing `type="bar"` used to
2368
+ replace `object-chart` and the block stopped resolving. The collision is created
2369
+ by the flattening and is resolved there (objectui's react-page wrapper), so the
2370
+ contract can publish `type` as the spec spells it. The contract generator's
2371
+ blanket `type` skip is now overridable by an explicit `dataProps` allow-list,
2372
+ since for this one block `type` is a real author prop.
2373
+
2374
+ **Lint.** `validate-react-page-props` reads the axes in the spec spelling —
2375
+ `xAxis.field`, `yAxis[].field`, `series[].name` — and keeps accepting the
2376
+ internal spellings silently, because dashboards and the console's own chart-view
2377
+ wiring emit them. `react-chart-axis-inert` is retired: the props it warned about
2378
+ are honored now, so the warning would be false. The three binding-integrity
2379
+ rules from #3701 are unchanged.
2380
+
2381
+ **Spec.** `chart-aggregate.ts` records the constraint the whole result-column
2382
+ convention rests on: an inline `aggregate` is SINGLE-MEASURE. Keying rows by the
2383
+ raw field name only works because there is exactly one measure to key; two
2384
+ measures over one field would collide, and resolving that needs an author-chosen
2385
+ name per measure — which is what a dataset is. Widening `ChartAggregateSchema`
2386
+ into a measures array would silently invalidate every axis binding these rules
2387
+ validate, so the boundary is now written down rather than left to be rediscovered.
2388
+
2389
+ The chart taxonomy note is corrected too: grouped/stacked bar and stacked area
2390
+ are absent from `ChartTypeSchema` not because they render as their base chart,
2391
+ but because stacking is a property of the SERIES (`ChartSeries.stack`), not a
2392
+ chart family — one `bar` family plus a series stack group expresses all three.
2393
+ `ChartInteraction.zoom` is now marked declared-not-delivered in its own
2394
+ description rather than reading as shipped.
2395
+
2396
+ - 17749fc: Page-component field bindings and non-dashboard chart bindings (issue #3583, Phase 2)
2397
+
2398
+ Two more reference-integrity rules from the #3583 assessment, both wired into
2399
+ `os validate`, `os lint`, and `os compile`.
2400
+
2401
+ **`validate-page-field-bindings`** — `PageComponent.properties` is an untyped
2402
+ bag, so a highlights strip, KPI card, or details section can name a field the
2403
+ bound object does not have; the component silently skips it. Which object a
2404
+ component binds follows `dataSource.object` → `properties.object` → the page's
2405
+ `object`, so multi-object pages are checked per element. `record:related_list`
2406
+ resolves its columns/sort/filter against the **related** object and its
2407
+ add-picker against that picker's own object. Advisory (matching
2408
+ `FORM_FIELD_UNKNOWN`). Relationship paths, system fields, cross-package objects,
2409
+ and unregistered component types are skipped.
2410
+
2411
+ **`validate-chart-bindings`** — extends ADR-0021 axis checking past dashboards to
2412
+ report charts (`report.chart` and `report.blocks[].chart`), list-view charts
2413
+ (`views[].list`, `views[].listViews.*`, `objects[].listViews.*`), and
2414
+ dataset-bound page chart components. An axis naming a raw field instead of a
2415
+ declared measure is an **error** (the series comes back empty); an axis naming a
2416
+ declared-but-unselected measure is a **warning**. The report shape needed its own
2417
+ handling: `ReportChartSchema` narrows `xAxis`/`yAxis` to bare strings, which the
2418
+ dashboard rule's array guard skips silently. The react `<ObjectChart>` block is
2419
+ object-bound, not dataset-bound, and is deliberately left out — nothing defines
2420
+ what its aggregate names the result column.
2421
+
2422
+ **Fixes:** the page walk used by `validate-action-name-refs` read a top-level
2423
+ `page.components` array, which `PageSchema` does not have — components live under
2424
+ `regions[].components[]` and `slots`, and sub-trees nest inside the untyped
2425
+ `properties` bag (`children`, `items[].children`, `body`, `footer`) rather than a
2426
+ `children` key on the component. The rule was therefore visiting nothing on a
2427
+ schema-parsed stack. Traversal now lives in one shared, tested module; on the
2428
+ showcase app it reaches 194 components where the previous shape found 46.
2429
+ Source-authored pages (`kind: 'html' | 'react' | 'jsx'`) are skipped — their
2430
+ `regions` hold a derived cache the `source` wins over.
2431
+
2432
+ - 4340f13: feat(lint,cli): flag flow `update_record` writes to readonly fields at design time (#3425)
2433
+
2434
+ A flow `update_record` node that writes a field the target object declares
2435
+ `readonly: true`, under the default `runAs: 'user'` identity, is a **silent
2436
+ no-op**: the objectql engine strips static-`readonly` fields from a non-system
2437
+ UPDATE payload (#2948), so the intended write never lands — yet the step still
2438
+ reports `success`. #3407/#3413 surfaced the strip as a run-time step warning;
2439
+ this moves the discovery **left** to `os validate` / `os build` so an author
2440
+ finds the mismatch at design time instead of by reading server WARN logs days
2441
+ later.
2442
+
2443
+ - New `@objectstack/lint` rule `validateReadonlyFlowWrites(stack)` — a pure
2444
+ `(stack) => Finding[]` check (ADR-0019). A static `readonly:true` field
2445
+ written by a literal `update_record` under `runAs !== 'system'` is a
2446
+ 100%-certain no-op → **error** (gates the build). A `readonlyWhen` field is
2447
+ per-record-state → **warning** (advisory). Deliberately narrow to stay
2448
+ false-positive-free: `create_record` (INSERT is engine-exempt from the strip),
2449
+ `runAs: 'system'` flows (the intended "automation maintains it" channel),
2450
+ templated object names, and non-literal `fields` maps are all skipped.
2451
+ - Wired into `os validate` and `os compile`/`os build`, mirroring the existing
2452
+ security-posture gate (errors fail; advisories print dimmed).
2453
+
2454
+ The formal contract, unchanged in behavior: `readonly` governs the end-user /
2455
+ API surface (REST/UI and `runAs:'user'` flows strip it); trusted system writers
2456
+ (`runAs:'system'`, system hooks, seeds) maintain it. To let a flow maintain a
2457
+ readonly field, declare `runAs: 'system'`.
2458
+
2459
+ - f163028: Reference-integrity validation for object and action names (issue #3583)
2460
+
2461
+ A HotCRM audit found ~20 shipped instances of one bug class — metadata naming
2462
+ something that does not exist — all passing `objectstack validate` / `lint`
2463
+ cleanly and failing silently at runtime. This closes the object-name and
2464
+ action-name half of that class.
2465
+
2466
+ **New — `@objectstack/spec`:** `PLATFORM_PROVIDED_OBJECT_NAMES`, a curated
2467
+ registry of every object name contributed by a platform package, official
2468
+ plugin, or the cloud runtime, plus `isPlatformProvidedObjectName()` and
2469
+ `hasPlatformObjectPrefix()`. This replaces the `startsWith('sys_')` prefix guess
2470
+ that could not tell `sys_user` (real) from `sys_approval_process` (fictional —
2471
+ removed by ADR-0019, registered by nothing), which is why every fictional
2472
+ platform-prefixed reference shipped. A conformance test scans each package's
2473
+ `*.object.ts` declarations and fails if the registry drifts.
2474
+
2475
+ **New lint rules** (wired into both `os validate` and `os lint`):
2476
+
2477
+ - `validate-object-references` — action-param `reference` / `objectOverride`,
2478
+ dashboard `globalFilters[].optionsFrom.object`, and navigation
2479
+ `requiresObject` gates. Severity follows resolvability: an unresolved
2480
+ _unprefixed_ name is a typo (**error** — `object: 'user'` where the platform
2481
+ object is `sys_user`); an unresolved _platform-prefixed_ name is **advisory**,
2482
+ since a third-party package may still provide it.
2483
+ - `validate-action-name-refs` — the surfaces that bind an action BY NAME:
2484
+ list-view `bulkActions` / `rowActions`, page `record:quick_actions`
2485
+ `actionNames`, and nav action items. A name matching no defined action is an
2486
+ **error** (the button renders and does nothing), matching the existing
2487
+ dashboard-action-target rule.
2488
+
2489
+ **Fixes:**
2490
+
2491
+ - `defineStack` cross-reference validation now walks `app.areas[].navigation` —
2492
+ an areas-based app previously got no navigation checking at all — and recurses
2493
+ into `children` on `object` nav items, not only `group` ones.
2494
+ - `os lint` i18n coverage now reads field `options` in the canonical
2495
+ `{value,label}[]` array shape; it only handled the record map, so option-label
2496
+ coverage silently never fired for canonically-shaped select fields.
2497
+ - Hook `condition` expressions are now field-checked when `object` is an ARRAY
2498
+ of targets (previously only a single string target was checked, so a
2499
+ multi-target hook filtering on a nonexistent field passed clean). Per-target
2500
+ diagnostics are de-duplicated.
2501
+ - A dashboard widget binding no `dataset` at all is now reported instead of
2502
+ silently bypassing every binding and chart check on the raw-config
2503
+ (`lint`/`doctor`) paths. `dataset` is schema-required, so this matches what
2504
+ the parsed paths already enforce.
2505
+
2506
+ ### Patch Changes
2507
+
2508
+ - 1bd5652: feat(auth): give ADR-0105 D8's scope-bounded issuance a caller — the
2509
+ `delegated_admin` org role, capped so it cannot mint authority (#3697)
2510
+
2511
+ D8 authorizes invitation _placement_ against the issuer's `adminScope`
2512
+ (ADR-0090 D12), so a delegated plant admin may invite only into their own
2513
+ subtree. That gate is implemented, unit-proven and reachable — but no principal
2514
+ could reach it in a state where it did anything:
2515
+
2516
+ - better-auth grants `invitation: ["create"]` to `owner` and `admin` only
2517
+ (`memberAc` holds `invitation: []`, which every other registered role
2518
+ inherits);
2519
+ - under a wall-enforcing posture, owners and admins are auto-elevated to
2520
+ `organization_admin` (`auto-org-admin-grant.ts`), which carries the wildcard
2521
+ `modifyAllRecords` that makes `isTenantAdmin()` true — and the gate
2522
+ short-circuits on tenant admins.
2523
+
2524
+ The two sets were disjoint. Issuance placement was bounded by the Layer 0 org
2525
+ wall (real, and correct) but never by `adminScope`, so D8's motivating story —
2526
+ "a plant admin invites into their own subtree without a platform admin
2527
+ finishing the job" — could not happen.
2528
+
2529
+ **Two pieces, and they only ship together.**
2530
+
2531
+ **1. The role.** `delegated_admin` is now registered with the organization
2532
+ plugin as `memberAc.statements` plus `invitation: ["create"]` — the one
2533
+ membership grade that may reach `/organization/invite-member` without being an
2534
+ org admin. Deliberately _not_ `invitation: ["cancel"]`: better-auth's cancel
2535
+ route checks the permission with no inviterId attribution, so it would mean
2536
+ "cancel anyone's pending invitation in the org".
2537
+
2538
+ The role carries no ObjectStack authority by construction — `mapMembershipRole`
2539
+ passes it through as a position name, and with no `sys_position_permission_set`
2540
+ binding that name resolves to nothing. Role = _can reach the endpoint_;
2541
+ `adminScope` = _what the endpoint permits_.
2542
+
2543
+ `sys_member.role` and `sys_invitation.role` each gain `delegated_admin` as a
2544
+ fourth option. Those selects are **enforced on write** — better-auth's own
2545
+ invitation and membership inserts are validated like any other row — so
2546
+ registering the role with the org plugin without listing it in both would have
2547
+ produced a role nobody could hold and nobody could hand out
2548
+ (`ValidationError: role must be one of: owner, admin, member`). That is exactly
2549
+ how the end-to-end regression caught it, twice; neither unit test could. The
2550
+ three non-English translation bundles carry the English label for the new option
2551
+ until localized.
2552
+
2553
+ **2. The role cap**, in the framework's own `beforeCreateInvitation` hook,
2554
+ beside the D8 placement gate. Registering the role alone would have been a
2555
+ four-step privilege escalation: better-auth's only role-level cap on _what role
2556
+ you may invite someone as_ is its `creatorRole` check (default `owner`), which
2557
+ blocks inviting an **owner** but not an **admin** — and an accepted `admin`
2558
+ membership is auto-elevated to `organization_admin` → `isTenantAdmin()`. A
2559
+ subtree-scoped delegate could have manufactured a tenant admin, with every
2560
+ existing defense off the path (`sys_member` is not a `GOVERNED_OBJECT`, and the
2561
+ acceptance-time membership write runs under better-auth's context, not the
2562
+ issuer's).
2563
+
2564
+ The cap refuses an invitation whose role outranks the issuer's own, and
2565
+ restricts a below-admin issuer to plain `member` — not merely "not admin/owner",
2566
+ because an app-registered role projects into `current_user.positions` and may be
2567
+ bound to permission sets, making it a capability channel too. A delegate's
2568
+ channel for capability is the invitation's _placement_ intent, which the D12
2569
+ gate allowlists position-by-position. The cap applies to every invitation,
2570
+ placement-carrying or not (the escalation is independent of placement), and
2571
+ fails closed: an issuer role that cannot be resolved confers nothing above a
2572
+ plain member.
2573
+
2574
+ **What changes for deployments.** One new class of principal exists: members
2575
+ holding the `delegated_admin` org role, who can invite into the org — as
2576
+ `member` only, into the subtree their `adminScope` allows. It is opt-in twice
2577
+ over (someone must set the membership role _and_ grant an adminScope set), so a
2578
+ default deployment changes not at all. Org owners and admins are unaffected.
2579
+
2580
+ Also exported: `MEMBERSHIP_ROLE_DELEGATED_ADMIN` from `@objectstack/spec`, so
2581
+ console and control-plane surfaces name the role from one place.
2582
+
2583
+ - 9dcc0ae: fix(automation): array-form flow `triggerType` fails loudly instead of silently never firing (#3481)
2584
+
2585
+ An array `triggerType` on a flow start node — the shape an author (or an AI
2586
+ authoring pass) naturally reaches for to fire on more than one event, e.g.
2587
+
2588
+ ```ts
2589
+ config: { objectName: 'app_task', triggerType: ['record-after-create', 'record-after-delete'] }
2590
+ ```
2591
+
2592
+ was accepted everywhere and armed nowhere. Multi-event unions are deliberately
2593
+ unsupported (only the single tokens plus the `record-after-write` create-OR-update
2594
+ union exist — see #3457), but nothing said so: `defineFlow` passed the array
2595
+ (start-node `config` is an open record), the engine's `typeof === 'string'` check
2596
+ folded it to no trigger and misclassified the flow as **manual**, so it never
2597
+ entered the trigger-binding audit, and the flow-trigger-readiness lint used the
2598
+ same `typeof` narrowing and produced no finding. The flow bound to nothing and
2599
+ never fired, with zero output at any layer — the same silent-never-fire class as
2600
+ #3427 / #3472, and the last authoring shape still slipping past every guard.
2601
+
2602
+ This is a **defensive** fix — arrays remain unsupported; they now fail loudly:
2603
+
2604
+ - **lint** (`validate-flow-trigger-readiness`): an array `triggerType` containing
2605
+ any `record-*` element now yields a `flow-trigger-unknown-event` warning at
2606
+ `os validate` time, steering to `record-after-write` (for created-or-updated) or
2607
+ one flow per event.
2608
+ - **engine** (`resolveTriggerBinding`): such an array is routed to the
2609
+ `record_change` trigger — exactly as an unmappable single token is — instead of
2610
+ being folded to a manual flow, so it reaches the trigger's bind-time rejection.
2611
+ - **trigger** (`record-change`): the bind-time rejection detects the array shape
2612
+ and emits a targeted warning (naming the flow, pointing at `record-after-write`
2613
+ and #3457) rather than the generic unknown-token line.
2614
+
2615
+ - 5b89711: feat(spec,lint): freeze the `{current_user_id}` filter vocabulary and fail the build on unresolvable placeholders (#3574)
2616
+
2617
+ A dashboard widget filtered on `{current_user}` rendered `0`. Not an error — a
2618
+ zero, indistinguishable from a metric that is legitimately empty, with nothing
2619
+ in the console or the server log. `service_dashboard.my_open_cases_by_priority`
2620
+ in the HotCRM template had shipped broken this way since the day it was
2621
+ written.
2622
+
2623
+ The token had never been part of the contract. Date macros were frozen in
2624
+ `date-macros.zod.ts` with a spec vocabulary, a lint-usable predicate, and a
2625
+ single client resolver; `{current_user_id}` had only prose in an `app.zod.ts`
2626
+ JSDoc and three ad-hoc client implementations that each handled one surface's
2627
+ filter shape. Nothing could tell an author their token was wrong.
2628
+
2629
+ - **`@objectstack/spec`** — new `data/context-tokens.zod.ts` freezing
2630
+ `CONTEXT_TOKENS` (`current_user_id`, `current_org_id`) as the sibling of
2631
+ `DATE_MACRO_TOKENS`, with `isContextToken` / `isKnownFilterToken` /
2632
+ `classifyFilterToken` and a `CONTEXT_TOKEN_SUGGESTIONS` near-miss table. The
2633
+ module documents what the tokens are _not_: presentation scope, never an
2634
+ access boundary — that is RLS, which uses the unrelated `current_user.id`
2635
+ expression root.
2636
+ - **`@objectstack/lint`** — new `validateFilterTokens` (rule
2637
+ `filter-token-unknown`, severity `error`). It walks `filter` / `filters` /
2638
+ `runtimeFilter` subtrees across dashboards, objects, views, reports,
2639
+ datasets, pages and apps, and reports any placeholder that resolves in
2640
+ neither vocabulary. It scans for filter _keys_ rather than enumerating known
2641
+ surfaces, so a new surface following the convention is covered the day it
2642
+ ships — enumerating surfaces is how the dashboard was missed in the first
2643
+ place. Navigation `recordId` / `params` are deliberately out of scope: they
2644
+ resolve `AppContextSelector` ids, which are meaningless in a filter.
2645
+ - **`@objectstack/cli`** — the gate runs in `os validate` and `os compile`.
2646
+
2647
+ It is an error rather than a warning because of who authors this metadata. An
2648
+ AI reads a query returning `0` as a correct answer and builds on it; its
2649
+ correction loop is author → validate → fix, so a diagnostic only reaches it if
2650
+ it can fail the build. The three spellings the suggestion table covers —
2651
+ `{current_user}`, `{user_id}`, `{organization_id}` — are each correct
2652
+ _somewhere else_ in the platform, which is exactly why authors reach for them.
2653
+
2654
+ Also fixes a `ViewSchema` JSDoc example that documented `{user_id}`, a token
2655
+ that resolves nowhere.
2656
+
2657
+ - de9af8a: fix(automation,objectql): a filter that loses a condition must not run (#3810)
2658
+
2659
+ Three related holes, all of which end in "the query matched rows the author
2660
+ excluded".
2661
+
2662
+ **1. A flow filter could silently widen to match everything.**
2663
+
2664
+ The flow template interpolator expresses "this token did not resolve" as
2665
+ `undefined`. In a message that renders as empty text — harmless. In a FILTER it
2666
+ removes the condition, and a removed condition matches MORE rows. When it was
2667
+ the only condition, `{ owner: '{record.ownr}' }` became `{}`, and `{}` handed to
2668
+ `deleteMany` is every row in the table.
2669
+
2670
+ So one mistyped field name in a `delete_record` node silently emptied the
2671
+ object. Reproduced with all four causes: a typo (`{record.ownr}`), an input the
2672
+ run never received, a lookup hop (`{record.account.name}` — the trigger record
2673
+ carries a scalar id), and a filter placeholder.
2674
+
2675
+ `get_record` / `update_record` / `delete_record` now refuse to execute when
2676
+ interpolation erased any authored condition, naming the offending template. The
2677
+ guard keys on LOSS, not emptiness: an author who deliberately wrote no filter is
2678
+ unaffected, and losing one of two conditions still fails, because widening from
2679
+ "my open records" to "all open records" is the same class of bug.
2680
+
2681
+ **2. Filter placeholders never reached the engine that resolves them.**
2682
+
2683
+ `config.filter` is where two `{…}` dialects meet — the flow template dialect
2684
+ (`{record.owner}`) and the filter placeholder dialect (`{current_year_start}`,
2685
+ `{current_user_id}`, resolved by `resolveFilterTokens()`). Evaluation order
2686
+ picked the winner by accident: the flow interpolator ran first, found no flow
2687
+ variable by that name, and erased it.
2688
+
2689
+ `interpolateFilter()` hands that position back to the dialect that owns it — a
2690
+ whole-string token that no flow variable resolves and that IS a recognised
2691
+ placeholder passes through verbatim for the engine to expand. Flow variables
2692
+ keep precedence, so a template that works today cannot change meaning.
2693
+
2694
+ **3. The engine resolved placeholders on reads but not on writes.**
2695
+
2696
+ `resolveFilterTokens()` reached `find`/`findOne`/`count`/`aggregate` only. So
2697
+ the SAME filter selected different rows depending on the verb: `find({ owner:
2698
+ '{current_user_id}' })` matched the signed-in user's rows, while
2699
+ `update`/`delete` compared the literal token text and matched none — a flow that
2700
+ previewed with one and acted with the other operated on two different row sets.
2701
+ This is the #3106 shape one layer down: the evaluator existed, only some call
2702
+ sites reached it.
2703
+
2704
+ `update` and `delete` now resolve too, BEFORE the by-id fast path claims a
2705
+ scalar `where.id` (otherwise an unresolved `{current_user_id}` would be bound as
2706
+ the primary key itself). Caller options are never mutated.
2707
+
2708
+ - 5524f84: feat(automation): opt-in single-hop lookup expansion for record-change flow templates (#3475)
2709
+
2710
+ A record-change flow can now declare `expand: ['<lookup_field>', …]` on its start
2711
+ node config so node templates resolve `{record.<lookup>.<field>}` (e.g.
2712
+ `{record.account.name}` in a notify title, closing the #3426 gap for lookups).
2713
+
2714
+ The engine re-reads the declared relations AFTER identity resolution, as the
2715
+ run's OWN principal — `resolveRunDataContext` honors `runAs`, so a `runAs:'user'`
2716
+ run reads the referenced object as the **triggering user** (its RLS/FLS enforced)
2717
+ rather than system-elevated. This is what made expansion unsafe to do in the
2718
+ trigger's re-read (which has no resolved grants) and is why it lives in the
2719
+ engine (new `AutomationEngine.setRecordExpander`, bridged by the plugin to the
2720
+ same data engine the CRUD nodes use).
2721
+
2722
+ Only the declared relation keys are grafted onto the run record, so bare lookup
2723
+ ids and `multiple` lookup arrays (#1872) on other relations — and the formula
2724
+ fields the trigger already hydrated — are untouched. Opt-in ⇒ zero cost when
2725
+ unused; best-effort ⇒ a re-read failure leaves the record unexpanded and never
2726
+ breaks the flow.
2727
+
2728
+ The `os validate` lint rule `flow-template-lookup-traversal` (#3426/#3472) is now
2729
+ suppressed for a relation once the flow declares it in `config.expand`.
2730
+
2731
+ - 169b58a: fix(#3426): build-time warning for unresolvable flow template paths + guard the formula re-read
2732
+
2733
+ Two follow-ups to #3426 (the formula/lookup `{record.<path>}` template gap that #3445 began closing).
2734
+
2735
+ **Build-time signal (the issue's fallback ask).** `os validate` now flags a
2736
+ record-change flow node whose `{record.<path>}` template cannot resolve —
2737
+ turning the previous SILENT blank into an advisory warning. Two cases, via the
2738
+ new `@objectstack/lint` rule `validateFlowTemplatePaths`:
2739
+
2740
+ - `flow-template-unknown-field` — `{record.<x>}` where `<x>` is neither a
2741
+ declared field nor a system column (a typo like `{record.full_naem}`).
2742
+ - `flow-template-lookup-traversal` — `{record.<lookup>.<field>}`, a cross-object
2743
+ hop the seeded record carries only as a scalar id (still unsupported; tracked
2744
+ on #3426).
2745
+
2746
+ Deliberately quiet: formula fields, bare lookup ids, numeric indexes into
2747
+ `multiple` lookups (#1872), `json` sub-paths, and system columns are NOT flagged,
2748
+ and flows bound to an object this stack does not define are skipped (no schema to
2749
+ compare against).
2750
+
2751
+ **Hydration re-read guards.** The `trigger-record-change` computed-field re-read
2752
+ (#3445) is now (a) skipped when the object declares no `formula` field — the only
2753
+ thing it adds — via the engine's optional `getObjectConfig`, and (b) memoized per
2754
+ write on the shared HookContext, so N flows on one written record share ONE
2755
+ re-read instead of N. Any uncertainty falls back to the prior unconditional
2756
+ re-read (correctness over the optimization).
2757
+
2758
+ - 7f4a8a1: fix(lint): flag every never-firing `record-`-prefixed trigger token, incl. `record-change` (#3427)
2759
+
2760
+ Generalizes the `flow-trigger-unknown-event` rule: it now flags ANY `record-`-prefixed
2761
+ `triggerType` that is not a valid firing token
2762
+ (`record-{before,after}-{create,insert,update,delete,write}`) — not just
2763
+ `record-(before|after)-<bad-op>` typos. This closes the `record-change` trap: the
2764
+ engine routes `record-change` ("Record changed (any)") to the record-change trigger,
2765
+ which maps it to no hook so it never fires — now caught at `os validate` time instead
2766
+ of only a runtime warn. Also covers bad-phase tokens like `record-during-update`.
2767
+ Warning severity, unchanged.
2768
+
2769
+ - 0045682: feat(auth)!: membership grade is not a capability channel — the `sys_member.role`
2770
+ vocabulary is closed (ADR-0108, #3723)
2771
+
2772
+ `sys_member.role` answers "what is your standing in this organization". It does
2773
+ not answer "what may you do" — that is what positions are for. One column was
2774
+ answering both.
2775
+
2776
+ `resolve-authz-context` projects EVERY value stored in `sys_member.role` into
2777
+ `current_user.positions`, alongside the rows read from `sys_user_position`. So a
2778
+ business role handed out through the membership role _was_ capability — granted
2779
+ with none of the position system's controls: no `granted_by`, no ADR-0091
2780
+ validity window, no BU-subtree check, no `assignablePermissionSets` allowlist.
2781
+ That is what ADR-0057 D4 ruled out ("feed the names to better-auth **only** so
2782
+ invitations are accepted — **never as the authority for RBAC**"), what
2783
+ ADR-0090 D3's word ban restates (distribution = `position`), and what
2784
+ ADR-0095 D3 keeps out of the enforcement path.
2785
+
2786
+ The vocabulary is therefore closed to the four framework-owned names:
2787
+ `owner` / `admin` / `delegated_admin` / `member`.
2788
+
2789
+ **BREAKING — `additionalOrgRoles` is removed** from `AuthManagerOptions` and
2790
+ `AuthPluginOptions`, together with `plugin-auth/src/org-roles.ts` in full
2791
+ (`collectStackOrgRoles`, `collectRegisteredOrgRoles`,
2792
+ `normalizeAdditionalOrgRoles`, `membershipRoleOptions`,
2793
+ `withMembershipRoleOptions`, `membershipRoleLabel`, `orgRoleNames`,
2794
+ `MEMBERSHIP_ROLE_OBJECTS`, `OrgRoleDescriptor`, `OrgRoleInput`,
2795
+ `OrgRoleLogger`) and the `kernel:ready` derivation hook that fed them. From
2796
+ `@objectstack/spec`, `MEMBERSHIP_ROLE_NAME_PATTERN` and
2797
+ `MEMBERSHIP_ROLE_NAME_MIN_LENGTH` are removed — they existed only to validate
2798
+ app-supplied names. A TypeScript error is the intended failure: an option that
2799
+ is silently ignored is `declared ≠ enforced` one more time.
2800
+
2801
+ FROM → TO:
2802
+
2803
+ ```diff
2804
+ - new AuthPlugin({ additionalOrgRoles: ['sales_rep'] })
2805
+ + new AuthPlugin({ /* nothing — declare `sales_rep` as a position */ })
2806
+
2807
+ - POST /organization/invite-member { email, role: 'sales_rep' }
2808
+ + POST /organization/invite-member { email, role: 'member',
2809
+ + businessUnitId, positions: ['sales_rep'] }
2810
+ ```
2811
+
2812
+ For an existing member, assign the position through `sys_user_position` (the
2813
+ governed write path). Invitation placement (ADR-0105 D8) is the one-step
2814
+ admission flow: issuance is authorized against the issuer's `adminScope` by
2815
+ dry-running `DelegatedAdminGate`, and acceptance writes real
2816
+ `sys_user_position` rows with a `granted_by` stamp. It reaches **further** than
2817
+ what it replaces — a delegated admin may use it within their subtree, where the
2818
+ membership-role route was open to org admins only (the invitation role cap holds
2819
+ anyone below admin grade to plain `member`).
2820
+
2821
+ An invitation naming an app role now fails at better-auth's door with
2822
+ `ROLE_NOT_FOUND`, before any row is written.
2823
+
2824
+ This reverses two changesets that were never consumed into a release
2825
+ (`app-org-roles-storable`, `auth-org-roles-self-derived`), so no published
2826
+ version ever offered the behaviour; both are removed rather than shipped and
2827
+ retracted in the same changelog. A pre-existing deployment could only have
2828
+ stored a custom value by direct DB write.
2829
+
2830
+ Also derived rather than transcribed: `@objectstack/lint`'s `MEMBERSHIP_TIERS`
2831
+ now reads `BUILTIN_MEMBERSHIP_ROLES` from `@objectstack/spec`. The hand-kept
2832
+ copy carried `guest`, which the `sys_member.role` select has never offered — an
2833
+ approver authored as `{ type: 'org_membership_level', value: 'guest' }`
2834
+ resolved to nobody and the lint whose whole job is to catch that stayed silent.
2835
+
2836
+ - 29ff3c2: feat(lint): warn on replay-unsafe `mode: 'insert'` seed datasets (#3434 follow-up)
2837
+
2838
+ Seeds are replayed — they re-load on every dev-server boot and every package
2839
+ re-publish, not applied once — so `mode: 'insert'` (the loader's one mode with
2840
+ no existing-row check) duplicates its table on every restart. That footgun
2841
+ shipped undetected until #3434 (showcase memberships grew 3 → 6 → 9).
2842
+
2843
+ Adds `validateSeedReplaySafety` to `@objectstack/lint` (a pure `(stack) => Finding[]`
2844
+ rule, ADR-0019) and wires it into `os validate` / `os lint`. Every `data[]` seed
2845
+ declared with `mode: 'insert'` now gets an advisory warning that points at the
2846
+ idempotent modes (`ignore` / `upsert`) and the `externalId` to match on — a
2847
+ single natural-key field, or a COMPOSITE list of fields for a join / junction
2848
+ table with no single key (`['team', 'project']`, the support #3434 added). It
2849
+ catches the mistake at authoring time instead of on the second boot.
2850
+
2851
+ - 95829a0: feat(lint): warn on seed values outside an object's declared state machine (#3433 follow-up)
2852
+
2853
+ #3433 exempts seed writes from the `state_machine` validation rule, so a seeded
2854
+ status the FSM does not declare is no longer rejected at write time. A field-level
2855
+ `select` still catches a value outside its `options`, but a `state_machine` on a
2856
+ free-text field — or a value that is a valid option yet not a declared FSM state —
2857
+ now sails through silently: the exemption is a deliberate but blind back door.
2858
+
2859
+ `validateSeedStateMachine` (a pure `(stack) => Finding[]` rule, run from
2860
+ `os validate` / `os lint`, symmetric with the replay-safety rule from #3434)
2861
+ re-adds that safety net at author time. It flags any seed record whose
2862
+ `state_machine`-governed field carries a value outside the machine's declared
2863
+ states — the union of `initialStates`, the transition-map keys, and the transition
2864
+ targets. Advisory (`warning`): the exemption itself is legitimate, so the fix-it
2865
+ points at either adding the state to the machine or correcting the typo, not a hard
2866
+ build failure. New rule id: `seed-value-outside-state-machine`.
2867
+
2868
+ - 57bab76: Typed `decisionOutputs` declarations (#3447 follow-up). A `decisionOutputs` entry may now be `{ key, label?, type: 'text' | 'user' | 'department' | 'position' | 'team', multiple? }` alongside the bare-string form — a typed entry tells the decision UI to render the matching record picker (id values; `multiple` collects an id array) instead of free text, turning "paste user ids" into "pick people". The type shapes only the input widget: the runtime whitelist works by `key` either way, via the new `normalizeDecisionOutputs` helper exported from `@objectstack/spec/automation` — the single reader of the union shape shared by the service, the request read, and `os lint`. The request read now carries `decision_output_defs` (normalized declarations) alongside the version-skew-safe `decision_outputs` key list.
2869
+ - Updated dependencies [50616d9]
2870
+ - Updated dependencies [08b5a3d]
2871
+ - Updated dependencies [d99aeb3]
2872
+ - Updated dependencies [4727eb8]
2873
+ - Updated dependencies [f63cd09]
2874
+ - Updated dependencies [fa3d0cf]
2875
+ - Updated dependencies [af5a224]
2876
+ - Updated dependencies [71f76e1]
2877
+ - Updated dependencies [37b1346]
2878
+ - Updated dependencies [99736a0]
2879
+ - Updated dependencies [fe67e34]
2880
+ - Updated dependencies [fdb4f50]
2881
+ - Updated dependencies [1bd5652]
2882
+ - Updated dependencies [14252d3]
2883
+ - Updated dependencies [7fb436c]
2884
+ - Updated dependencies [879ea13]
2885
+ - Updated dependencies [201b31f]
2886
+ - Updated dependencies [e2616e0]
2887
+ - Updated dependencies [6fdc5c6]
2888
+ - Updated dependencies [8b9d71e]
2889
+ - Updated dependencies [33f5e23]
2890
+ - Updated dependencies [259af21]
2891
+ - Updated dependencies [587fc91]
2892
+ - Updated dependencies [1986594]
2893
+ - Updated dependencies [ad4af62]
2894
+ - Updated dependencies [d44dbfa]
2895
+ - Updated dependencies [474fe39]
2896
+ - Updated dependencies [0bc685a]
2897
+ - Updated dependencies [b949059]
2898
+ - Updated dependencies [be1c52c]
2899
+ - Updated dependencies [c5ff96d]
2900
+ - Updated dependencies [84e7be9]
2901
+ - Updated dependencies [a6c3f38]
2902
+ - Updated dependencies [debc23a]
2903
+ - Updated dependencies [0f8ad09]
2904
+ - Updated dependencies [8f9689f]
2905
+ - Updated dependencies [57a3bb3]
2906
+ - Updated dependencies [5f9a987]
2907
+ - Updated dependencies [db02d47]
2908
+ - Updated dependencies [0bfdf46]
2909
+ - Updated dependencies [376a061]
2910
+ - Updated dependencies [7c7e246]
2911
+ - Updated dependencies [f35cdc5]
2912
+ - Updated dependencies [9ea2bc5]
2913
+ - Updated dependencies [c2d9098]
2914
+ - Updated dependencies [a227ed7]
2915
+ - Updated dependencies [9613396]
2916
+ - Updated dependencies [e47b342]
2917
+ - Updated dependencies [4ed7ed4]
2918
+ - Updated dependencies [2fa4ca1]
2919
+ - Updated dependencies [f5a2320]
2920
+ - Updated dependencies [deb538f]
2921
+ - Updated dependencies [5b89711]
2922
+ - Updated dependencies [0c8a22f]
2923
+ - Updated dependencies [763931e]
2924
+ - Updated dependencies [de9af8a]
2925
+ - Updated dependencies [c4df271]
2926
+ - Updated dependencies [a41ba5c]
2927
+ - Updated dependencies [189854c]
2928
+ - Updated dependencies [0e3a226]
2929
+ - Updated dependencies [1d4756e]
2930
+ - Updated dependencies [720c5ad]
2931
+ - Updated dependencies [a8d1e24]
2932
+ - Updated dependencies [41642b0]
2933
+ - Updated dependencies [4cca74c]
2934
+ - Updated dependencies [88ef03e]
2935
+ - Updated dependencies [9e2caf3]
2936
+ - Updated dependencies [81ce41a]
2937
+ - Updated dependencies [85e1e4e]
2938
+ - Updated dependencies [dac6a08]
2939
+ - Updated dependencies [394b7a1]
2940
+ - Updated dependencies [677b591]
2941
+ - Updated dependencies [d77d1b7]
2942
+ - Updated dependencies [5b79a34]
2943
+ - Updated dependencies [c757854]
2944
+ - Updated dependencies [0045682]
2945
+ - Updated dependencies [2a5f04a]
2946
+ - Updated dependencies [4f740b0]
2947
+ - Updated dependencies [67452d1]
2948
+ - Updated dependencies [0fc6219]
2949
+ - Updated dependencies [605e190]
2950
+ - Updated dependencies [c6c59f1]
2951
+ - Updated dependencies [b0e78a8]
2952
+ - Updated dependencies [f31cc8d]
2953
+ - Updated dependencies [f343dc4]
2954
+ - Updated dependencies [8269e32]
2955
+ - Updated dependencies [74f7339]
2956
+ - Updated dependencies [a6c35a2]
2957
+ - Updated dependencies [c2f1002]
2958
+ - Updated dependencies [f163028]
2959
+ - Updated dependencies [f07808c]
2960
+ - Updated dependencies [7ffc3d3]
2961
+ - Updated dependencies [88346ba]
2962
+ - Updated dependencies [4631592]
2963
+ - Updated dependencies [32ff033]
2964
+ - Updated dependencies [5ac93d4]
2965
+ - Updated dependencies [93f267f]
2966
+ - Updated dependencies [0024abf]
2967
+ - Updated dependencies [acbf364]
2968
+ - Updated dependencies [7687f7b]
2969
+ - Updated dependencies [1659072]
2970
+ - Updated dependencies [abceb0d]
2971
+ - Updated dependencies [0c302a7]
2972
+ - Updated dependencies [6633337]
2973
+ - Updated dependencies [f00d8d4]
2974
+ - Updated dependencies [503be86]
2975
+ - Updated dependencies [cde1975]
2976
+ - Updated dependencies [0bc685a]
2977
+ - Updated dependencies [11949fc]
2978
+ - Updated dependencies [b098b0e]
2979
+ - Updated dependencies [4d00b13]
2980
+ - Updated dependencies [57bab76]
2981
+ - Updated dependencies [b90086a]
2982
+ - Updated dependencies [b95577a]
2983
+ - Updated dependencies [83c161f]
2984
+ - Updated dependencies [d8c4957]
2985
+ - Updated dependencies [f24cb83]
2986
+ - Updated dependencies [5dbbb92]
2987
+ - Updated dependencies [69f1dfd]
2988
+ - @objectstack/spec@17.0.0-rc.0
2989
+ - @objectstack/formula@17.0.0-rc.0
2990
+ - @objectstack/sdui-parser@17.0.0-rc.0
2991
+
2992
+ ## 16.1.0
2993
+
2994
+ ### Minor Changes
2995
+
2996
+ - fa006fb: Validate dashboard filter field-existence at build time (extend ADR-0021, #3365).
2997
+
2998
+ `validateWidgetBindings` now checks that every dashboard-level filter (`dateRange`
2999
+
3000
+ - each `globalFilters[]`) resolves to a real field on each bound widget's dataset
3001
+ object. Since #2501 wired these filters into every widget's analytics query, a
3002
+ filter field absent on a widget's object — e.g. a `dateRange` bound to
3003
+ `close_date` inherited by an account/contact widget over a different object —
3004
+ emitted invalid SQL (`no such column: close_date`) and crashed the widget at
3005
+ render time. That build-decidable invariant previously escaped `os validate` /
3006
+ `os build` and failed only when a user opened the dashboard.
3007
+
3008
+ It now fails the build (new rule `dashboard-filter-field-unknown`) with a message
3009
+ naming the dashboard, widget, filter, field, and object, unless the widget opts
3010
+ out via `filterBindings: { <name>: false }` or re-targets to an existing field —
3011
+ mirroring the field-existence invariant ADR-0032 enforces for CEL references.
3012
+ Effective-field resolution matches the runtime (`filterBindings` re-target /
3013
+ opt-out, legacy `targetWidgets` allow-list, filter default). Registry-injected
3014
+ system fields (e.g. `created_at`, the `dateRange` default) and objects outside
3015
+ the validated stack never false-positive.
3016
+
3017
+ - db160dd: Flag dead action/route references in dashboard header & widget actions (ADR-0049 for references, #3367).
3018
+
3019
+ `os validate` / `os build` now run a new `validateDashboardActionRefs` gate over every dashboard `header.actions[]` and widget `actionUrl`:
3020
+
3021
+ - `actionType: 'script' | 'modal'` — **error** unless `actionUrl` resolves to a defined action (`stack.actions` or an object's `actions`). `modal` also resolves via the runtime `<verb>_<object>` convention (`create_/new_/add_/edit_/update_` + a real object) and bare object names. A dangling target ships a button that renders and silently does nothing on click — a false affordance, exactly the "declared ≠ enforced" gap ADR-0049 closes, applied to references.
3022
+ - `actionType: 'url'` — **warning** when a relative in-app path names a `objects/reports/dashboards/pages/views` route whose target does not exist in the stack. External URLs, interpolated (`${…}`) targets, and opaque routes are skipped to keep false positives near zero.
3023
+
3024
+ ### Patch Changes
3025
+
3026
+ - Updated dependencies [9e45b63]
3027
+ - @objectstack/spec@16.1.0
3028
+ - @objectstack/formula@16.1.0
3029
+ - @objectstack/sdui-parser@16.1.0
3030
+
3031
+ ## 16.0.0
3032
+
3033
+ ### Minor Changes
3034
+
3035
+ - 3a18b60: feat(approvals): rename the `role` approver type to `org_membership_level` (#3133)
3036
+
3037
+ `ApproverType.role` was the last platform surface projecting the reserved word
3038
+ "role" (ADR-0090 D3). It is not covered by D3's better-auth exception: that
3039
+ exception protects better-auth's own `sys_member.role` **column**, which we do
3040
+ not own — `ApproverType` is our own enum, an authoring surface, and D3 mandates
3041
+ that the projection of that concept is spelled `org_membership_level` and
3042
+ labelled "organization membership", **never "role"**.
3043
+
3044
+ The sentence licensing the leak was also false: ADR-0090 D3 claims
3045
+ `sys_member.role` is "already relabelled `org_membership_level` in the platform
3046
+ projection", but `org_membership_level` existed nowhere in the codebase and
3047
+ ADR-0057 D7 lists that relabel under "Deferred (evidence-gated, P4)". The
3048
+ projection never landed, so the word reached authors.
3049
+
3050
+ The name manufactured a real, silent failure — "hotcrm class": every other
3051
+ surface renamed to `position` (`sys_role`, `ShareRecipientType.role`,
3052
+ `ctx.roles[]`), so `{ type: 'role', value: 'sales_manager' }` reads as the
3053
+ legacy spelling of a position. It resolves against the membership tier, finds
3054
+ no member row, falls back to an inert `role:sales_manager` literal, and the
3055
+ request waits forever on an approver that cannot exist.
3056
+
3057
+ - **spec**: `ApproverType` gains `org_membership_level`; `role` stays as a
3058
+ deprecated alias for one window (a published 15.x flow keeps loading) with
3059
+ `DEPRECATED_APPROVER_TYPES` + `canonicalApproverType()` as the single source
3060
+ for the mapping. Removed in the next major.
3061
+ - **plugin-approvals**: resolves on the canonical type and warns on the
3062
+ deprecated spelling. The `type:value` fallback literal keeps the **authored**
3063
+ spelling — stored `sys_approval_approver` rows and `pending_approvers` slots
3064
+ from 15.x carry `role:<v>`, and rewriting it would orphan them.
3065
+ - **lint**: `approval-role-not-membership-tier` → `approval-approver-not-membership-tier`
3066
+ (the rule id carried the reserved word too), plus a new
3067
+ `approval-approver-type-deprecated`. The two are mutually exclusive: a bad
3068
+ _value_ wins, because prescribing `org_membership_level` for a position name
3069
+ would be wrong advice — the fix there is `position`.
3070
+
3071
+ Authoring `type: 'role'` keeps working and now says so out loud. Rewrite it as
3072
+ `org_membership_level`; if the value is an org position, the fix is `position`.
3073
+
3074
+ - 2ea08ee: Flow trigger observability — kill the four-layer silence around record-change flows that never fire (2026-07-17 third-party eval).
3075
+
3076
+ A misauthored auto-launched flow (wrong `objectName`, missing `requires: ['automation','triggers']`, failing start condition) produced ZERO output at every layer: the engine's own registration/binding logs land inside the CLI's boot-quiet stdout window (which swallows debug/info/warn — only error/fatal reach stderr), and each "didn't happen" path was itself silent. Fixes:
3077
+
3078
+ - **Startup banner `Flows:` section** (`os serve`/`os dev`/`os start`): flow count, bound-to-trigger count, registered trigger types, draft count — plus loud `⚠` lines for flows declared with no automation engine enabled (`requires` missing), flows whose trigger type has no registered trigger, and bound record-change flows targeting an unknown object (dead binding). Printed after stdout is restored, so it is immune to the boot-quiet window.
3079
+ - **Trigger-fired run failures now log at ERROR** (stderr — always visible): the automation engine no longer drops the AutomationResult of a trigger-fired execution; condition-evaluation faults and node failures surface with the flow name. Condition-not-met skips stay at debug (high-frequency, intentional).
3080
+ - **`RecordChangeTrigger` probes object existence at bind time** and warns when a flow's `objectName` matches no registered object (exact-name matching), instead of silently arming a hook that can never fire.
3081
+ - **`kernel:bootstrapped` binding audit** in the automation plugin: warns per enabled-but-unbound triggered flow with the reason, and reports registered/bound/draft counts (`AutomationEngine.getTriggerBindingAudit()`, extended `getFlowRuntimeStates()` with `status`/`triggerType`/`object`).
3082
+ - **`os validate` flow-wiring advisories** (`@objectstack/lint` `validateFlowTriggerReadiness`): warns when a record-triggered flow targets an object the stack does not define, and when an auto-triggered flow's status is `draft` (authored or defaulted — draft flows still fire; declare `active` or `obsolete`).
3083
+ - Removed leftover boot-debug writes (`registerApp`/`AppPlugin`/`StandaloneStack`/`AuditPlugin` stderr noise) that previous debugging of this same silence had left behind.
3084
+
3085
+ - ea32ec7: feat(formula,lint): advisory type-soundness warnings for formula/predicate expressions (#1928 tier 4)
3086
+
3087
+ Closes the last open guardrail from #1928. A `Field.formula` or record-scoped
3088
+ predicate that uses a **text or boolean field with an arithmetic (`+ - * / %`)
3089
+ or ordering (`< > <= >=`) operator against a number** faults the runtime
3090
+ overload and silently evaluates to `null` (e.g. `record.title * 2`,
3091
+ `record.is_active + 1`). The build now surfaces this as a **non-blocking
3092
+ warning** with the offending field and a corrective message.
3093
+
3094
+ Honours the ADR-0032 design law — the checker only flags what the runtime
3095
+ would also fail:
3096
+
3097
+ - Number / currency / percent / date / datetime fields are declared `dyn`, so
3098
+ the cases the runtime rescues never warn — `record.amount / 100` (the #1930
3099
+ `registerOperator` fix), `record.due == today()` and numeric-string / ISO-date
3100
+ values (the string-hydration retry), and numeric-coded `select` option values.
3101
+ - Equality (`==` / `!=`) is excluded: a heterogeneous equality is runtime-safe
3102
+ (evaluates to `false`), never a fault.
3103
+
3104
+ New `firstTypeMismatch(source, fieldCelTypes, scope)` export in
3105
+ `@objectstack/formula` (and an optional `fieldTypes` hint on
3106
+ `validateExpression`); `@objectstack/lint`'s `validateStackExpressions` threads
3107
+ each object's field types into every checked site:
3108
+
3109
+ - **record-scoped** sites (`record.<field>`) — formula fields, validation rules,
3110
+ action / hook / sharing predicates;
3111
+ - **flattened** flow / automation conditions (bare `field`) — where flow
3112
+ variables stay `dyn` and are never flagged, and equality stays runtime-safe.
3113
+
3114
+ Warnings are advisory in `objectstack build` / `validate` (fatal only under
3115
+ `--strict`), matching the tier-3 channel.
3116
+
3117
+ - a2795f6: feat(triggers): declarative time-relative trigger — daily sweep instead of fragile date-equality (#1874)
3118
+
3119
+ Time-relative business rules ("alert 60 days before a contract's `end_date`")
3120
+ could only be expressed as a `record_change` flow gated on a date-equality
3121
+ condition like `end_date == daysFromNow(60)`. That predicate is only evaluated
3122
+ when the record _happens to change_, so it fires only if a record is edited on
3123
+ exactly the threshold day — i.e. almost never, unattended. The robust
3124
+ alternative was a hand-written cron + range query that every author
3125
+ re-implemented (contracts `renewal_alert`, hr `document_expiring_soon`,
3126
+ procurement `po_overdue`, …).
3127
+
3128
+ A flow's start node can now declare a `timeRelative` descriptor instead:
3129
+
3130
+ ```ts
3131
+ config: {
3132
+ timeRelative: {
3133
+ object: 'contracts',
3134
+ dateField: 'end_date',
3135
+ offsetDays: [60, 30, 7], // T-minus reminders — fires on each threshold day
3136
+ // — or — withinDays: 30 // "expiring soon" range; negative = overdue lookback
3137
+ filter: { status: 'active' }, // optional, ANDed with the date window
3138
+ },
3139
+ schedule: { type: 'cron', expression: '0 8 * * *' }, // optional; defaults to daily 08:00 UTC
3140
+ }
3141
+ ```
3142
+
3143
+ The new `time_relative` trigger (shipped in `@objectstack/trigger-schedule` as
3144
+ `TimeRelativeTriggerPlugin`) sweeps the object on that schedule and launches the
3145
+ flow **once per matching record**, with the record on the automation context —
3146
+ so the start-node `condition` gate and `{record.<field>}` interpolation work
3147
+ exactly as for a record-change flow. Because the window is evaluated every day,
3148
+ a threshold is never missed regardless of when the record last changed. The
3149
+ discovery query runs as a system operation (RLS-bypassing) and is capped
3150
+ (`maxRecords`, default 1000) so a mis-scoped window can't fan out unboundedly;
3151
+ per-record failures are isolated so one bad row never aborts the sweep.
3152
+
3153
+ The automation engine routes a start node carrying `config.timeRelative` to the
3154
+ `time_relative` trigger (ahead of the plain `schedule` trigger, whose behavior is
3155
+ unchanged), and `os validate` gains readiness checks for the new descriptor
3156
+ (unknown swept object, ambiguous draft status). New authorable spec key:
3157
+ `TimeRelativeTriggerSchema` (`@objectstack/spec/automation`).
3158
+
3159
+ ### Patch Changes
3160
+
3161
+ - 524696a: feat(spec)!: `DashboardWidgetSchema.strict()` — reject undeclared widget keys (framework#3251)
3162
+
3163
+ The ADR-0021 analytics endpoint. `DashboardWidgetSchema` now rejects any
3164
+ undeclared top-level key instead of silently stripping it, moving a whole class
3165
+ of author error (a hallucinated or legacy key that renders as a silent no-op)
3166
+ from fallible human review to deterministic CI. `options: z.unknown()` remains
3167
+ the escape hatch for renderer-specific extras.
3168
+
3169
+ A custom error map names the offending key(s) and, when a key is a removed
3170
+ pre-ADR-0021 inline-analytics key (`object` / `categoryField` / `valueField` /
3171
+ `aggregate`, pivot `rowField` / `columnField`) or an objectui-internal prop
3172
+ (`component`, inline `data`), points the author at the dataset shape
3173
+ (`dataset` + `dimensions` + `values`).
3174
+
3175
+ Recorded as protocol-16 migration `step16`
3176
+ (`dashboard-widget-strict-unknown-keys`), mirroring protocol-15's `step15`
3177
+ strict flip on the form/page schemas (ADR-0089 D3a). The inline-analytics shape
3178
+ itself was already removed at protocol 9 (single-form cutover), so there is no
3179
+ mechanical rewrite — the residue is the strictness, delegated to the author.
3180
+
3181
+ **Breaking:** shipped as `minor` per the launch-window policy (a breaking change
3182
+ does not burn a major while the stack is in lockstep), riding the already-pending
3183
+ 16.0.0 train. The release train's Version-Packages PR must set
3184
+ `PROTOCOL_VERSION = '16.0.0'`; until then `step16` is inert
3185
+ (`composeMigrationChain` caps at `PROTOCOL_MAJOR`).
3186
+
3187
+ `@objectstack/lint` — the `widget-legacy-analytics-shape` /
3188
+ `widget-legacy-analytics-unrenderable` rules are retained as the friendly,
3189
+ suppressible bridge on the raw-config lint/doctor paths (strict preempts them on
3190
+ the schema-parsed compile/validate paths); doc comment updated to explain the
3191
+ interplay.
3192
+
3193
+ - 8923843: Reject view containers that define no views. A flat list-view object (`{ name, label, type, columns, ... }`) parses to an empty `ViewSchema` container because Zod strips unknown keys — zero views register and the Console silently renders nothing. `defineView()` now throws on a zero-view container, and `os validate` gains a `view-container-shape` check (`validateViewContainers` in `@objectstack/lint`) that reports flat or empty `views: []` entries pre-parse with a wrap-it fix hint.
3194
+ - Updated dependencies [f972574]
3195
+ - Updated dependencies [6289ec3]
3196
+ - Updated dependencies [22013aa]
3197
+ - Updated dependencies [3ad3dd5]
3198
+ - Updated dependencies [8efa395]
3199
+ - Updated dependencies [3a18b60]
3200
+ - Updated dependencies [a8aa34c]
3201
+ - Updated dependencies [a3823b2]
3202
+ - Updated dependencies [43a3efb]
3203
+ - Updated dependencies [524696a]
3204
+ - Updated dependencies [6b51346]
3205
+ - Updated dependencies [80273c8]
3206
+ - Updated dependencies [bfa3c3f]
3207
+ - Updated dependencies [5e3301d]
3208
+ - Updated dependencies [46e876c]
3209
+ - Updated dependencies [7125007]
3210
+ - Updated dependencies [158aa14]
3211
+ - Updated dependencies [62a2117]
3212
+ - Updated dependencies [d2723e2]
3213
+ - Updated dependencies [fefcd54]
3214
+ - Updated dependencies [beaf2de]
3215
+ - Updated dependencies [369eb6e]
3216
+ - Updated dependencies [06ff734]
3217
+ - Updated dependencies [b659111]
3218
+ - Updated dependencies [5754a23]
3219
+ - Updated dependencies [6c270a6]
3220
+ - Updated dependencies [668dd17]
3221
+ - Updated dependencies [8abf133]
3222
+ - Updated dependencies [e0859b1]
3223
+ - Updated dependencies [04ecd4e]
3224
+ - Updated dependencies [4d5a892]
3225
+ - Updated dependencies [16cebeb]
3226
+ - Updated dependencies [86d30af]
3227
+ - Updated dependencies [8923843]
3228
+ - Updated dependencies [ea32ec7]
3229
+ - Updated dependencies [a2795f6]
3230
+ - Updated dependencies [f16b492]
3231
+ - Updated dependencies [4b6fde8]
3232
+ - Updated dependencies [2018df9]
3233
+ - Updated dependencies [fc5a3a2]
3234
+ - Updated dependencies [8ff9210]
3235
+ - @objectstack/spec@16.0.0
3236
+ - @objectstack/formula@16.0.0
3237
+ - @objectstack/sdui-parser@16.0.0
3238
+
3239
+ ## 16.0.0-rc.1
3240
+
3241
+ ### Patch Changes
3242
+
3243
+ - Updated dependencies [6289ec3]
3244
+ - Updated dependencies [8efa395]
3245
+ - Updated dependencies [bfa3c3f]
3246
+ - Updated dependencies [7125007]
3247
+ - Updated dependencies [62a2117]
3248
+ - Updated dependencies [06ff734]
3249
+ - @objectstack/spec@16.0.0-rc.1
3250
+ - @objectstack/formula@16.0.0-rc.1
3251
+ - @objectstack/sdui-parser@16.0.0-rc.1
3252
+
3253
+ ## 16.0.0-rc.0
3254
+
3255
+ ### Minor Changes
3256
+
3257
+ - 3a18b60: feat(approvals): rename the `role` approver type to `org_membership_level` (#3133)
3258
+
3259
+ `ApproverType.role` was the last platform surface projecting the reserved word
3260
+ "role" (ADR-0090 D3). It is not covered by D3's better-auth exception: that
3261
+ exception protects better-auth's own `sys_member.role` **column**, which we do
3262
+ not own — `ApproverType` is our own enum, an authoring surface, and D3 mandates
3263
+ that the projection of that concept is spelled `org_membership_level` and
3264
+ labelled "organization membership", **never "role"**.
3265
+
3266
+ The sentence licensing the leak was also false: ADR-0090 D3 claims
3267
+ `sys_member.role` is "already relabelled `org_membership_level` in the platform
3268
+ projection", but `org_membership_level` existed nowhere in the codebase and
3269
+ ADR-0057 D7 lists that relabel under "Deferred (evidence-gated, P4)". The
3270
+ projection never landed, so the word reached authors.
3271
+
3272
+ The name manufactured a real, silent failure — "hotcrm class": every other
3273
+ surface renamed to `position` (`sys_role`, `ShareRecipientType.role`,
3274
+ `ctx.roles[]`), so `{ type: 'role', value: 'sales_manager' }` reads as the
3275
+ legacy spelling of a position. It resolves against the membership tier, finds
3276
+ no member row, falls back to an inert `role:sales_manager` literal, and the
3277
+ request waits forever on an approver that cannot exist.
3278
+
3279
+ - **spec**: `ApproverType` gains `org_membership_level`; `role` stays as a
3280
+ deprecated alias for one window (a published 15.x flow keeps loading) with
3281
+ `DEPRECATED_APPROVER_TYPES` + `canonicalApproverType()` as the single source
3282
+ for the mapping. Removed in the next major.
3283
+ - **plugin-approvals**: resolves on the canonical type and warns on the
3284
+ deprecated spelling. The `type:value` fallback literal keeps the **authored**
3285
+ spelling — stored `sys_approval_approver` rows and `pending_approvers` slots
3286
+ from 15.x carry `role:<v>`, and rewriting it would orphan them.
3287
+ - **lint**: `approval-role-not-membership-tier` → `approval-approver-not-membership-tier`
3288
+ (the rule id carried the reserved word too), plus a new
3289
+ `approval-approver-type-deprecated`. The two are mutually exclusive: a bad
3290
+ _value_ wins, because prescribing `org_membership_level` for a position name
3291
+ would be wrong advice — the fix there is `position`.
3292
+
3293
+ Authoring `type: 'role'` keeps working and now says so out loud. Rewrite it as
3294
+ `org_membership_level`; if the value is an org position, the fix is `position`.
3295
+
3296
+ - 2ea08ee: Flow trigger observability — kill the four-layer silence around record-change flows that never fire (2026-07-17 third-party eval).
3297
+
3298
+ A misauthored auto-launched flow (wrong `objectName`, missing `requires: ['automation','triggers']`, failing start condition) produced ZERO output at every layer: the engine's own registration/binding logs land inside the CLI's boot-quiet stdout window (which swallows debug/info/warn — only error/fatal reach stderr), and each "didn't happen" path was itself silent. Fixes:
3299
+
3300
+ - **Startup banner `Flows:` section** (`os serve`/`os dev`/`os start`): flow count, bound-to-trigger count, registered trigger types, draft count — plus loud `⚠` lines for flows declared with no automation engine enabled (`requires` missing), flows whose trigger type has no registered trigger, and bound record-change flows targeting an unknown object (dead binding). Printed after stdout is restored, so it is immune to the boot-quiet window.
3301
+ - **Trigger-fired run failures now log at ERROR** (stderr — always visible): the automation engine no longer drops the AutomationResult of a trigger-fired execution; condition-evaluation faults and node failures surface with the flow name. Condition-not-met skips stay at debug (high-frequency, intentional).
3302
+ - **`RecordChangeTrigger` probes object existence at bind time** and warns when a flow's `objectName` matches no registered object (exact-name matching), instead of silently arming a hook that can never fire.
3303
+ - **`kernel:bootstrapped` binding audit** in the automation plugin: warns per enabled-but-unbound triggered flow with the reason, and reports registered/bound/draft counts (`AutomationEngine.getTriggerBindingAudit()`, extended `getFlowRuntimeStates()` with `status`/`triggerType`/`object`).
3304
+ - **`os validate` flow-wiring advisories** (`@objectstack/lint` `validateFlowTriggerReadiness`): warns when a record-triggered flow targets an object the stack does not define, and when an auto-triggered flow's status is `draft` (authored or defaulted — draft flows still fire; declare `active` or `obsolete`).
3305
+ - Removed leftover boot-debug writes (`registerApp`/`AppPlugin`/`StandaloneStack`/`AuditPlugin` stderr noise) that previous debugging of this same silence had left behind.
3306
+
3307
+ - ea32ec7: feat(formula,lint): advisory type-soundness warnings for formula/predicate expressions (#1928 tier 4)
3308
+
3309
+ Closes the last open guardrail from #1928. A `Field.formula` or record-scoped
3310
+ predicate that uses a **text or boolean field with an arithmetic (`+ - * / %`)
3311
+ or ordering (`< > <= >=`) operator against a number** faults the runtime
3312
+ overload and silently evaluates to `null` (e.g. `record.title * 2`,
3313
+ `record.is_active + 1`). The build now surfaces this as a **non-blocking
3314
+ warning** with the offending field and a corrective message.
3315
+
3316
+ Honours the ADR-0032 design law — the checker only flags what the runtime
3317
+ would also fail:
3318
+
3319
+ - Number / currency / percent / date / datetime fields are declared `dyn`, so
3320
+ the cases the runtime rescues never warn — `record.amount / 100` (the #1930
3321
+ `registerOperator` fix), `record.due == today()` and numeric-string / ISO-date
3322
+ values (the string-hydration retry), and numeric-coded `select` option values.
3323
+ - Equality (`==` / `!=`) is excluded: a heterogeneous equality is runtime-safe
3324
+ (evaluates to `false`), never a fault.
3325
+
3326
+ New `firstTypeMismatch(source, fieldCelTypes, scope)` export in
3327
+ `@objectstack/formula` (and an optional `fieldTypes` hint on
3328
+ `validateExpression`); `@objectstack/lint`'s `validateStackExpressions` threads
3329
+ each object's field types into every checked site:
3330
+
3331
+ - **record-scoped** sites (`record.<field>`) — formula fields, validation rules,
3332
+ action / hook / sharing predicates;
3333
+ - **flattened** flow / automation conditions (bare `field`) — where flow
3334
+ variables stay `dyn` and are never flagged, and equality stays runtime-safe.
3335
+
3336
+ Warnings are advisory in `objectstack build` / `validate` (fatal only under
3337
+ `--strict`), matching the tier-3 channel.
3338
+
3339
+ - a2795f6: feat(triggers): declarative time-relative trigger — daily sweep instead of fragile date-equality (#1874)
3340
+
3341
+ Time-relative business rules ("alert 60 days before a contract's `end_date`")
3342
+ could only be expressed as a `record_change` flow gated on a date-equality
3343
+ condition like `end_date == daysFromNow(60)`. That predicate is only evaluated
3344
+ when the record _happens to change_, so it fires only if a record is edited on
3345
+ exactly the threshold day — i.e. almost never, unattended. The robust
3346
+ alternative was a hand-written cron + range query that every author
3347
+ re-implemented (contracts `renewal_alert`, hr `document_expiring_soon`,
3348
+ procurement `po_overdue`, …).
3349
+
3350
+ A flow's start node can now declare a `timeRelative` descriptor instead:
3351
+
3352
+ ```ts
3353
+ config: {
3354
+ timeRelative: {
3355
+ object: 'contracts',
3356
+ dateField: 'end_date',
3357
+ offsetDays: [60, 30, 7], // T-minus reminders — fires on each threshold day
3358
+ // — or — withinDays: 30 // "expiring soon" range; negative = overdue lookback
3359
+ filter: { status: 'active' }, // optional, ANDed with the date window
3360
+ },
3361
+ schedule: { type: 'cron', expression: '0 8 * * *' }, // optional; defaults to daily 08:00 UTC
3362
+ }
3363
+ ```
3364
+
3365
+ The new `time_relative` trigger (shipped in `@objectstack/trigger-schedule` as
3366
+ `TimeRelativeTriggerPlugin`) sweeps the object on that schedule and launches the
3367
+ flow **once per matching record**, with the record on the automation context —
3368
+ so the start-node `condition` gate and `{record.<field>}` interpolation work
3369
+ exactly as for a record-change flow. Because the window is evaluated every day,
3370
+ a threshold is never missed regardless of when the record last changed. The
3371
+ discovery query runs as a system operation (RLS-bypassing) and is capped
3372
+ (`maxRecords`, default 1000) so a mis-scoped window can't fan out unboundedly;
3373
+ per-record failures are isolated so one bad row never aborts the sweep.
3374
+
3375
+ The automation engine routes a start node carrying `config.timeRelative` to the
3376
+ `time_relative` trigger (ahead of the plain `schedule` trigger, whose behavior is
3377
+ unchanged), and `os validate` gains readiness checks for the new descriptor
3378
+ (unknown swept object, ambiguous draft status). New authorable spec key:
3379
+ `TimeRelativeTriggerSchema` (`@objectstack/spec/automation`).
3380
+
3381
+ ### Patch Changes
3382
+
3383
+ - 524696a: feat(spec)!: `DashboardWidgetSchema.strict()` — reject undeclared widget keys (framework#3251)
3384
+
3385
+ The ADR-0021 analytics endpoint. `DashboardWidgetSchema` now rejects any
3386
+ undeclared top-level key instead of silently stripping it, moving a whole class
3387
+ of author error (a hallucinated or legacy key that renders as a silent no-op)
3388
+ from fallible human review to deterministic CI. `options: z.unknown()` remains
3389
+ the escape hatch for renderer-specific extras.
3390
+
3391
+ A custom error map names the offending key(s) and, when a key is a removed
3392
+ pre-ADR-0021 inline-analytics key (`object` / `categoryField` / `valueField` /
3393
+ `aggregate`, pivot `rowField` / `columnField`) or an objectui-internal prop
3394
+ (`component`, inline `data`), points the author at the dataset shape
3395
+ (`dataset` + `dimensions` + `values`).
3396
+
3397
+ Recorded as protocol-16 migration `step16`
3398
+ (`dashboard-widget-strict-unknown-keys`), mirroring protocol-15's `step15`
3399
+ strict flip on the form/page schemas (ADR-0089 D3a). The inline-analytics shape
3400
+ itself was already removed at protocol 9 (single-form cutover), so there is no
3401
+ mechanical rewrite — the residue is the strictness, delegated to the author.
3402
+
3403
+ **Breaking:** shipped as `minor` per the launch-window policy (a breaking change
3404
+ does not burn a major while the stack is in lockstep), riding the already-pending
3405
+ 16.0.0 train. The release train's Version-Packages PR must set
3406
+ `PROTOCOL_VERSION = '16.0.0'`; until then `step16` is inert
3407
+ (`composeMigrationChain` caps at `PROTOCOL_MAJOR`).
3408
+
3409
+ `@objectstack/lint` — the `widget-legacy-analytics-shape` /
3410
+ `widget-legacy-analytics-unrenderable` rules are retained as the friendly,
3411
+ suppressible bridge on the raw-config lint/doctor paths (strict preempts them on
3412
+ the schema-parsed compile/validate paths); doc comment updated to explain the
3413
+ interplay.
3414
+
3415
+ - 8923843: Reject view containers that define no views. A flat list-view object (`{ name, label, type, columns, ... }`) parses to an empty `ViewSchema` container because Zod strips unknown keys — zero views register and the Console silently renders nothing. `defineView()` now throws on a zero-view container, and `os validate` gains a `view-container-shape` check (`validateViewContainers` in `@objectstack/lint`) that reports flat or empty `views: []` entries pre-parse with a wrap-it fix hint.
3416
+ - Updated dependencies [f972574]
3417
+ - Updated dependencies [22013aa]
3418
+ - Updated dependencies [3ad3dd5]
3419
+ - Updated dependencies [3a18b60]
3420
+ - Updated dependencies [a8aa34c]
3421
+ - Updated dependencies [a3823b2]
3422
+ - Updated dependencies [43a3efb]
3423
+ - Updated dependencies [524696a]
3424
+ - Updated dependencies [6b51346]
3425
+ - Updated dependencies [80273c8]
3426
+ - Updated dependencies [5e3301d]
3427
+ - Updated dependencies [46e876c]
3428
+ - Updated dependencies [158aa14]
3429
+ - Updated dependencies [d2723e2]
3430
+ - Updated dependencies [fefcd54]
3431
+ - Updated dependencies [beaf2de]
3432
+ - Updated dependencies [369eb6e]
3433
+ - Updated dependencies [b659111]
3434
+ - Updated dependencies [5754a23]
3435
+ - Updated dependencies [6c270a6]
3436
+ - Updated dependencies [668dd17]
3437
+ - Updated dependencies [8abf133]
3438
+ - Updated dependencies [e0859b1]
3439
+ - Updated dependencies [04ecd4e]
3440
+ - Updated dependencies [4d5a892]
3441
+ - Updated dependencies [16cebeb]
3442
+ - Updated dependencies [86d30af]
3443
+ - Updated dependencies [8923843]
3444
+ - Updated dependencies [ea32ec7]
3445
+ - Updated dependencies [a2795f6]
3446
+ - Updated dependencies [f16b492]
3447
+ - Updated dependencies [4b6fde8]
3448
+ - Updated dependencies [2018df9]
3449
+ - Updated dependencies [fc5a3a2]
3450
+ - @objectstack/spec@16.0.0-rc.0
3451
+ - @objectstack/formula@16.0.0-rc.0
3452
+ - @objectstack/sdui-parser@16.0.0-rc.0
3453
+
3454
+ ## 15.1.1
3455
+
3456
+ ### Patch Changes
3457
+
3458
+ - @objectstack/spec@15.1.1
3459
+ - @objectstack/formula@15.1.1
3460
+ - @objectstack/sdui-parser@15.1.1
3461
+
3462
+ ## 15.1.0
3463
+
3464
+ ### Patch Changes
3465
+
3466
+ - f531a26: ADR-0085 #2548 follow-ups surfaced by the real-backend browser pass:
3467
+
3468
+ - **lint**: new `field-group-shadowed` warning in `validate-semantic-roles` — a
3469
+ declared fieldGroup whose every visible member is hoisted into the detail
3470
+ highlight strip (or is the record title) renders on forms but silently never
3471
+ on detail pages (detail bodies hide the first 4 highlightFields). Warning
3472
+ tier, same as the other semantic-role rules.
3473
+ - **plugin-audit**: feed/audit summaries ("Created … / Deleted … / Updated …")
3474
+ now name the object by its display label ("Semantic Zoo") instead of its API
3475
+ name ("showcase_semantic_zoo") — these strings render verbatim in the record
3476
+ Discussion feed and Setup dashboards. Falls back to the API name when the
3477
+ object definition isn't resolvable. Existing stored rows are unchanged.
3478
+
3479
+ - Updated dependencies [f531a26]
3480
+ - Updated dependencies [f531a26]
3481
+ - Updated dependencies [f531a26]
3482
+ - Updated dependencies [f531a26]
3483
+ - Updated dependencies [f531a26]
3484
+ - Updated dependencies [f531a26]
3485
+ - Updated dependencies [3fe9df1]
3486
+ - Updated dependencies [f531a26]
3487
+ - Updated dependencies [f531a26]
3488
+ - Updated dependencies [f531a26]
3489
+ - Updated dependencies [f531a26]
3490
+ - Updated dependencies [f531a26]
3491
+ - Updated dependencies [f531a26]
3492
+ - Updated dependencies [f531a26]
3493
+ - Updated dependencies [f531a26]
3494
+ - Updated dependencies [f531a26]
3495
+ - Updated dependencies [f531a26]
3496
+ - Updated dependencies [f531a26]
3497
+ - Updated dependencies [4109153]
3498
+ - Updated dependencies [f531a26]
3499
+ - Updated dependencies [f531a26]
3500
+ - Updated dependencies [f531a26]
3501
+ - Updated dependencies [f531a26]
3502
+ - Updated dependencies [f531a26]
3503
+ - Updated dependencies [f531a26]
3504
+ - Updated dependencies [627f225]
3505
+ - Updated dependencies [f531a26]
3506
+ - Updated dependencies [f531a26]
3507
+ - Updated dependencies [f531a26]
3508
+ - @objectstack/spec@15.1.0
3509
+ - @objectstack/formula@15.1.0
3510
+ - @objectstack/sdui-parser@15.1.0
3511
+
3512
+ ## 15.0.0
3513
+
3514
+ ### Minor Changes
3515
+
3516
+ - 891ea81: ADR-0089 D3b: make the `visibility-root-mislayered` lint check bidirectional. `validateVisibilityPredicates` now accepts an optional `{ layer }` option — `'runtime'` (default, unchanged) flags a `data.`-rooted predicate on a `*.view.ts` / `*.page.ts` surface, and `'metadata'` flags a `record.`-rooted predicate on a `*.form.ts` metadata-editing form. Both directions of the ADR's binding-root rule are now covered. Adds the `VisibilityLayer` / `VisibilityOptions` exported types. Fully back-compat: existing single-argument callers keep the runtime behavior.
3517
+ - e62c233: feat(spec,plugin-security): package-level capability declaration API (ADR-0066 D1)
3518
+
3519
+ Packages can now DEFINE their own authorization capabilities explicitly via the
3520
+ new `defineCapability` factory and a stack's `capabilities` array, instead of
3521
+ relying on the implicit "derive an untitled capability from whatever a permission
3522
+ set references in `systemPermissions[]`" back-door.
3523
+
3524
+ - `@objectstack/spec`: new `defineCapability` / `CapabilityDeclarationSchema`
3525
+ (`{ name, label?, description?, scope, packageId? }`) and a `capabilities`
3526
+ field on the stack definition.
3527
+ - `@objectstack/plugin-security`: new `bootstrapDeclaredCapabilities` seeds
3528
+ declared capabilities into `sys_capability` with `managed_by:'package'` +
3529
+ `package_id` provenance (new `package_id` field on the object). Idempotent,
3530
+ upgrade-aware; refuses to hijack curated platform capabilities or another
3531
+ package's rows, never clobbers admin-authored rows, and CLAIMS a pre-existing
3532
+ derived placeholder (upgrading it to package provenance). The implicit
3533
+ derive-from-`systemPermissions` path still runs for back-compat but now skips
3534
+ any explicitly-declared name so it can't clobber authored metadata.
3535
+ - `@objectstack/runtime`: stack-declared `capabilities` are registered into the
3536
+ metadata registry (type `capability`) so the boot seeder can read them.
3537
+ - `@objectstack/lint`: `validateCapabilityReferences` treats
3538
+ `stack.capabilities` names as a known capability source.
3539
+
3540
+ A capability is not a contract: DEFINE it (`defineCapability`), GRANT it
3541
+ (`systemPermissions`), REQUIRE it (`requiredPermissions`) — no `inputs`.
3542
+ Aligns with ADR-0094 D5 (retire implicit `managed_by`-guessing back-doors).
3543
+
3544
+ ### Patch Changes
3545
+
3546
+ - Updated dependencies [28b7c28]
3547
+ - Updated dependencies [13749ec]
3548
+ - Updated dependencies [e62c233]
3549
+ - Updated dependencies [ed61c9b]
3550
+ - Updated dependencies [31d04d4]
3551
+ - @objectstack/spec@15.0.0
3552
+ - @objectstack/formula@15.0.0
3553
+ - @objectstack/sdui-parser@15.0.0
3554
+
3555
+ ## 14.8.0
3556
+
3557
+ ### Minor Changes
3558
+
3559
+ - 10e8983: ADR-0089 D3b: add the `validateVisibilityPredicates` lint rule for conditional-visibility keys, wired into `os validate` and `os compile` as advisory warnings.
3560
+
3561
+ Two rules, both `warning` (never fail the build):
3562
+
3563
+ - `visibility-alias-deprecated` — a `visibleOn` (view form section/field) or `visibility` (page component) key in authored source. It still works — the schema normalizes it to `visibleWhen` at parse — but the canonical key is `visibleWhen`. Fix: rename the key (same CEL value).
3564
+ - `visibility-root-mislayered` — a runtime view/page visibility predicate rooted at `data.` (the metadata-editing-form root). Runtime record surfaces bind `record` + `current_user` (pages also expose `page.<var>`), so a `data.`-rooted predicate here never matches and the element renders unconditionally. Fix: use `record.`/`page.`.
3565
+
3566
+ The rule runs on the **pre-parse** stack (like `validate-list-view-mode`) so it can see the deprecated alias the author actually wrote before the schema folds it into `visibleWhen`.
3567
+
3568
+ ### Patch Changes
3569
+
3570
+ - Updated dependencies [16b4bf6]
3571
+ - Updated dependencies [16b4bf6]
3572
+ - Updated dependencies [10e8983]
3573
+ - Updated dependencies [607aaf4]
3574
+ - Updated dependencies [bb71321]
3575
+ - @objectstack/spec@14.8.0
3576
+ - @objectstack/formula@14.8.0
3577
+ - @objectstack/sdui-parser@14.8.0
3578
+
3579
+ ## 14.7.0
3580
+
3581
+ ### Patch Changes
3582
+
3583
+ - Updated dependencies [d6a72eb]
3584
+ - @objectstack/spec@14.7.0
3585
+ - @objectstack/formula@14.7.0
3586
+ - @objectstack/sdui-parser@14.7.0
3587
+
3588
+ ## 14.6.0
3589
+
3590
+ ### Patch Changes
3591
+
3592
+ - Updated dependencies [609cb13]
3593
+ - Updated dependencies [ce6d151]
3594
+ - @objectstack/spec@14.6.0
3595
+ - @objectstack/formula@14.6.0
3596
+ - @objectstack/sdui-parser@14.6.0
3597
+
3598
+ ## 14.5.0
3599
+
3600
+ ### Patch Changes
3601
+
3602
+ - Updated dependencies [526805e]
3603
+ - Updated dependencies [d79ca07]
3604
+ - Updated dependencies [33ebd34]
3605
+ - Updated dependencies [c044f08]
3606
+ - Updated dependencies [01274eb]
3607
+ - @objectstack/spec@14.5.0
3608
+ - @objectstack/formula@14.5.0
3609
+ - @objectstack/sdui-parser@14.5.0
3610
+
3611
+ ## 14.4.0
3612
+
3613
+ ### Minor Changes
3614
+
3615
+ - 82e745e: ADR-0091 L1 — grant validity windows: effective-dated assignments, resolution-time filtering, explain expired state, authoring lint.
3616
+
3617
+ - **plugin-security (objects)**: `sys_user_position` and `sys_user_permission_set` gain the D1 lifecycle columns — `valid_from`, `valid_until` (half-open `[from, until)`, UTC; null = unbounded, existing rows unchanged), `reason`, `delegated_from`, `last_certified_at`, `certified_by`.
3618
+ - **core**: new shared predicate `isGrantActive` / `isGrantExpired` (`@objectstack/core`), and `resolveAuthzContext` now filters BOTH grant tables through it (D2, fail-closed — an expired unscoped `admin_full_access` grant no longer derives `platform_admin`). Present-but-unparseable bounds fail closed.
3619
+ - **plugin-security (explain)**: `buildContextForUser` applies the same filter and returns `expiredGrants`; the principal layer reports the dedicated "held until … — expired" contributor state so "why did access disappear" is self-answering. Spec `ExplainLayerSchema` contributors gain an optional `state: 'active' | 'expired'`.
3620
+ - **plugin-sharing**: `PositionGraphService.expandPositionUsers` filters expired holders — sharing-rule recipients stop including them at resolution time.
3621
+ - **lint (D7)**: two new error rules over seed data — `security-grant-expired-at-authoring` (a `valid_until` in the past, or unparseable, is a grant that can never resolve) and `security-delegation-missing-reason` (a `delegated_from` row without `reason` breaks the D3 dual audit). Also re-exported the missing `SECURITY_MASTER_DETAIL_UNGRANTED` constant.
3622
+
3623
+ No background job is involved anywhere — per ADR-0049, an expired grant simply stops resolving, in every edition.
3624
+
3625
+ - 7449476: Permission-zoo audit follow-ups:
3626
+
3627
+ **FLS keys must be object-qualified (`security-fls-unqualified-key`, error).**
3628
+ The runtime evaluator matches field-permission keys by `<object>.<field>`
3629
+ prefix — a bare `budget` key matches NOTHING and the declared masking
3630
+ silently never enforces. The showcase itself shipped exactly that bug: its
3631
+ contributor FLS block (bare `budget`/`spent`/`budget_remaining`) was a
3632
+ runtime no-op, and the "FLS proof" in earlier verification was actually a
3633
+ validation-rule rejection. Fixed: keys qualified
3634
+ (`showcase_project.budget` …), a new D7 lint rule rejects bare keys at
3635
+ compile time with a fix-it, and the permission-zoo dogfood now proves the
3636
+ served pipeline denies a contributor's budget write while allowing ordinary
3637
+ field edits.
3638
+
3639
+ **Release pipeline: PROTOCOL_VERSION auto-sync.** `changeset version` now
3640
+ runs `scripts/sync-protocol-version.mjs`, regenerating the handshake
3641
+ constant from the spec package major. Release PRs opened by
3642
+ changesets/action with the default GITHUB_TOKEN never trigger CI (GitHub's
3643
+ anti-recursion rule), so the lockstep guard could only fire AFTER a release
3644
+ merged — the drift class that broke main at 14.0.0 (#2769) is now fixed at
3645
+ version time, the one spot that cannot be skipped.
3646
+
3647
+ **D11 `externalSharingModel` honestly marked.** The dial has no runtime
3648
+ consumer yet (authoring lint + Studio badges only); its liveness entry
3649
+ moves from a bespoke `authorable` status to the documented `planned` +
3650
+ `authorWarn`, and the sharing docs / design doc / showcase comments now say
3651
+ explicitly that evaluation of external principals lands with the
3652
+ principal-taxonomy phase (#2696).
3653
+
3654
+ ### Patch Changes
3655
+
3656
+ - Updated dependencies [7953832]
3657
+ - Updated dependencies [82e745e]
3658
+ - Updated dependencies [f3035bd]
3659
+ - Updated dependencies [82c0d94]
3660
+ - Updated dependencies [7449476]
3661
+ - @objectstack/spec@14.4.0
3662
+ - @objectstack/formula@14.4.0
3663
+ - @objectstack/sdui-parser@14.4.0
3664
+
3665
+ ## 14.3.0
3666
+
3667
+ ### Minor Changes
3668
+
3669
+ - 02f6af4: ADR-0090 follow-through wave: enforce book audience at the read layer; finish the D2/D3 cleanup the P1 rename missed.
3670
+
3671
+ - **rest**: `/meta/book`, `/meta/doc`, and `/meta/book/:name/tree` now ENFORCE
3672
+ the ADR-0046 §6.7 audience model (ADR-0049 — no unenforced security
3673
+ properties): anonymous callers see only `public` books/docs;
3674
+ `{ permissionSet }`-gated books require the caller to hold the named set;
3675
+ a doc's effective audience is the union over the books that CLAIM it
3676
+ (unclaimed docs default to `org`; orphan rendering never inherits `public`).
3677
+ Gated evaluation fails CLOSED when holdings cannot be resolved. `doc`/`book`
3678
+ single-item reads bypass the shared meta cache (per-caller gate vs shared ETag).
3679
+ - **spec**: new pure helpers powering that gate — `audienceAllows`,
3680
+ `resolveDocAudiences`, `docAudienceAllows`, `resolveBookClaimedDocs`
3681
+ (+ `AudienceCaller`/`AudienceBook` types). BREAKING but ships as a `minor`
3682
+ per the launch-window convention (pre-1.0 semantics — breaking changes do
3683
+ not burn a major version number while the whole stack is in lockstep):
3684
+ `METADATA_FORM_REGISTRY` keys `role`/`profile` are gone — `position` is the
3685
+ registered form (the `position` type had LOST its form layout in the P1
3686
+ rename); `EnvironmentArtifactMetadataSchema` declares `positions` instead of
3687
+ retired `roles`/`profiles`.
3688
+ - **plugin-security**: the `security` service exposes
3689
+ `resolvePermissionSetNames(ctx)` — the same resolution as data-plane
3690
+ enforcement, for the docs gate.
3691
+ - **metadata**: artifact ingestion maps `positions → 'position'` (the stale
3692
+ `roles → 'role'` mapping matched nothing since the P1 rename, silently
3693
+ dropping compiled positions from metadata registration).
3694
+ - **lint**: books join the D3 role-word scan (their `audience` is a
3695
+ permission-model reference now), and a new advisory rule
3696
+ `security-book-audience-unknown-set` flags a `{ permissionSet }` audience
3697
+ naming a set the stack does not declare (runtime fails closed — the typo
3698
+ cost is "nobody can read the book", so say it at author time).
3699
+ - **platform-objects**: metadata-form translations regain `position` (all four
3700
+ locales) and drop the retired `role`/`profile` groups, with a vocabulary
3701
+ regression test.
3702
+
3703
+ ### Patch Changes
3704
+
3705
+ - Updated dependencies [2a71f48]
3706
+ - Updated dependencies [02f6af4]
3707
+ - Updated dependencies [c1064f1]
3708
+ - @objectstack/spec@14.3.0
3709
+ - @objectstack/formula@14.3.0
3710
+ - @objectstack/sdui-parser@14.3.0
3711
+
3712
+ ## 14.2.0
3713
+
3714
+ ### Patch Changes
3715
+
3716
+ - Updated dependencies [ac8f029]
3717
+ - Updated dependencies [4ab9958]
3718
+ - @objectstack/spec@14.2.0
3719
+ - @objectstack/formula@14.2.0
3720
+ - @objectstack/sdui-parser@14.2.0
3721
+
3722
+ ## 14.1.0
3723
+
3724
+ ### Minor Changes
3725
+
3726
+ - 5a8465f: SLA escalation `escalateTo` is position-first (ADR-0090 D3 follow-up to the `position` approver type).
3727
+
3728
+ - **spec**: `ApprovalEscalationSchema.escalateTo` is documented as a position machine name or a
3729
+ specific user id (was "User id, role, or manager level" — the same pre-D3 'role' trap the
3730
+ `position` approver type fixed); the Studio xRef picker kind moves `role` → `position`.
3731
+ - **plugin-approvals**: on escalation, `escalateTo` now expands position holders via
3732
+ `sys_user_position` ∪ the `sys_member.role` transition source (ADR-0057 D4) for both the
3733
+ `reassign` approver hand-off and the `notify` audience. An empty expansion falls back to
3734
+ treating the value as a literal user id, so configs naming a specific user keep working
3735
+ unchanged. The audit trail keeps the authored target.
3736
+ - **lint**: new `approval-escalation-reassign-no-target` warning — `escalation.action: 'reassign'`
3737
+ with no `escalateTo` silently degrades to a notify at runtime; the fix-it prescribes a position
3738
+ or user id target (or `action: 'notify'`).
3739
+
3740
+ ### Patch Changes
3741
+
3742
+ - Updated dependencies [5a8465f]
3743
+ - Updated dependencies [7f8620b]
3744
+ - Updated dependencies [82ba3a6]
3745
+ - @objectstack/spec@14.1.0
3746
+ - @objectstack/formula@14.1.0
3747
+ - @objectstack/sdui-parser@14.1.0
3748
+
3749
+ ## 14.0.0
3750
+
3751
+ ### Minor Changes
3752
+
3753
+ - 216fa9a: Add a `position` approver type so approvals can route to org positions (ADR-0090 D3 fallout).
3754
+
3755
+ Post ADR-0090 D3 the `role` approver type resolves against the better-auth org-membership
3756
+ tier (`sys_member.role`: `owner`/`admin`/`member`) — it was never a position. Downstream
3757
+ apps that authored `{ type: 'role', value: 'sales_manager' }` silently routed approvals to
3758
+ nobody. Now:
3759
+
3760
+ - **spec**: `ApproverType` gains `'position'` — `value` is the position machine name; the
3761
+ approver expands to its holders via `sys_user_position`. Authoring guidance: keep
3762
+ `type: 'role'` ONLY for membership tiers; for org positions use
3763
+ `{ type: 'position', value: '<position_name>' }` (one-line fix for the mismatch above).
3764
+ - **plugin-approvals**: the engine resolves `position` approvers via `sys_user_position` ∪
3765
+ the `sys_member.role` transition source (same semantics as `PositionGraphService` in
3766
+ plugin-sharing). The `department` approver type is now honored by its spec spelling
3767
+ (previously only the off-spec `business_unit`/`bu` dialect matched).
3768
+ - **lint**: new `validateApprovalApprovers` rule — `approval-role-not-membership-tier`
3769
+ warns when a `role` approver's value is not a membership tier and prescribes the
3770
+ `position` rewrite; `approval-approver-type-unknown` flags off-spec approver types
3771
+ (with a `business_unit` → `department` fix-it). Wired into `os lint`.
3772
+
3773
+ ### Patch Changes
3774
+
3775
+ - 2f3581f: feat(lint): warn when a master-detail child has no object-level CRUD grant (ADR-0090 D7)
3776
+
3777
+ New security-posture rule `security-master-detail-ungranted` (advisory
3778
+ `warning`; it does not gate the build). A master-detail DETAIL object derives
3779
+ its RECORD-level access from the master (ADR-0055 `controlled_by_parent`,
3780
+ gate ②), but object-level CRUD is a SEPARATE gate ① (`checkObjectPermission`)
3781
+ that is never derived — a permission set that grants the parent but forgets the
3782
+ child denies role-bound non-admin users a 403 before the parent-derived access
3783
+ is ever consulted, surfacing as the silent "can't fill in / can't submit the
3784
+ subtable" trap (framework#2700, downstream os-tianshun-mtc#43).
3785
+
3786
+ The rule flags a non-system detail (has a `master_detail` field) that NO
3787
+ authored permission set grants (explicit entry or `'*'` wildcard). It stays
3788
+ silent when the package authors no permission sets, when a package-declared
3789
+ `'*'` wildcard grant covers every object, or for `sys_*` / `isSystem` objects —
3790
+ keeping the false-positive rate near zero. The residual per-set gap (one role
3791
+ grants it, another forgets it) is intentionally out of scope, and CRUD
3792
+ auto-inheritance is deliberately NOT adopted (secure-by-default, Salesforce
3793
+ parity).
3794
+
3795
+ - Updated dependencies [0a8e685]
3796
+ - Updated dependencies [afa8115]
3797
+ - Updated dependencies [80f12ca]
3798
+ - Updated dependencies [e2fa074]
3799
+ - Updated dependencies [23c8668]
3800
+ - Updated dependencies [29f017d]
3801
+ - Updated dependencies [216fa9a]
3802
+ - Updated dependencies [6c22b12]
3803
+ - @objectstack/spec@14.0.0
3804
+ - @objectstack/formula@14.0.0
3805
+ - @objectstack/sdui-parser@14.0.0
3806
+
3807
+ ## 13.0.0
3808
+
3809
+ ### Minor Changes
3810
+
3811
+ - b271691: ADR-0090 P3 — security-domain publish linter (D7) and delegated administration (D12).
3812
+
3813
+ **D7 — `validateSecurityPosture` (@objectstack/lint), wired into `os compile` (errors gate the build) and `os lint`.** Rules, each with a failing fixture: `security-owd-unset` (custom object with no `sharingModel` — the objectui#2348 leave_request shape), `security-owd-alias` (retired D4 alias values, with fix-it), `security-external-wider-than-internal` (D11 `external ≤ internal`), `security-wildcard-vama` (`'*'` + View/Modify All outside the platform admin set, ADR-0066), `security-anchor-high-privilege` (an `isDefault`/everyone-suggested set carrying anchor-forbidden bits), `security-role-word` (D3 vocabulary freeze in security identifiers/labels; ARIA/page roles exempt), and advisory `security-private-no-readscope`.
3814
+
3815
+ **D12 — delegated administration (@objectstack/plugin-security `DelegatedAdminGate`).** `PermissionSetSchema.adminScope` (new in spec, persisted as `sys_permission_set.admin_scope`) declares WHERE (a `sys_business_unit` subtree), WHAT (`manageAssignments` / `manageBindings` / `authorEnvironmentSets`), and WHICH sets a delegate may hand out (`assignablePermissionSets` allowlist). Writes to `sys_user_position`, `sys_position_permission_set`, `sys_user_permission_set`, and `sys_permission_set` are now governed: tenant-level admins (ADR-0066 superuser wildcard) pass through; delegates need a covering scope — inside their subtree, allowlisted sets only (to others AND themselves), single-row writes, `granted_by` audit-stamped; everyone else (including holders of plain CRUD on RBAC tables) is denied. Granting or authoring a set that itself carries an `adminScope` requires a held scope that STRICTLY contains it. The `everyone`/`guest` anchors stay tenant-level only, and direct position assignments to an anchor are rejected for every caller.
3816
+
3817
+ **ADR-0090 Addendum — assignment-level BU anchor.** `sys_user_position.business_unit_id` lands with its three consumers scoped: D12 delegation boundary (enforced here), audit fact, and the depth-anchor contract for enterprise `hierarchy-scope-resolver` implementations (documented on `IHierarchyScopeResolver`).
3818
+
3819
+ **D9 tier tightening.** `describeHighPrivilegeBits` moved to `@objectstack/spec/security` (re-exported from plugin-security) alongside new `describeAnchorForbiddenBits`: `guest` bindings now additionally reject edit bits (read-only by default; create stays the case-by-case exception).
3820
+
3821
+ **BREAKING (@objectstack/plugin-security):** exports renamed to the ADR-0090 D3 vocabulary — `SysRole`→`SysPosition`, `SysUserRole`→`SysUserPosition`, `SysRolePermissionSet`→`SysPositionPermissionSet` (no aliases, pre-launch one-step rename). `sys_position` row actions/list views renamed (`activate_position`, …), labels relabeled Role→Position. Non-tenant-admin writes to the RBAC link tables without an `adminScope` are now denied (previously any CRUD grant on those tables sufficed).
3822
+
3823
+ **BREAKING (@objectstack/platform-objects):** `sys_business_unit_member.role_in_business_unit` → `function_in_business_unit` (D3 reserved-word sweep; values member/lead/deputy unchanged).
3824
+
3825
+ - a5a1e41: ADR-0090 P4 — explain engine (D6), access-matrix snapshot gate, recalibrated benchmark.
3826
+
3827
+ **Explain contract (@objectstack/spec).** `ExplainRequestSchema` / `ExplainDecisionSchema` / `ExplainLayerSchema`: `explain(principal, object, operation)` reports the verdict of every evaluation-pipeline layer in order (principal → required_permissions → object_crud → fls → owd_baseline → depth → sharing → vama_bypass → rls), with per-layer contributor attribution (which permission set, reached via which position/baseline) and — for reads — the composed row filter as the machine artifact. Carries the D10 dual attribution (`principalKind`, `onBehalfOf`).
3828
+
3829
+ **Explain engine (@objectstack/plugin-security).** `explainAccess` is "explained by construction": it calls the SAME permission-set resolution, evaluator, FLS mask, and RLS composition the enforcement middleware calls (injected from `SecurityPlugin`), so the report cannot drift from enforcement. Exposed on the `security` kernel service as `explain(request, callerContext)`; explaining another user requires `manage_users` (the target's context is reconstructed from `sys_user_position` / `sys_user_permission_set` with everyone-anchor semantics via `buildContextForUser`).
3830
+
3831
+ **Access-matrix snapshot gate (@objectstack/lint + os compile).** `buildAccessMatrix(stack)` derives the (permission set × object) capability matrix purely from metadata; `diffAccessMatrix` renders semantic review lines ("'crm_admin' gains delete on 'crm_lead'", depth changes, OWD swings, entry add/remove). `os compile` gains an opt-in gate: with `access-matrix.json` committed next to the config, any drift fails the build with those lines until re-snapshotted via `--update-access-matrix` — every capability change becomes a reviewable diff. Seeded for `examples/app-crm`.
3832
+
3833
+ **Benchmark (ADR-0090 Addendum).** `scripts/bench/permission-bench.mts` — single-org 10k users × 1M rows per the recalibrated topology; asserts the O()-shape property (per-request cost independent of user population; unit-depth IN-set cost tracks unit size). Passing at 0.1µs/eval and 59ms/1M-row IN-set scan.
3834
+
3835
+ - 466adf6: Author-time capability-reference lint (ADR-0066 ⑨) — `os validate` / `os lint`
3836
+ now warn when a `requiredPermissions` names a capability that is registered
3837
+ nowhere.
3838
+
3839
+ `requiredPermissions` (on objects, fields, apps, actions) is a free string, so a
3840
+ typo like `mange_users` is schema-valid and fails closed at runtime (the caller
3841
+ is denied) — safe, but silent. The new `validateCapabilityReferences` rule
3842
+ (`@objectstack/lint`) resolves every reference against the author-time known set
3843
+ and warns on the unresolved ones:
3844
+
3845
+ - built-in platform capabilities — now sourced from a single canonical list in
3846
+ `@objectstack/spec` (`security/capabilities.ts`: `PLATFORM_CAPABILITIES` /
3847
+ `PLATFORM_CAPABILITY_NAMES`), which `@objectstack/plugin-security`'s
3848
+ `bootstrapSystemCapabilities` also seeds from (one source of truth, no drift),
3849
+ - any capability a permission set in the stack grants via `systemPermissions`
3850
+ (granting is what declares it — mirrors the runtime derived-defaults rule), and
3851
+ - any `sys_capability` row shipped as seed data.
3852
+
3853
+ It is a **warning**, not an error: a single package can't see capabilities
3854
+ declared by other installed packages, and the reference fails closed anyway.
3855
+ `systemPermissions` itself is never flagged — it is the declaration side, and a
3856
+ package legitimately introduces new capabilities there. The object case also
3857
+ understands the per-operation `requiredPermissions` map form (ADR-0066 ⑤) and
3858
+ points a finding at the exact operation slice.
3859
+
3860
+ ### Patch Changes
3861
+
3862
+ - Updated dependencies [6d83431]
3863
+ - Updated dependencies [01917c2]
3864
+ - Updated dependencies [b271691]
3865
+ - Updated dependencies [a5a1e41]
3866
+ - Updated dependencies [466adf6]
3867
+ - Updated dependencies [5be00c3]
3868
+ - Updated dependencies [466adf6]
3869
+ - Updated dependencies [2bee609]
3870
+ - Updated dependencies [fc7e7f7]
3871
+ - @objectstack/spec@13.0.0
3872
+ - @objectstack/formula@13.0.0
3873
+ - @objectstack/sdui-parser@13.0.0
3874
+
3875
+ ## 12.6.0
3876
+
3877
+ ### Patch Changes
3878
+
3879
+ - Updated dependencies [6cebf22]
3880
+ - @objectstack/spec@12.6.0
3881
+ - @objectstack/formula@12.6.0
3882
+ - @objectstack/sdui-parser@12.6.0
3883
+
3884
+ ## 12.5.0
3885
+
3886
+ ### Patch Changes
3887
+
3888
+ - Updated dependencies [8b3d363]
3889
+ - @objectstack/spec@12.5.0
3890
+ - @objectstack/formula@12.5.0
3891
+ - @objectstack/sdui-parser@12.5.0
3892
+
3893
+ ## 12.4.0
3894
+
3895
+ ### Patch Changes
3896
+
3897
+ - Updated dependencies [60dc3ba]
3898
+ - @objectstack/spec@12.4.0
3899
+ - @objectstack/formula@12.4.0
3900
+ - @objectstack/sdui-parser@12.4.0
3901
+
3902
+ ## 12.3.0
3903
+
3904
+ ### Patch Changes
3905
+
3906
+ - Updated dependencies [e7eceec]
3907
+ - @objectstack/spec@12.3.0
3908
+ - @objectstack/formula@12.3.0
3909
+ - @objectstack/sdui-parser@12.3.0
3910
+
3911
+ ## 12.2.0
3912
+
3913
+ ### Patch Changes
3914
+
3915
+ - Updated dependencies [fce8ff4]
3916
+ - Updated dependencies [3962023]
3917
+ - Updated dependencies [2bb193d]
3918
+ - Updated dependencies [0426d27]
3919
+ - Updated dependencies [da807f7]
3920
+ - @objectstack/spec@12.2.0
3921
+ - @objectstack/formula@12.2.0
3922
+ - @objectstack/sdui-parser@12.2.0
3923
+
3924
+ ## 12.1.0
3925
+
3926
+ ### Patch Changes
3927
+
3928
+ - Updated dependencies [93e6d02]
3929
+ - @objectstack/spec@12.1.0
3930
+ - @objectstack/formula@12.1.0
3931
+ - @objectstack/sdui-parser@12.1.0
3932
+
3933
+ ## 12.0.0
3934
+
3935
+ ### Minor Changes
3936
+
3937
+ - a8df396: feat(spec,lint): adaptive record surface + semantic field `span` for field-heavy objects (#2578)
3938
+
3939
+ Field-heavy objects need two things the protocol did not express well: multi-column
3940
+ forms, and opening create/edit/detail as a full page rather than a cramped popup —
3941
+ for _some_ objects, automatically. Because all metadata is AI-authored, the design
3942
+ goal is to make AI unable to get it wrong, which reshaped both features away from
3943
+ new authored keys.
3944
+
3945
+ **`deriveRecordSurface` (new spec derivation, ADR-0085 §5).** A record's default
3946
+ surface — full `page` vs `drawer`/`modal` overlay — is _derived_ from how heavy the
3947
+ record is (visible, non-system field count; mobile always pages), not authored. Per
3948
+ ADR-0085 §2's admission test a `recordSurface` object key would fail: field count is
3949
+ exactly the kind of fact a machine can infer, and modal-vs-page is pure
3950
+ re-arrangement, not a business fact. So there is **no new object key** and **no new
3951
+ ADR** — just a single shared derivation renderers consume as a default (an explicit
3952
+ form/navigation config still wins), plus a one-line clarification to ADR-0085 §2's
3953
+ rejected-keys list so `recordSurface` is not re-proposed. Explicit per-object control
3954
+ remains the sanctioned assigned-page path.
3955
+
3956
+ **`FormField.span: 'auto' | 'full'` (new, replaces absolute `colSpan` as the
3957
+ primary primitive).** Under a per-surface derived column count (mobile 1 / modal 2 /
3958
+ page 3-4) an absolute `colSpan: 3` only lines up at the one width the author
3959
+ imagined — fragile by construction. The relative `span` is decoupled from the column
3960
+ count: `auto` (default; omit it) sizes by widget type × current columns, `full` takes
3961
+ the whole row at any count. `colSpan` is retained for back-compat and clamped by the
3962
+ renderer; `half` was considered and deferred (weakest AI-safety). The rationale lives
3963
+ here rather than in a new ADR, per the fewer-ADRs convention.
3964
+
3965
+ **`validateFormLayout` (new lint, ADR-0078/0019).** Two advisory rules over authored
3966
+ form views: `form-field-unknown` (a section references a field not on the bound
3967
+ object — silently never renders) and `absolute-colspan-discouraged` (steers authors
3968
+ to `span: 'full'`). Both warnings, with fix hints, held to the same bar for AI and
3969
+ hand authors.
3970
+
3971
+ **`NavigationConfig.size` (new) replaces pixel `width`.** A T-shirt bucket
3972
+ (`auto`/sm/md/lg/xl/full, default `auto`, aligned with `FormView.modalSize`) for a
3973
+ drawer/modal detail overlay. `width`/`drawerWidth` (pixel) are deprecated: a pixel
3974
+ width cannot be authored blind — the author (often an AI) does not know the client
3975
+ viewport. `auto` means the renderer derives the size from field count and clamps to
3976
+ the viewport, so AI writes nothing.
3977
+
3978
+ All additive: no exports removed, no behavior change for existing metadata.
3979
+
3980
+ - e695fe0: feat(spec,lint): reject userFilters on object list views (ADR-0053 phase 4)
3981
+
3982
+ ADR-0053 reserves `userFilters`/`quickFilters` for page lists ("filters" mode);
3983
+ on an object list view ("views" mode — where the `ViewTabBar` is the only nav
3984
+ control) they are silently dropped. This lands the phase-4 guardrail as a
3985
+ layered defence, so the wrong-context authoring mistake is caught without
3986
+ breaking existing metadata:
3987
+
3988
+ - **Type-level (author time):** new `ObjectListViewSchema` = `ListViewSchema`
3989
+ minus `userFilters`. Object built-in `listViews` and `defineView`
3990
+ `list`/`listViews` now use it, so `userFilters` on an object list view is a
3991
+ `tsc` error. The full `ListViewSchema` (page "filters" mode) is untouched.
3992
+ - **Runtime (back-compat):** the field is STRIPPED at parse (default strip, no
3993
+ throw), so existing metadata keeps loading — `ObjectSchema.parse` never fails
3994
+ on a stray `userFilters`.
3995
+ - **Author/CI (actionable):** new `@objectstack/lint` rule
3996
+ `validateListViewMode`, wired into `os validate`, reports the wrong-context
3997
+ field PRE-parse (before the schema strips it) with a fix hint.
3998
+
3999
+ Closes the schema half of objectui #2219; supersedes the interim runtime warn in
4000
+ objectui #2220.
4001
+
4002
+ ### Patch Changes
4003
+
4004
+ - Updated dependencies [a8df396]
4005
+ - Updated dependencies [e695fe0]
4006
+ - Updated dependencies [7c09621]
4007
+ - Updated dependencies [7709db4]
4008
+ - Updated dependencies [2082109]
4009
+ - Updated dependencies [7c09621]
4010
+ - Updated dependencies [9860de4]
4011
+ - Updated dependencies [069c205]
4012
+ - @objectstack/spec@12.0.0
4013
+ - @objectstack/formula@12.0.0
4014
+ - @objectstack/sdui-parser@12.0.0
4015
+
4016
+ ## 11.10.0
4017
+
4018
+ ### Patch Changes
4019
+
4020
+ - 996c548: Load Sucrase lazily in `validateReactPages` instead of at module top level — the same kernel boot-path contract applied to the TypeScript compiler in `validateReactPageProps` (framework#2544).
4021
+
4022
+ `@objectstack/lint` sits on the kernel boot path, so the eager `import { transform } from 'sucrase'` made every boot parse ~1.5 MB of transpiler (~16 ms cold require) for a syntax gate that only runs when a `kind:'react'` page is actually validated — a rare, trusted-tier case. Sucrase now loads on the first validated react-source page via the same deferred-createRequire pattern; the public API stays synchronous and unchanged, `sucrase` stays a regular dependency, and if the package is missing at call time validation fails with an actionable error instead of killing boot.
4023
+
4024
+ The boot-path guard test is generalized from `lazy-typescript.test.ts` to `lazy-deps.test.ts` and now covers both deps at all three levels (structural no-eager-import scan over src, child-process probes of both built dist formats, in-process lazy-load behavior) — verified to go red for each dep when its eager import is reintroduced.
4025
+
4026
+ - e82a495: Load the TypeScript compiler lazily in `validateReactPageProps` instead of at module top level (ADR-0081 Phase 2 follow-up).
4027
+
4028
+ `@objectstack/lint` sits on the kernel boot path, so the eager `import ts from 'typescript'` (framework#2482) made every boot parse the ~9 MB compiler (~70 ms+ on a warm laptop, worse on container cold starts) for a gate that only runs when a `kind:'react'` page is actually validated — a rare, trusted-tier case. It also hard-crashed boot in deployments that prune the package from the image (cloud's Docker pruner did exactly that; worked around in cloud#728).
4029
+
4030
+ - The compiler now loads on the first validated react-source page, via a deferred `createRequire` (same bundling-safe pattern as driver-sqlite-wasm's knex-wasm-dialect); the public API stays synchronous and unchanged.
4031
+ - Importing the package, and validating stacks with no react pages, no longer touches `typescript` at all — so images that prune it boot fine and only fail (with an actionable error naming the package and the fix) if a react-source page is actually validated.
4032
+ - `typescript` remains a regular dependency of `@objectstack/lint`.
4033
+ - Guarded by a three-level regression test (structural no-eager-import scan, child-process probes of both dist formats, in-process lazy-load behavior), verified to go red if the eager import is reintroduced.
4034
+
4035
+ - Updated dependencies [6a9397e]
4036
+ - Updated dependencies [c0efe5d]
4037
+ - @objectstack/spec@11.10.0
4038
+ - @objectstack/formula@11.10.0
4039
+ - @objectstack/sdui-parser@11.10.0
4040
+
4041
+ ## 11.9.0
4042
+
4043
+ ### Patch Changes
4044
+
4045
+ - Updated dependencies [d3595d9]
4046
+ - @objectstack/spec@11.9.0
4047
+ - @objectstack/formula@11.9.0
4048
+ - @objectstack/sdui-parser@11.9.0
4049
+
4050
+ ## 11.8.0
4051
+
4052
+ ### Patch Changes
4053
+
4054
+ - @objectstack/spec@11.8.0
4055
+ - @objectstack/formula@11.8.0
4056
+ - @objectstack/sdui-parser@11.8.0
4057
+
4058
+ ## 11.7.0
4059
+
4060
+ ### Minor Changes
4061
+
4062
+ - 5178906: ADR-0085: object presentation intent is declared as cross-surface semantic
4063
+ roles, never as per-surface hint blocks.
4064
+
4065
+ **@objectstack/spec**
4066
+
4067
+ - New top-level `stageField: string | false` — names the object's linear
4068
+ lifecycle field (`false` declares the status-like field non-linear and
4069
+ suppresses every consumer's stage heuristics). Legitimizes the key the UI
4070
+ runtime already read but the schema rejected.
4071
+ - `compactLayout` → **`highlightFields`** (the value is an ordered field
4072
+ list, not a layout; "highlight" is already the renderer-side term of art).
4073
+ `compactLayout` stays accepted as a parse-time alias and is preserved on
4074
+ output — the ADR-0079 `displayNameField → nameField` pattern.
4075
+ - `fieldGroups[].collapse: 'none' | 'expanded' | 'collapsed'` replaces
4076
+ `defaultExpanded` AND the UI-dialect `collapsible`/`collapsed` boolean pair
4077
+ (which had drifted two ways: spec declared a key no renderer read, renderers
4078
+ read keys the spec rejected). Old keys map onto the enum at parse and remain
4079
+ accepted for one minor.
4080
+ - `fieldGroups[].visibleOn` removed (no consumer anywhere — ADR-0049
4081
+ enforce-or-remove; re-add together with its enforcement when a surface
4082
+ evaluates it).
4083
+ - The `detail: { … }.passthrough()` UI-hints block is **removed**. Every key
4084
+ in it was either unauthorable, a proven no-op for spec authors
4085
+ (`hideReferenceRail` — the rail is default-off and its enabling key was
4086
+ never typed), or a per-page toggle that belongs to an assigned Page. Zero
4087
+ authors existed across framework and objectui (evidence in ADR-0085); the
4088
+ removal ships as a minor under the documented dead-surface exception
4089
+ (PR #2272 precedent).
4090
+ - New `deriveFieldGroupLayout(def)` in `@objectstack/spec/data` — the single
4091
+ source of the fieldGroups rendering semantics (declared order, empty groups
4092
+ dropped, ungrouped trailing bucket minus audit/system fields, collapse
4093
+ passthrough incl. deprecated aliases). UI renderers consume this instead of
4094
+ their two pre-existing near-identical local copies.
4095
+
4096
+ **@objectstack/lint / @objectstack/cli**
4097
+
4098
+ - New `validateSemanticRoles` (wired into `os lint`): warns on
4099
+ `Field.group` → undeclared group, declared-but-unreferenced groups, and
4100
+ `stageField`/`highlightFields` entries naming non-existent fields — the
4101
+ dangling-pointer shapes that are Zod-valid but silently inert at render
4102
+ time (ADR-0078 completeness gate).
4103
+
4104
+ **@objectstack/platform-objects**
4105
+
4106
+ - All 35 system objects renamed `compactLayout:` → `highlightFields:`
4107
+ (behaviour unchanged via the alias).
4108
+
4109
+ ### Patch Changes
4110
+
4111
+ - Updated dependencies [5178906]
4112
+ - @objectstack/spec@11.7.0
4113
+ - @objectstack/formula@11.7.0
4114
+ - @objectstack/sdui-parser@11.7.0
4115
+
4116
+ ## 11.6.0
4117
+
4118
+ ### Patch Changes
4119
+
4120
+ - @objectstack/spec@11.6.0
4121
+ - @objectstack/formula@11.6.0
4122
+ - @objectstack/sdui-parser@11.6.0
4123
+
4124
+ ## 11.5.0
4125
+
4126
+ ### Minor Changes
4127
+
4128
+ - 5a5bf61: ADR-0081 Phase 2: a build-time prop check for `kind:'react'` pages. After the
4129
+ syntax gate, `validateReactPageProps` parses the real JSX (TypeScript compiler)
4130
+ and checks each usage of an injected block (`<ObjectForm>`, `<ListView>`, …)
4131
+ against the react-tier contract (`REACT_BLOCKS` from `@objectstack/spec/ui`):
4132
+ missing a required binding (e.g. `<ObjectForm>` with no `objectName`) is an
4133
+ error; a near-miss prop (`onSucces` → `onSuccess`) is a warning. Wired into
4134
+ `os validate`. Curated data props are not flagged (low false-positive); a spread
4135
+ `{...props}` escapes the required check. (`typescript` moves to `@objectstack/lint`
4136
+ dependencies so it externalizes instead of bundling into the CLI.)
4137
+ - ec7175d: Add the source-page styling guardrail (ADR-0065): `os validate`/`os build` now flags Tailwind `className` in `kind:'html'`/`kind:'react'` page source, which silently produces no CSS because the build never scans authored metadata. New `validatePageSourceStyling` rule with an actionable inline-style/`hsl(var(--token))` fix; also corrects the react-blocks contract, the objectstack-ui skill, the layout-dsl docs, and ADR-0080/0081 away from the "HTML + Tailwind" framing.
4138
+
4139
+ ### Patch Changes
4140
+
4141
+ - Updated dependencies [6ee4f04]
4142
+ - Updated dependencies [c1e3a65]
4143
+ - @objectstack/spec@11.5.0
4144
+ - @objectstack/formula@11.5.0
4145
+ - @objectstack/sdui-parser@11.5.0
4146
+
4147
+ ## 11.4.0
4148
+
4149
+ ### Minor Changes
4150
+
4151
+ - 5821c51: ADR-0081: split the AI page-authoring surface into honest tiers.
4152
+
4153
+ - `PageSchema.kind` gains `'html'` and `'react'`. `'html'` is the constrained
4154
+ parse-never-execute tier (the renamed `'jsx'`, kept as a deprecated alias);
4155
+ `'react'` is the real-React tier (executed at render by
4156
+ `@object-ui/react-runtime`). It runs author JS, so it is gated by a host
4157
+ capability that **defaults ON** (the platform trusts reviewed, draft-gated
4158
+ authors) and is disabled **server-side** via the `OS_PAGE_REACT=off`
4159
+ env toggle. The completeness gate now requires `source` for all three kinds.
4160
+ - `@objectstack/cli` console serving injects the disable global into the served
4161
+ HTML when `OS_PAGE_REACT=off` (read per request, no rebuild).
4162
+ - `validate-jsx-pages` lints `html`/`jsx` (constrained parse). A new
4163
+ `validate-react-pages` transpiles `react` source with Sucrase (transpile-only,
4164
+ never executed) so syntax errors fail at `os build` instead of at render.
4165
+
4166
+ ### Patch Changes
4167
+
4168
+ - Updated dependencies [5821c51]
4169
+ - Updated dependencies [a0fce3f]
4170
+ - @objectstack/spec@11.4.0
4171
+ - @objectstack/formula@11.4.0
4172
+ - @objectstack/sdui-parser@11.4.0
4173
+
4174
+ ## 11.3.0
4175
+
4176
+ ### Minor Changes
4177
+
4178
+ - 58e8e31: feat(lint): ADR-0079 record-title gate — deprecate titleFormat + record-title validator
4179
+
4180
+ A record's human title is a structural invariant (ADR-0079): every object
4181
+ resolves a primary title from a real STORED field via `nameField` (the
4182
+ canonical pointer; `displayNameField` is the deprecated alias) or a
4183
+ deterministic derivation. This adds build-time diagnostics so `os build` /
4184
+ `os lint`, the MCP authoring surface, and hand-authoring all get the coverage
4185
+ cloud graph-lint already has (the ADR-0078 "not cloud-only" principle):
4186
+
4187
+ - `title-format-retired` — flags an object that declares a `titleFormat`. That
4188
+ key is a render-only template the server can neither return nor query;
4189
+ ADR-0079 retires it in favour of `nameField`. The schema still parses it
4190
+ (existing metadata keeps loading), so this is advisory, not an error.
4191
+ - `title-unresolvable` — flags an object whose title cannot be resolved from any
4192
+ stored field (`objectTitleCompleteness` reports `status: 'none'`).
4193
+
4194
+ `@objectstack/spec` carries the `titleFormat` `.describe()` deprecation note;
4195
+ the `@objectstack/cli` `lint` command wires the new validator into its run.
4196
+
4197
+ ### Patch Changes
4198
+
4199
+ - Updated dependencies [58e8e31]
4200
+ - Updated dependencies [b4a5df0]
4201
+ - @objectstack/spec@11.3.0
4202
+ - @objectstack/formula@11.3.0
4203
+ - @objectstack/sdui-parser@11.3.0
4204
+
4205
+ ## 11.2.0
4206
+
4207
+ ### Minor Changes
4208
+
4209
+ - 8ea1f4f: ADR-0080 M3b②: `os validate` / `os build` now parse `kind:'jsx'` page `source` via `@objectstack/sdui-parser` (new `validateJsxPages` lint rule) — malformed JSX fails loudly at author time (ADR-0078) instead of being stored and breaking only at render. Parse-level for now (syntax, tag matching, forbidden constructs like event handlers / dangerouslySetInnerHTML); full component/prop whitelist validation arrives once the registry manifest is threaded through `compile()`.
4210
+ - 21c37d8: ADR-0080 M3b① (consumption seam): the `os build` / `os validate` JSX gate now does **full component/prop validation** (unknown component, missing/wrong prop, bad enum, bindings) when a `sdui.manifest.json` is present at the project root — falling back to parse-level otherwise. `validateJsxPages` accepts an optional manifest; the validate command loads the file when present. Generating + shipping that manifest from the registry's public tier remains a build/CI step.
4211
+
4212
+ ### Patch Changes
4213
+
4214
+ - Updated dependencies [d0f4b13]
4215
+ - Updated dependencies [302bdab]
4216
+ - Updated dependencies [012c046]
4217
+ - @objectstack/spec@11.2.0
4218
+ - @objectstack/sdui-parser@11.2.0
4219
+ - @objectstack/formula@11.2.0
4220
+
4221
+ ## 11.1.0
4222
+
4223
+ ### Patch Changes
4224
+
4225
+ - Updated dependencies [ecf193f]
4226
+ - Updated dependencies [51bec81]
4227
+ - Updated dependencies [3e593a7]
4228
+ - Updated dependencies [63d5403]
4229
+ - @objectstack/spec@11.1.0
4230
+ - @objectstack/formula@11.1.0
4231
+
4232
+ ## 11.0.0
4233
+
4234
+ ### Patch Changes
4235
+
4236
+ - Updated dependencies [ab5718a]
4237
+ - Updated dependencies [4845c12]
4238
+ - Updated dependencies [c1a754a]
4239
+ - Updated dependencies [6fbe91f]
4240
+ - Updated dependencies [715d667]
4241
+ - Updated dependencies [5eef4cf]
4242
+ - Updated dependencies [72759e1]
4243
+ - Updated dependencies [6c4fbd9]
4244
+ - Updated dependencies [ef3ed67]
4245
+ - Updated dependencies [cd51229]
4246
+ - Updated dependencies [7697a0e]
4247
+ - Updated dependencies [e7e04f1]
4248
+ - Updated dependencies [cfd5ac4]
4249
+ - Updated dependencies [2be5c1f]
4250
+ - Updated dependencies [ad143ce]
4251
+ - Updated dependencies [5c4a8c8]
4252
+ - Updated dependencies [3afaeed]
4253
+ - Updated dependencies [8801c02]
4254
+ - Updated dependencies [3d04e06]
4255
+ - Updated dependencies [4a84c98]
4256
+ - Updated dependencies [d980f0d]
4257
+ - Updated dependencies [a658523]
4258
+ - Updated dependencies [82ff91c]
4259
+ - Updated dependencies [638f472]
4260
+ - @objectstack/spec@11.0.0
4261
+ - @objectstack/formula@11.0.0
4262
+
4263
+ ## 10.3.0
4264
+
4265
+ ### Minor Changes
4266
+
4267
+ - f75943a: feat(lint): SDUI styling validator (ADR-0065)
4268
+
4269
+ `validateResponsiveStyles` — a pure `(stack) => Finding[]` rule wired into
4270
+ `os validate` and `os compile`, so hand-authored and AI-generated pages are
4271
+ held to the same bar (ADR-0019). Catches the deterministic ways a
4272
+ `responsiveStyles` block silently fails: a styled node with no `id` (CSS can't
4273
+ be scoped → dropped) is an **error**; warnings cover Tailwind-in-`className`
4274
+ (silently dead in metadata), a smaller breakpoint with no `large` base, unknown
4275
+ CSS properties, and unknown/typo'd design tokens. Quality/visual judgement
4276
+ (is it ugly) is out of scope — that needs render + a VLM gate.
4277
+
4278
+ ### Patch Changes
4279
+
4280
+ - @objectstack/spec@10.3.0
4281
+ - @objectstack/formula@10.3.0
4282
+
4283
+ ## 10.2.0
4284
+
4285
+ ### Minor Changes
4286
+
4287
+ - 63f3219: feat(lint): extract static metadata validators into @objectstack/lint (ADR-0019 P3)
4288
+
4289
+ New public package `@objectstack/lint` holds the pure, build-time metadata
4290
+ validators as `(stack) => Finding[]` functions, so the same rules run wherever a
4291
+ stack can be assembled — the CLI's `os validate`/`compile` and any other
4292
+ consumer (notably AI-driven authoring), instead of being trapped in CLI
4293
+ internals where only the CLI could reach them.
4294
+
4295
+ First release moves the two validators the AI build needs:
4296
+
4297
+ - `validateWidgetBindings` — dashboard widget → dataset → measure/dimension
4298
+ reference integrity + measure-aggregation coherence (ADR-0021).
4299
+ - `validateStackExpressions` — CEL/predicate validity for field conditionals,
4300
+ sharing rules, action visible/disabled, lifecycle hooks (ADR-0032).
4301
+
4302
+ `@objectstack/cli` now imports both from `@objectstack/lint` (was `./utils/*`);
4303
+ pure move, no behavior change. Dependency direction is one-way `lint → spec`;
4304
+ the package never depends on a runtime and is never bundled into a frontend
4305
+ (that is why the validators do NOT live in the frontend-facing `@objectstack/spec`).
4306
+
4307
+ Filesystem-coupled checks (`lint-liveness-properties`) and CLI-command-coupled
4308
+ ones (`score` → `lintConfig`) deliberately stay in the CLI for now; they can
4309
+ move in a later increment.
4310
+
4311
+ ### Patch Changes
4312
+
4313
+ - Updated dependencies [b496498]
4314
+ - @objectstack/spec@10.2.0
4315
+ - @objectstack/formula@10.2.0