@objectstack/lint 17.2.0 → 17.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,3077 @@
1
1
  # @objectstack/lint
2
2
 
3
+ ## 17.4.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 954cb0b: feat(service-automation): an `assignment` value may be a CEL envelope — evaluated at run time, validated at `registerFlow`, `objectstack validate` and the runtime publish gate (#15137, the executor half of #14149)
8
+
9
+ <!-- adr-0087: not-required (no-migration-prescription) No authorable key is renamed, retired or re-typed: the `assignments` map and every value form it accepted still parse. The only newly refused shape is a malformed CEL value envelope, a spelling declared one day earlier in #15113 and offered by no authoring surface before it, so `objectstack migrate meta` has nothing to rewrite and this changeset carries no rewrite instructions for a consumer to follow. -->
10
+
11
+ **BREAKING** in the accept-set sense, landing in the launch window as `minor`
12
+ (the lockstep convention; the level also follows the 2026-09-04 bump ruling —
13
+ this adds `AutomationEngine.evaluateValueEnvelope` to a published surface, and an
14
+ additive widening is at least `minor`). No ADR-0087 conversion: no authorable key
15
+ is renamed or retired, and the shape this refuses was never a shape any surface
16
+ offered.
17
+
18
+ The maintainer's 2026-09-02 ruling on #14149 made an assignment value able to be
19
+ a CEL **value** expression, so the declared stdlib (`joinNonEmpty`, `map`, `size`
20
+ …) is finally reachable from metadata — until now CEL was only ever asked for a
21
+ boolean. The spec half landed the contract (PR #15113); this is the half that
22
+ makes it do something.
23
+
24
+ ```yaml
25
+ # before: written into the variable verbatim, and rendered by `notify` as
26
+ # {"dialect":"cel","source":"joinNonEmpty(...)"}
27
+ # now: evaluated — digest is "Renewal due\nInvoice overdue"
28
+ assignments:
29
+ digest: { dialect: cel, source: 'joinNonEmpty(rows.map(r, r.subject), "\n")' }
30
+ ```
31
+
32
+ - **Evaluated at run time.** The built-in `assignment` executor evaluates a
33
+ `value`-role envelope with the expression engine and assigns the result, in the
34
+ same CEL scope a flow predicate is evaluated in (one shared scope builder, so a
35
+ predicate and a value expression cannot disagree about what `rows` means). A
36
+ plain string keeps today's `{token}` interpolation, and every other literal is
37
+ still assigned as data.
38
+ - **Refused at three doors.** A malformed envelope now stops the flow registering
39
+ (`registerFlow` throws, the severity a malformed predicate gets) and surfaces as
40
+ a located `error` finding naming the node and the author's own variable —
41
+ `config.assignments.digest` — both at `objectstack validate` and at the runtime
42
+ publish gate a Studio / REST / MCP flow write goes through
43
+ (`validateStackExpressions` is registered `CLI_AND_RUNTIME`, `runtimeTypes:
44
+ ['flow']`). Malformed is a composition, not a fixed list: whatever
45
+ `AssignmentValueSchema` refuses in the envelope's shape — among them a missing,
46
+ empty or non-string `source`, a dialect other than `cel`, a non-object `meta` —
47
+ and then CEL that does not parse. All three doors derive that set from the same
48
+ two published validators, so none refuses a shape the executor would have run,
49
+ and a registered flow never faults for a shape those validators judge malformed.
50
+ Two shapes sit outside what either validator can judge — an `ast`-only envelope
51
+ and a whitespace-only `source` (it passes `min(1)` and reads as "not authored"
52
+ to the validator, while the CEL engine parses it untrimmed) — and those fault
53
+ loudly at run time rather than assigning a value. Both are pinned and tracked in
54
+ #15430.
55
+ - **Only the canonical map.** The ledger declares `assignment.assignments.*` and
56
+ nothing else, so the two legacy shapes the executor still normalizes — the
57
+ `assignments: [{ variable, value }]` array and the bare `{ <variable>: <value> }`
58
+ config — keep every meaning they had, envelope-shaped values included.
59
+ `AssignmentConfigSchema` is deliberately NOT wired into `parseNodeConfig` for the
60
+ array form: refusing it would break flows that register today, and that refusal
61
+ is a maintainer ruling rather than a lane's call (#15137 ask 3).
62
+
63
+ **What changes silently, and how far it reaches.** A flow that today authors an
64
+ envelope-shaped object *as data* in the canonical `assignments` map now evaluates
65
+ it — no error on either side, a different value. The discriminator is the spec's
66
+ own `isExpressionEnvelopeShaped`: a plain object naming a **string** `dialect`,
67
+ in the declared map only. Data that names no `dialect`, names a non-string one,
68
+ nests the envelope one level down, or sits in either legacy shape is untouched
69
+ and byte-identical. The remaining overlap — a well-formed
70
+ `{ dialect: 'cel', source: … }` written as data in the canonical map — is exactly
71
+ the spelling the ruling reinterprets; every near-miss the two validators can
72
+ judge now refuses loudly at registration instead of changing value in silence.
73
+ - 36a16d0: Two new widget-binding rule ids for a chart widget with an empty selection
74
+
75
+ `validateWidgetBindings` reported nothing about two dataset-bound chart shapes that the
76
+ `@object-ui` revision this repo pins (`.objectui-sha`) visibly degrades. Both are now
77
+ warnings, suppressible per widget with `suppressWarnings: ['<rule-id>']`:
78
+
79
+ - `chart-measures-missing` — a chart-family widget selects no measures (`values` empty or
80
+ absent). `DatasetWidget.tsx:683` returns the authoring placeholder "Pick measures
81
+ (values) for this dataset widget." before any query runs, above every family branch, so
82
+ no chart is drawn at all.
83
+ - `chart-dimensions-missing` — a chart-family widget selects at least one measure but no
84
+ dimensions. `DatasetWidget.tsx:423` reads
85
+ `const isMetric = METRIC_TYPES.has(widgetType) || dimensions.length === 0;`, so the
86
+ widget renders as a single KPI number and the declared chart family is silently ignored.
87
+ The hint steers the author to a dimension, or to the `metric`/`kpi` family that matches
88
+ what actually renders.
89
+
90
+ Warning tier rather than error for both: an empty selection is a work-in-progress state a
91
+ build must tolerate, and erroring would gate the `sys_metadata` publish path on a
92
+ half-authored widget. Neither shape is folded into `chart-config-missing` — neither is
93
+ caused by, nor repairable with, `chartConfig`, which carries presentation only.
94
+
95
+ "Chart family" is derived, not hand-listed: every declared `ChartTypeSchema` option that
96
+ the pinned renderer routes to its chart branch — the taxonomy minus the renderer's own
97
+ `METRIC_TYPES` (`metric`, `kpi`, `gauge`, `solid-gauge`, `bullet`) and its `table`/`pivot`
98
+ tabular test. A `metric` tile with no dimensions, such as the shipped `system_overview`
99
+ board's own KPI tiles, is therefore not a finding.
100
+ - c01b3a6: `chart-field-unknown` drops to `warning` on the three `chartConfig` binding keys the pinned renderer refuses, and says what actually happens
101
+
102
+ The rule id covers exactly three positions, and the `@object-ui` revision this repo pins (`.objectui-sha`) refuses all three as bindings, so none of them can produce the data failure the messages described:
103
+
104
+ - `chartConfig.xAxis.field` — `axisPresentation` (`@object-ui/core` `src/utils/chart-presentation.ts`) builds the axis presentation **minus** its `field`. The x-axis key is `buildChartSeries`' `xAxisKey`, i.e. the widget's `dimensions[0]`; an authored `field` re-points nothing.
105
+ - `chartConfig.yAxis[].field` — the same call, per entry. The entry keeps its slot (the count is what turns on a secondary axis) and its scale and chrome; only the binding is dropped.
106
+ - `chartConfig.series[].name` — `mergeAuthoredSeries` pairs an authored entry with the derived series whose `dataKey` it equals, one per entry of `values`. An entry naming no derived series is ignored whole, so the presentation hung on it — the mark, the colour, the stack, the axis side — lands on nothing.
107
+
108
+ The renderer pins this by name in `DatasetWidget.chartConfig.test.tsx` ("ignores an authored axis `field` and keeps the derived axis binding", "ignores an authored series and keeps one derived series per measure").
109
+
110
+ So the old message — "the query result will not contain it" — named a query failure that never happens, and `error` blocked a build and a Studio publish for a key that changes nothing at runtime. That is the class `widget-legacy-analytics-shape` reports at `warning` in the same file ("the dashboard renderer ignores them … a silent no-op"), and this id now carries the same tier, the same suppressibility (`suppressWarnings: ['chart-field-unknown']` per widget) and the same kind of sentence. Each message states its own consequence, because the axis positions and the series position are refused for different reasons.
111
+
112
+ The finding is **kept**, not deleted: unlike the `chart-config-missing` over-reach this measurement came from, the metadata really is wrong — the author wrote a binding and believes it is in force.
113
+
114
+ ## Migration
115
+
116
+ **A publish that used to be refused now succeeds.** Ruled 2026-08-15, `validateWidgetBindings` put its whole error set on the `sys_metadata` publish door (Studio / REST `/meta` / MCP) as one "this board cannot render" reference-integrity class. That class was six ids and is now five — `chart-field-unknown` has left it. A dashboard write whose only reference-integrity problem is a refused `chartConfig` binding key is no longer a 422 `INVALID_METADATA`; it publishes, and the finding rides the non-blocking `advisories` channel on the 2xx response instead. The other five (`widget-dataset-unknown`, `widget-dimension-unknown`, `widget-measure-unknown`, `widget-legacy-analytics-unrenderable`, `dashboard-filter-field-unknown`) are unchanged.
117
+
118
+ Same direction on the CLI: `os validate` / `os build` / `os lint` report the finding at `warning`, so a stack that used to fail the build over one of these keys now exits 0 with an advisory. If you were relying on the build to stop on it, add the key to your own gate, or fix the binding — the fix has not changed:
119
+
120
+ - point `xAxis.field` at a dimension the widget selects (or drop the key — `xAxis` carries presentation only);
121
+ - point `yAxis[].field` at a selected measure (or drop it — `yAxis[]` carries presentation only);
122
+ - name a selected measure in `series[].name`, remembering that post-cutover (ADR-0021) result rows are keyed by the dataset's measure **name** (`sum_amount`), not the base column (`amount`).
123
+
124
+ A deliberately inert key can be silenced per widget with `suppressWarnings: ['chart-field-unknown']`.
125
+ - 56fe8c2: A flow predicate authored as a CEL envelope is now refused at build time, instead of running unread by either validator.
126
+
127
+ A `predicate`-role expression slot holds **bare CEL text** — `DecisionConditionSchema.expression` is declared `z.string()`, and so is a screen field's `visibleWhen`. An author who instead wrote the `{ dialect, source }` expression *envelope* there reached a shape nothing could see: a flow node's `config` is an open `z.record(z.unknown())` that no Zod schema is parsed against, the unknown-key walk exempts the schemaless node types on purpose (`decision` publishes no descriptor `configSchema`), and the expression ledger's `predicate` arm skipped every non-string as "a type violation for the schema pass to report" — a schema pass that, for those node types, does not exist. `registerFlow` accepted the flow, `objectstack validate` reported nothing, and the evaluator was the only layer that ever read the predicate.
128
+
129
+ - `resolveFlowNodeExpressions` now emits a non-string sitting in a `predicate` slot, and the new `predicateSlotRefusal` / `PREDICATE_SLOT_STRING_REFUSAL` say why it is refused — one notion, derived once, read by both validators so build time and author time cannot disagree about the shape. `flow-template` slots keep the old rule: no validator implements that dialect, so a finding there is one nobody could judge.
130
+ - `registerFlow` throws, naming the node, the slot and the index, and attributing the finding to the envelope's own `source`. `objectstack validate` reports the same refusal as a located `error`.
131
+
132
+ **String predicates are untouched, deliberately.** A whitespace-only string still means "not authored" on both sides, exactly as before; what a non-empty string *says* is still judged by `validateExpression('predicate', …)`, brace trap and all. Only the shape moved.
133
+
134
+ An app that authored an envelope in one of these slots now fails to register with a message naming the slot; the fix is to write the predicate as bare CEL text (`record.rating >= 4`). The `{ dialect, source }` envelope remains the `value`-role spelling, on the `assignment` node's `assignments` map.
135
+ - d61bad0: New gating rule `flow-filter-token-unknown`: a `{…}` filter token in a flow node's `config.filter` that NEITHER `{…}` dialect can resolve is now an authoring-time `error`.
136
+
137
+ `filter-token-unknown` walks seven presentation collections and not `flows`, so `{TOMORROW()}` in a list view's filter failed the build while the identical string in a flow node's `config.filter` was silent — even though this package's other filter rules (`empty-combinator`, the preset-comparand rules) have reached flows all along.
138
+
139
+ The gap was not an oversight to close by adding a root. A flow node's filter is interpolated by the automation template evaluator **before** ObjectQL sees it, and only what that evaluator cannot resolve is handed on. Judging a flow filter against the ObjectQL vocabulary — the obvious one-line fix — reports every legitimate `{record.id}` and `{recordId}`: measured at **7 findings, all 7 false positives**, on this repo's own example apps. So the new rule is a second rule id with the flow dialect as its reference set, and `filter-token-unknown`'s surface list is untouched.
140
+
141
+ Reported (`error`): a call to a name in neither table — `{TOMORROW()}`, `{ROUND(x)}`, `{Math.round(x)}`, `{DATEADD(day, -45)}`. The flow template dialect's function vocabulary is closed (`round` / `floor` / `ceil` / `abs` / `min` / `max`, plus the whole-token `NOW()` / `TODAY()` with an optional `± N` day offset), and the evaluator already raises a guard refusal on anything else — so the node cannot run at all, and the build was shipping a flow whose runtime was already decided. This is the same severity axis `flow-template-unknown-field` applies at this exact position.
142
+
143
+ Silent, deliberately: `{TODAY() - 45}` and every other whole-token date form; `{$User.Id}`; `{current_user_id}` / `{today}` / `{30_days_ago}` and the rest of the filter placeholders; and every bare or dotted identifier (`{recordId}`, `{record.id}`, `{currentTask.id}`), which addresses the run's variable map — declared flow variables, node outputs, and the trigger record's own fields — and is not decidable from authored metadata.
144
+
145
+ Finding delta on this repo's example apps: **0**. Expect a new `error` only where a flow filter calls a function the evaluator would refuse at run time.
146
+ - a87163c: New advisory rule `field-no-consumers` (`validateFieldConsumers`): a field declared on an object that nothing in the stack reads or displays is reported as a `warning` by `os validate`, `os build` and `os lint`.
147
+
148
+ Until now such a field was schema-valid and passed every platform check — the declaration was inert and nothing in the toolchain said so. The rule is object-aware (the same field name on two objects gets two verdicts, resolved against the object whose declaration encloses each reference), and it distinguishes consumers from carriers: a view column, form section, page binding, flow node, dataset dimension, widget filter, formula, validation, hook or action is a consumer; a translation label, a seed value, an import-mapping column, a field-level permission grant or a flow that only writes the field is a carrier and never counts. The finding carries the verdict (`carrier-only` with the carrier paths a removal must clean, or `inert`), the roots scanned, and — when the name is also declared elsewhere — the other objects, so a per-object verdict is never mistaken for a name-level one.
149
+
150
+ Exempt, each derived from the spec rather than listed by hand: the registry-injected system columns an author re-declared, the record's title field (ADR-0079 `nameField` ladder), and `master_detail` fields (ADR-0035 — cascade delete, `controlled_by_parent` sharing and roll-ups read the relationship by declaration). A stack that declares no consumer root at all (objects only, or objects plus carriers) is not judged: its consumers live in another package. Test fixtures are never scanned.
151
+
152
+ Public surface: `validateFieldConsumers`, `FIELD_NO_CONSUMERS`, `FIELD_CONSUMER_ROOTS`, `FIELD_CARRIER_ROOTS`, and the `FieldConsumerFinding` / `FieldConsumerVerdict` / `FieldConsumerSeverity` types.
153
+ - 0cde37d: `objectstack lint` now judges hooks authored as inline `handler` functions with the same write-set rules it already applied to explicit `body` hooks.
154
+
155
+ The `hook-body-write-unknown-field`, `hook-body-write-unprovisioned-anchor`, `hook-body-source-unparseable`, `hook-api-update-readonly-field` and `hook-api-update-readonly-when-field` rules open on `body.language === 'js'`. A hook written as `handler: async (ctx) => { … }` carries no `body`, so on the stack `objectstack lint` handed the rule registry the whole family returned before reading anything — while the reference app authors every one of its hooks that way. `objectstack build` never had the gap: it lowers each inline handler to a metadata body before it parses and judges the lowered stack.
156
+
157
+ `objectstack lint` now hands the registry's parsed-tier rules that same lowered view (the `lowerCallables` pass the build runs), so a handler-authored hook writing a `readonly` field through `ctx.api` is refused by the pre-flight exactly as the build would refuse it. What this does and does not change:
158
+
159
+ - A config whose inline handler writes a `readonly: true` field via `ctx.api.object(...).update()` / `.updateById()` / `.insert()` — and does not declare `runAs: 'system'` — now fails `objectstack lint` with `hook-api-update-readonly-field` (exit 1). It already failed `objectstack build` with the same finding, so nothing that built green fails lint red.
160
+ - The warning-severity members of the family (`hook-body-write-unknown-field`, `hook-api-update-readonly-when-field`, …) now report on inline handlers too; they never fail a run without `--strict`.
161
+ - Nothing about what `objectstack build` accepts changes, and `objectstack validate` — which parses without lowering — is unchanged and still does not see handler-authored hooks; both are recorded in the rules' headers.
162
+ - The lint input is never mutated: rules that read the live function value (`hook-body/not-lowerable` and its siblings) keep seeing it, and a handler the extractor refuses has no body on any command, so no rule guesses about a body that was not produced.
163
+
164
+ Measured on this repository's own four example apps (`examples/app-crm`, `app-showcase`, `app-todo`, `app-multi-package`), before and after: **121 findings before, 121 after — row for row identical, and zero at `error` on both sides.** No config that passes today starts failing. Two of the six hooks in that corpus are `handler`-authored and were invisible to this family before; their bodies write nothing the family objects to, which is why the delta is zero rather than the family being unreached. The reach itself is pinned separately, with a body-authored control beside every leg.
165
+
166
+ `@objectstack/lint` carries only the header ledger recording which *intakes* reach each hook rule — the call sites of `runAuthoringRules`, which are more numerous than the three commands (the scaffold validator is a fourth, and it has always reached this family). Its behaviour is unchanged.
167
+ - 9f890d3: `filter-preset-comparand` gains a FIELD-TYPED arm (#16106, maintainer-ruled 1′): on a declared `date` / `datetime` field, a dashboard date-range preset name (`last_30_days`, `this_quarter`, …) is now refused in EVERY comparand position — bare (implicit equality), `$eq` / `$ne`, `$in` / `$nin`, and their view-rule (`equals` / `not_equals` / `in` / `not_in`) and triple (`=` / `!=` / `in` / `nin`) spellings — with the same located message and prescription the ordering positions already carry (`{ $gte: '{30_days_ago}' }` for `last_30_days`, and so on). The field type is read from the stack's own object graph: a dashboard widget or report through its `dataset` to that dataset's `object`, a view through `data.object`, a flow CRUD node through `config.objectName`, a page component through `dataSource` / `properties`, an object's own list views and `relatedListFilter`, a summary field's child object. A position the graph cannot bind, a registry-injected column, a `time` field, or a select / text column stays unjudged — equality against a picklist value that collides with a preset name is a working filter. The field-agnostic schema door in `@objectstack/spec` keeps its ordering-only boundary unchanged; this closes the authoring-time gap where `objectstack lint` and the runtime publish gate accepted a filter the engine then refused with `INVALID_FILTER` / 400 on first render.
168
+ - 720bf47: `flow-update-readonly-field` and `hook-api-update-readonly-field` now report a non-system **create** of a static-`readonly` field — a new **error**-severity finding that fails `os lint` / `os validate` / `os build` on a shape they used to accept.
169
+
170
+ Both rules scanned only the update verb (`update_record`; `ctx.api…update()` / `.updateById()`) and justified the omission with the same sentence: INSERT is engine-exempt from the author-declared `readonly` strip, so a create that seeds a `readonly` column is not a no-op. The maintainer ruling of 2026-09-03 (option C, #14147) made that false — `engine.insert` now runs the same `isSystem`-gated `stripReadonlyFields` the update path runs — so a flow `create_record` without `runAs: 'system'`, or a hook body's `ctx.api.object('…').insert()` under a non-system trigger, that writes a `readonly` field became a **silent no-op**: the row lands without the column (which falls back to its `defaultValue`), the step reports `success`, and only a run-time warning names the dropped field (measured end to end in `@objectstack/service-automation`'s `create-record-readonly-drop.test.ts`). Nothing reported it at build time. This closes that scan gap (#15394).
171
+
172
+ **What now fails that passed before.** Exactly one new shape per rule, at `error`:
173
+
174
+ - a flow `create_record` node whose literal `fields` map writes a field the target object declares `readonly: true`, on a flow that does not declare `runAs: 'system'`;
175
+ - an L2 hook body's literal `ctx.api.object('<name>').insert({ … })` writing such a field, on a hook that does not declare `runAs: 'system'`.
176
+
177
+ The rule ids and severities are the update ones — one id per shape, not per verb — and each finding's message names the verb it was judged on and what actually happens to a create. Everything the rules already skipped is still skipped: a templated object name, a non-literal payload, an object outside the stack or declaring no fields, an unknown field (the unknown-field rules' question), and any `runAs: 'system'` flow or hook, because seeding a `readonly` column at create time is a system act and that write lands.
178
+
179
+ **Deliberately not reported.**
180
+
181
+ - No `readonlyWhen` (conditional) finding on a create, on either surface: a conditional lock is evaluated against the record being written over, which a create does not have, and the engine runs no conditional strip on INSERT ("INSERT stays exempt"). A warning there would state something false about a write that lands.
182
+ - The hook rule judges `.insert()` only, not `.create()`. The host `ObjectRepository` aliases `create()` to `insert()`, but L2 bodies run in QuickJS and the VM-side `ctx.api.object()` installs no `create` leaf — a body calling `.create()` throws `TypeError: not a function` on its first run, a loud failure rather than the silent drop this rule reports. The silence is recorded as a reasoned method exclusion (`READONLY_HOOK_METHOD_EXCLUSIONS`) and pinned.
183
+ - No create finding on a **platform object** — one declaring `managedBy`, or in the reserved `sys_` namespace. The engine's create-side strip does not judge those at all (`staticReadonlyInsertSubject`: their own ADR-0086 write guard governs them), so a finding there would describe a strip that never runs. The update verb keeps judging them, exactly as the engine's update path does.
184
+ - `validate-readonly-action-writes` is unchanged: an action body runs system-elevated by design, so its create genuinely lands.
185
+
186
+ **Migration.** If your build reds on the new finding, the fix is one of: declare `runAs: 'system'` on the flow or hook when seeding the `readonly` column is the intent (the intended channel — `readonly` governs the end-user/API surface, not trusted system writers); remove the key from the `create_record` `fields` / `insert()` payload when it is not; or stamp it in a `beforeInsert` hook on the target object (`ctx.input.<field> = …`), which is a server value the strip does not touch. Measured over this repository's shipped examples (`app-crm`, `app-showcase`, `app-todo`): zero in-repo flows or hooks go red — the two `create_record` nodes that target an object carrying a `readonly` field write none of its `readonly` fields, and the one flow that creates unauthenticated already declares `runAs: 'system'`; no shipped hook body inserts through `ctx.api`.
187
+ - b4b37e5: The object publish door now refuses an object whose `searchableFields` entry, or whose built-in list view's `columns` (and every other field-naming position on that list view), names a field the object does not have.
188
+
189
+ `#15254` closed this one key over: it crossed the reference-integrity suite onto the object write door for the object's own field-name **lists** (`highlightFields`, `publicSharing.redactFields`). The two members that read the *other* field surfaces an object carries — its ADR-0061 search set and its built-in `listViews` — still declared `runtimeTypes: ['flow', 'view']`, so on the only door a Studio, REST `/meta` or MCP author has they never judged the snapshot that arrived. An object could publish clean with `searchableFields: ['gone_field']` or a list-view column resolving to nothing, and both fail the same silent way downstream: the engine filters a stale search entry out without a word (`resolveSearchFields`), so `$search` scans a narrower set than declared — or, once every entry is stale, the auto-default set the author never chose — and a dangling column renders one field short.
190
+
191
+ - **`validateSearchableFields` and `validateListViewFieldRefs` gain `object`** in their suite-member `runtimeTypes`. No new rule and no new finding class: the rule ids (`searchable-field-unknown`, `searchable-field-unsearchable`, `list-view-field-unknown`, `list-view-field-dotted`) and their severities are unchanged — they now reach the door where the author actually is.
192
+ - **The crossing carries the #9313 precondition.** Both members resolve only against `stack.objects`, the one collection every per-write snapshot carries, so neither opens a missing-collection false-positive channel; their `views[]` rungs simply find no `stack.views` on an object snapshot.
193
+ - **Measured before crossing**, at the door's own snapshot shape and differential, over every shipped object definition in the monorepo: 116 objects (platform-objects 48, showcase 24, plugins 19, services 12, crm 6, metadata-core 5, todo 1, qa 1), 105 built-in list views on 40 objects, 666 list-view field-naming positions and 5 `searchableFields` entries judged — **0 findings for both members, precision 1.0**, against synthetic probes that are refused.
194
+ - **`validateSortableFields`, the third sibling, is deliberately not crossed** — it measured equally clean, but that crossing is its own adjudication.
195
+
196
+ ## Migration
197
+
198
+ **A publish that used to succeed can now be refused (HTTP 422, `INVALID_METADATA`).** The receipt names the rule id and the offending path, name-keyed on the wire — for example `objects.proj_task.searchableFields[1]` or `objects.proj_task.listViews.all.columns[1]` — plus the string that was written and the fields the object actually has.
199
+
200
+ To fix a refusal, do one of:
201
+
202
+ - rewrite the entry to the field's current API name (after a Studio label edit the derived name is the one to use — `field_10` becomes `health_score`); or
203
+ - drop the entry from the declaration; or, for `searchable-field-unsearchable`, target a text-like stored column instead of a virtual or non-scannable one.
204
+
205
+ `os validate` / `os build` / `os lint` already reported these findings at the same severity, so a code-authored stack can be repaired before it reaches a publish. Objects that name a platform-injected system column are unaffected — both members resolve those per object and stay silent where the platform really provisions them.
206
+ - e0af1a8: feat(spec)!: `<ListView objectName>` / `<ListView viewType>` are retired from the react-tier component contract — `data={{ provider: 'object', object }}` / `type` are the only spellings (#14791)
207
+
208
+ <!-- adr-0087: registered ui-react-list-view-binding-aliases-retired -->
209
+
210
+ **BREAKING** — an accept-set narrowing on a published contract. The `REACT_BLOCKS`
211
+ ListView entry no longer publishes the `objectName` and `viewType` overlay props that
212
+ #11284 had deprecated in favour of ListViewSchema's own `data` / `type`: the generated
213
+ contract (`skills/objectstack-ui/references/react-blocks.md`) drops both rows, and
214
+ `@objectstack/lint`'s `validate-react-page-props` now REFUSES either spelling on a
215
+ `kind:'react'` page with a new `react-prop-retired` error that carries the fix, where it
216
+ used to warn and accept. Shipped as `minor` under the repo's launch-window convention for
217
+ breaking changes; the hand-migration prescription is registered under protocol major 18
218
+ (`ui-react-list-view-binding-aliases-retired`). Maintainer ruling on #14791 (2026-09-07,
219
+ director seat summon #17, decision batch #1, option B — retire now, no deprecation window,
220
+ 「同意」).
221
+
222
+ ## FROM → TO
223
+
224
+ | you wrote | write instead |
225
+ |:--|:--|
226
+ | `<ListView objectName="account" … />` | `<ListView data={{ provider: 'object', object: 'account' }} … />` |
227
+ | `<ListView viewType="kanban" … />` | `<ListView type="kanban" … />` |
228
+ | `<ListView … />` with no binding at all | add `data={{ provider: 'object', object: '…' }}` — it is the required binding on a react page |
229
+
230
+ One-line fix: on every `<ListView>` in react page source replace `objectName="X"` with
231
+ `data={{ provider: 'object', object: 'X' }}` and `viewType="K"` with `type="K"`, then re-run
232
+ `objectstack validate` — a leftover alias is reported as `react-prop-retired` with this
233
+ same prescription, and a list with no data source as `react-prop-missing-required`.
234
+
235
+ ## Why now, and why no window
236
+
237
+ The contract deprecated both aliases (#11284) while objectui's ListView still read only
238
+ `objectName`, so a page written the canonical way validated green and rendered an empty
239
+ list. That consumer half has landed and ships in the console this repo pins
240
+ (`normalizeListViewSchema` at `a472b071` folds `data.provider === 'object'` onto the key
241
+ the renderer reads and takes the author's `type` for the view kind), so both spellings
242
+ render today — and the maintainer's standing rule for a spelling with zero external
243
+ authors is to retire it at once rather than keep two vocabularies alive.
244
+
245
+ ## What else moved
246
+
247
+ - `REACT_RETIRED_OVERLAY_PROPS` is a new export of `@objectstack/spec/ui`: the tombstone
248
+ ledger (prop → replacement + one-line fix) the lint quotes, the react-tier twin of a
249
+ metadata schema's `retiredKey()`.
250
+ - `data` is restated on the ListView overlay as its **required** binding (ledgered in
251
+ `REACT_OVERLAY_SHADOWS`), so the generated contract marks it ✓ and a `<ListView>` with no
252
+ data source is refused — the check the required `objectName` used to carry.
253
+ - `REACT_RECORD_BLOCK_ALTERNATIVES['record:related_list']` prescribes the canonical spelling.
254
+ - The showcase pages (`crm-workbench`, `renewals-pipeline`, `task-desk`), the published
255
+ `objectstack-ui` skill and the react-pages / validating-metadata guides write the
256
+ canonical spelling; `@objectstack/lint` exports `REACT_PROP_RETIRED`.
257
+ - 9408b7f: A flow condition that is neither CEL text nor an expression is now refused at build time, instead of being read as an empty condition and answering a silent `false`.
258
+
259
+ `evaluateCondition` derives its source as `typeof expression === 'string' ? expression : (expression?.source ?? '')`. For a value that is neither — a number, a boolean, an array — the read yields `undefined`, the `??` supplies `''`, and the empty-source arm returns **`false`**: the "an unauthored branch must not open" rule, applied to a value that was very much authored. Measured: a `decision` node carrying `config: { condition: 42 }` **registered clean** and executed `success: true` with nothing said at any layer; `{ source: 1 }` did not even get that far and threw a bare `TypeError: exprStr.trim is not a function` out of the validator. `config.condition` is also the key a **start node's trigger gate** is read from, so the same value could gate a whole flow shut forever with no signal to the author.
260
+
261
+ - The new `structuralConditionRefusal` / `STRUCTURAL_CONDITION_SHAPE_REFUSAL` in `@objectstack/spec/automation` are the single shared notion of why, read by both validators so build time and author time cannot disagree about the shape. `registerFlow` throws, naming the node or edge and attributing the finding; `objectstack validate` reports the same refusal as a located `error`.
262
+
263
+ **This is deliberately NOT the `predicate`-slot rule, and the difference is measured.** A ledger `predicate` slot (`decision.conditions[].expression`, a screen field's `visibleWhen`) is declared `z.string()`, so `PREDICATE_SLOT_STRING_REFUSAL` refuses every non-string including an envelope. Neither structural slot is declared that way: `FlowEdgeSchema.condition` is `ExpressionInputSchema`, whose string arm **transforms into** `{ dialect: 'cel', source }` — so after `FlowSchema.parse` every authored edge condition *is* an envelope — and `FlowNodeSchema.config` is an open `z.record` that passes an envelope written at `config.condition` through verbatim, where `evaluateCondition` evaluates it correctly. Both shapes stay accepted here; an envelope with no `dialect`, and an `ast`-carrying one (`ExpressionSchema`'s own `source`-or-`ast` rule), stay accepted too.
264
+
265
+ **Strings are untouched, deliberately.** A whitespace-only condition still means "not authored" and still answers `false` on both sides — consistent behaviour, ruled correct, not a defect. What a non-empty string *says* is still `validateExpression('predicate', …)`'s verdict, brace trap and all. Only the shape moved.
266
+
267
+ An app that authored a number, a boolean, an array or a source-less object in a node or edge `condition` now fails to register with a message naming the site; the fix is to write the condition as bare CEL text (`record.rating >= 4`) or as an expression envelope.
268
+ - 615fac3: A publish now refuses an object whose `highlightFields` names a field that does not exist on it — the same gate that refuses a code-authored stack.
269
+
270
+ `list-view-field-unknown` inspects `view.columns`, and Studio's app builder mints no `view` items at all, so the reference-integrity family had nothing to inspect on the only artifacts the click path authors. What it authors is the **object**, and an object-level field-name list was covered by nothing that could refuse: measured on `origin/main`, `runtimeAuthoringRulesFor('object')` dispatched seven rules with no reference-integrity rule among them, while the object-level existence check that did exist (`semantic-role-field-unknown`) is `warning`, advisory-tier and CLI-only. So `os validate` exited 0 on a dangling reference and the runtime publish door — the only door a Studio, REST `/meta` or MCP author has — said nothing at all.
271
+
272
+ The reproduction is the natural click order, not a contrived one: click-create a field (Studio mints it as `field_10`), add it to `highlightFields`, then give it a label — the API name auto-derives to `health_score` and `highlightFields` keeps `field_10`. Anyone who names a field after placing it produces this.
273
+
274
+ - **New rule `object-field-ref-unknown` (`error`)**, in `@objectstack/lint`, over the object-level field-name **lists** that no rule owned: `highlightFields` (ADR-0085) and `publicSharing.redactFields`. It resolves through the same `object-graph` seam as the rest of the family, so the three shared skips hold — an object outside the stack, an object with no readable field map (ADR-0015 `external`), and a registry-injected system column resolved **per object** (`highlightFields: ['owner_id']` is a live pointer on an owned object and a real miss under `ownership: 'none'`).
275
+ - **It runs on the runtime publish door.** The reference-integrity suite entry's `runtimeTypes` gains `object`, and the suite's per-member declaration keeps the crossing narrow: this is the only member that judges an object snapshot; every other member keeps `['flow', 'view']` or the frozen `['flow']` default.
276
+ - **`validateSemanticRoles` keeps the provenance question** at the same position (`semantic-role-field-unprovisioned`, still `warning`) and no longer restates existence — one finding per path, at one tier.
277
+ - **`probes.checked` gained an `objects` counter.** Its absence was the tell: a receipt reading `{seeds: 0, views: 0, widgets: 0}` was accurate while the objects the package published were probed by nothing.
278
+
279
+ ## Migration
280
+
281
+ **A publish that used to succeed can now be refused (HTTP 422, `INVALID_METADATA`).** The receipt names the rule id `object-field-ref-unknown` and the offending path, name-keyed on the wire — for example `objects.proj_task.highlightFields[1]` — plus the string that was written and the fields the object actually has.
282
+
283
+ To fix a dangling reference, do one of:
284
+
285
+ - rewrite the entry to the field's current API name (after a Studio label edit the derived name is the one to use — `field_10` becomes `health_score`); or
286
+ - drop the entry from the list.
287
+
288
+ `os validate` / `os build` / `os lint` report the same finding at `error`, so a stack can be repaired before it reaches a publish. If an object legitimately points at a platform-injected system column, no change is needed — the rule resolves those per object and stays silent where the platform really provisions them.
289
+ - 6b7d709: `objectstack validate` now lowers hooks authored as inline `handler` functions to a metadata body before it parses, so the hook write-set rules judge them there exactly as `objectstack build` and `objectstack lint` already do.
290
+
291
+ The `hook-body-write-unknown-field`, `hook-body-write-unprovisioned-anchor`, `hook-body-source-unparseable`, `hook-api-update-readonly-field` and `hook-api-update-readonly-when-field` rules open on `body.language === 'js'`. A hook written as `handler: async (ctx) => { … }` carries no `body`, and `objectstack validate` parsed the normalized stack without lowering — so on that command the whole family returned before reading anything, and a stack `objectstack build` refuses with `hook-api-update-readonly-field` (exit 1) passed `objectstack validate` with exit 0 and no finding. The same statement authored as an explicit `body: { language: 'js', source }` was refused by `objectstack validate` all along, so the silence was the command's intake, not the rule.
292
+
293
+ `objectstack validate` now runs the same `lowerCallables` pass `objectstack build` runs before its parse — after its two pre-parse undeclared-key lints, which keep reading the un-lowered stack, and before the schema parse, which reads the lowered view — and hands the rule registry the parsed result as before. This moves what `objectstack validate` accepts in **both** directions, and both are parity with `objectstack build`:
294
+
295
+ - **Narrowing (hooks).** A config whose inline handler writes a `readonly: true` field via `ctx.api.object(...).update()` / `.updateById()` / `.insert()` — and does not declare `runAs: 'system'` — now fails `objectstack validate` with `hook-api-update-readonly-field` (exit 1). It already failed `objectstack build` and (since #16095) `objectstack lint` with the same finding, so nothing that builds green starts failing `objectstack validate`.
296
+ - **Widening (actions, and a nameless `functions` array entry).** A plain-object config carrying an inline action `target` callable — `actions: [{ name, label, target: async (ctx) => { … } }]`, or the same on `objects[*].actions[*]` — was **refused** by `objectstack validate` before this change: `ActionSchema.target` is a string, and nothing lowered the function before the parse, so the run exited 1 with `invalid_type` at `actions.0.target` (measured through the real CLI: `valid=false errors=2 invalid_type@objects.0.actions.0.target | invalid_type@actions.0.target`). The same pass now lowers it to a ref string plus `body` on this command too, so `objectstack validate` **accepts** it (exit 0, `valid: true`) — exactly as `objectstack build` accepted it all along (exit 0 on both sides). Likewise a nameless `functions` **array** entry, `functions: [{ handler: async (ctx) => { … } }]`, which the same pass names `anon_fn`: the array form requires `name`, so `objectstack validate` refused it at the parse (measured: `valid=false errors=1 invalid_union@functions`, exit 1) and now accepts it (exit 0, `valid: true`), as `objectstack build` did (exit 0 on both sides). The `functions` map forms and `hooks[*].handler` parse either way and are not affected. These are accepted-set relaxations on a published command; they are declared here rather than inferred from the build's behaviour, and pinned beside the hook legs.
297
+ - The warning-severity members of the family now report on inline handlers under `objectstack validate` too; they fail a run only with `--strict`, as every other advisory does.
298
+ - The `--json` payload gains no key and the text face prints no new step: the lowering is a view for the parse and the rule registry. A handler the extractor cannot lower (a forbidden token, a module-scope identifier) has no body on any command and is reported by `objectstack lint`'s `hook-body/*` rules and `objectstack build`'s warn-and-bundle line, never guessed at here.
299
+ - Nothing about what `objectstack build` accepts changes; on both axes above `objectstack validate` now agrees with it.
300
+
301
+ Measured on this repository's ten `objectstack.config.ts` corpus files at `6ba0db4e0` with `objectstack validate --json`, before and after: **exit code, error text and rule-id list identical on 10 of 10 — zero findings change, zero verdicts change.** Six reach the rule registry (the four example apps and the `plugin-auth` / `plugin-security` / `service-i18n` configs); two (`driver-memory`, `plugin-hono-server`) are plugin manifests, not stacks, and are refused at the schema parse — after the lowering point — with the same top-level `unrecognized_keys` on both sides; two (`app-showcase`, the `blank` template) fail at load in the measuring environment, before the lowering point, on both sides. None of the repository's handler-authored hooks writes through `ctx.api`, and none of the ten carries an inline action `target` callable, which is why the delta is zero on both axes rather than either being unreached — a corpus with neither shape cannot see either limb, so both are pinned on their own fixtures; the reach itself is pinned by the card's own fixture, with the body-authored control beside it and a handler-authored hook the family has nothing to say about still passing.
302
+
303
+ `@objectstack/lint` carries only the header ledger recording which intakes reach each hook rule; `objectstack validate` moves from "not reached" to "reached". Its behaviour is unchanged.
304
+ - cd55558: `widget-measures-missing` — the empty-measure selection is reported on every widget family, not just charts
305
+
306
+ `chart-measures-missing` (#15462) reported the authoring placeholder only for the chart
307
+ family, but the return that produces it is type-independent. At the `@object-ui` revision
308
+ this repo pins (`.objectui-sha` = `a472b0716`), `packages/plugin-dashboard/src/DatasetWidget.tsx:683`
309
+ reads `if (values.length === 0)` and returns *"Pick measures (values) for this dataset
310
+ widget."* ABOVE `isMetric` (`:423`, over `METRIC_TYPES` at `:343`), `isTable` (`:424`) and
311
+ the chart branch alike. So a `metric`, `kpi`, `gauge`, `solid-gauge`, `bullet`, `table` or
312
+ `pivot` widget that selects no measures renders the same placeholder — the KPI number or
313
+ the table the author declared is not drawn at all — and nothing reported it:
314
+ `table-count-only` requires `values.length > 0` before it looks, and the rules that iterate
315
+ `dimensions[]`/`values[]` are silent on an empty array by construction.
316
+
317
+ - **New id `widget-measures-missing`** — a NON-chart declared widget type selects no
318
+ measures. Warning tier, suppressible per widget with
319
+ `suppressWarnings: ['widget-measures-missing']`, exactly as the chart-family id is. The
320
+ message states the consequence its family actually has (the single KPI number is not
321
+ drawn / no table is rendered) and the hint names the dataset's declared measures.
322
+ - **`chart-measures-missing` is unchanged** — same id, same chart-family population, same
323
+ message and same suppression. The condition split rather than widened because "chart"
324
+ stops naming it once the population is every family, while the old id is reachable from
325
+ the package barrel (a public-surface contract) and may already be written into a board's
326
+ `suppressWarnings`.
327
+ - `chart-dimensions-missing` stays chart-family only: a dimensionless `metric` or `table`
328
+ is what those families are for.
329
+
330
+ The two never double-report one widget, in the pin's own order: the measures check runs
331
+ before the dimensions one, and `table-count-only` already skips an empty selection.
332
+
333
+ ### Patch Changes
334
+
335
+ - 347b777: `chart-config-missing` no longer fires on a widget whose binding the renderer derives
336
+
337
+ The rule warned on every chart-family widget that declared no `chartConfig`, on the
338
+ stated grounds that "the renderer cannot determine which measure to plot, so the series
339
+ renders empty". Measured against the `@object-ui` revision this repo pins
340
+ (`.objectui-sha`), that consequence is false: `DatasetWidget` derives the x-axis key and
341
+ one series per measure from the widget's own `dimensions` / `values` via
342
+ `buildChartSeries`, and refuses an authored `ChartAxis.field` / `ChartSeries.name`
343
+ outright — `chartConfig` carries presentation only. The renderer pins this by name:
344
+ "ignores an authored axis `field` and keeps the derived axis binding", "ignores an
345
+ authored series and keeps one derived series per measure", "emits none of the
346
+ presentation keys when no chartConfig is declared".
347
+
348
+ The false finding was landing on this platform's own shipped metadata — the
349
+ `system_overview` dashboard's pie and bar tiles, on the Setup board every customer opens
350
+ first — which is the ADR-0072 D1 cost the rule family exists to avoid.
351
+
352
+ The rule id is unchanged and keeps one true arm: a `combo` widget with no `chartConfig`,
353
+ whose per-series mark is authored as `chartConfig.series[].type` and has no other
354
+ channel, so every measure draws with the same default mark and the chart is not a
355
+ combination at all. Its message now names that consequence instead of the binding.
356
+ An existing `suppressWarnings: ['chart-config-missing']` entry stays valid.
357
+ - a51eb86: `chart-measure-unknown` no longer blocks a build over a chart `series[].name` (or a page chart's `yAxis[].field`) that names nothing — those positions are presentation, and the message now says so.
358
+
359
+ The rule fired at `error` on every measure position of the three chart surfaces it covers, with one consequence sentence: *"result rows are keyed by MEASURE NAME … so this series comes back empty"*. Read at the `@object-ui` revision this repo pins (`.objectui-sha`), that is true only where the position feeds the dataset query, and the three surfaces do not agree:
360
+
361
+ - **Report charts** run the chart's own query out of the two axis strings (`useDatasetRows(dataset, [xAxis], [yAxis], …)` — *"the embedded chart queries only `chart.xAxis` × `chart.yAxis`"*), so `chart.xAxis`/`chart.yAxis` are the binding. `chart.series[]` is *"the author's per-chart override for ONE measure's display name"*, lowered through `mergeAuthoredSeries`, where *"an authored entry naming a measure that is NOT in the dataset selection is **ignored** — membership belongs to the dataset"*.
362
+ - **List-view charts** have no presentation position at all: `ListChartConfigSchema` is a strict object of `chartType`/`dataset`/`dimensions`/`values`, and `values[]` is handed to the chart as the dataset measures.
363
+ - **Dataset-bound page chart components** query `{ dimensions, measures: values }` and then replace the authored series wholesale with one derived entry per selected measure, so `properties.series[].name` reaches the renderer not at all and `properties.yAxis[].field` re-points nothing.
364
+
365
+ **Behaviour change users see:** the three presentation positions — report `chart.series[].name`, page-component `properties.series[].name` and `properties.yAxis[].field` — drop from `error` to `warning`. A build or a metadata publish that used to be refused because of one of them now succeeds, with the finding on the advisory channel. The finding is KEPT, not deleted: the metadata really is wrong — the author wrote a key and believes it is in force. Every query position (report `chart.yAxis`, and `values[]` on all three surfaces) keeps `error` and its existing message verbatim.
366
+
367
+ Two smaller corrections ride along, both from the same read:
368
+
369
+ - The page surface's `yAxis[].field` refs are no longer concatenated into the `series[]` limb before the measure walk, so an axis position no longer takes the series message. Reading both shapes on that surface stays deliberate; giving them one sentence was not.
370
+ - `chart-axis-not-selected` (a declared measure outside the selection) took the same one-size consequence, *"the query does not return it, so the series plots nothing"*. It keeps that wording at a query position and states the real one at a presentation position, where no series is derived for the name in the first place.
371
+
372
+ Note that none of these three surfaces declares `suppressWarnings` — it is a dashboard-widget key — so the new advisories cannot be individually silenced; the hint says so instead of pointing at a key that does not exist.
373
+ - 693fbcb: `dashboard-action-route-unresolved` now resolves the `apps/NAME` head of a dashboard header action's `url` target against `stack.apps`, and reports every unresolved `<collection>/<name>` segment in the path rather than stopping at the first one it recognizes.
374
+
375
+ Before this, `URL_COLLECTION_TO_STACK_KEY` had no `apps` entry, so an `actionUrl` like `/apps/no_such_app_nope/crm_lead` was never checked at all — a dashboard button pointing at an app that does not exist passed lint clean. Worse, once a bad app name was combined with a second bad segment later in the same path (e.g. `/apps/no_such_app_nope/dashboard/no_such_dashboard_nope`), the old loop returned at the FIRST recognized segment and reported only that one — so a bad app name plus a bad dashboard name reported only the dashboard, never the app.
376
+
377
+ **Behavior change on paths that used to pass clean:** the loop no longer stops scanning a path the moment it recognizes one collection segment, resolved or not. A path like `/dashboards/exec/views/bad_view` — where `exec` is a real dashboard but `bad_view` names no view — used to report nothing (the loop returned as soon as `dashboards/exec` resolved, never reaching `views/bad_view`); it now reports one warning on the `views/bad_view` segment. Any stack with a dashboard `url` action whose path recognizes a valid collection segment followed later by an unresolved one will see a NEW warning here that did not fire before. This is intentional — it is the same false-affordance category the rule already exists to catch — but it is a real, visible change to what a clean `lint` run reports on such stacks, not a pure addition.
378
+ - 86c75f4: `firstUndeclaredReference` now documents the side of its contract it was silent about: it can false-NEGATIVE, and a `null` is "nothing was reported", not "every reference is rooted".
379
+
380
+ The existing sentence — "Acts ONLY on cel-js's `Unknown variable: X` fault, so it cannot false-positive on arithmetic/comparison overloads" — is true, and stays. What it never said is what that narrowing costs. cel-js's checker returns exactly ONE error, so when the first one is of another class every undeclared reference behind it in the same source goes unjudged and the helper answers `null` — the same value that means the source is clean. A contract that declares only which error it cannot make reads as making neither.
381
+
382
+ No behaviour changes. This is the contract text, and it ships: the amended block is JSDoc on a published export, so it is emitted into `@objectstack/formula`'s `dist/index.d.ts` and `dist/index.d.mts` (measured — the declaration file grew 53.45 KB to 55.99 KB) and is what a consumer reads on hover.
383
+
384
+ What the amendment adds, all of it measured rather than reasoned:
385
+
386
+ - **The masking is positional, not name-keyed.** The masked name is not the one that triggered the first error, so excluding the trigger's own name does not reach it. `data == 'x' && status == 'q'` answers `null`; the same two names in the other order answer `"status"`.
387
+ - **`celEngine.compile()` is not a gate against it.** `compile` type-checks in the permissive environment, where every unlisted name is `dyn`. The strict environment here declares `SCOPE_ROOTS` as `map`, so a root — or an object field sharing one of those names (`data`, `config`, `result`, `item`, `event`, `input`, `user`, …) — used as the operand of an operator with no `map` overload faults HERE and nowhere else. A caller that only reaches the helper on a clean compile is therefore not protected by its own gate.
388
+ - **The CEL type-name class is the same shape.** `type == 'grid'` is already pinned as a blind spot in `@objectstack/lint`'s `visibility-bare-identifier` suite, but pinned per NAME; the masking it causes is source-wide.
389
+ - **What closing it would take, and why that is not this change.** Widening the regex onto the overload message is the false positive the narrowing buys off (`type(record.x) == string` is legitimate CEL). Reporting past the first error needs a re-check loop over a neutralised source, or a checker entry returning more than one error — cel-js 8.0.0 has neither; its `TypeCheckResult` carries a single `error`. Both change what every consuming rule reports, so the oracle's shape is a design decision.
390
+
391
+ `@objectstack/lint` carries a second comment-only correction, to `flow-variable-scope`'s account of the same oracle. Its "known, deliberate blind spot" note bounded the under-report to a flow variable named after a `SCOPE_ROOTS` member; measured, the bound does not hold — such a name in an operand position terminates the discovery loop on iteration 0 and every shadow in that source is lost, whatever it is named. That block sits on an internal function, so unlike the `formula` half it reaches no published declaration file; the entry is here because the package is touched and published.
392
+ - b371960: `flow-decision-unconditional-branch` now reports the decision that gates on nothing — the shape the rule used to skip.
393
+
394
+ A `decision` whose out-edges carry no `condition` and no `isDefault`, and whose node declares no `config.conditions[]`, selects no branch at all: the automation engine's own decision executor reports no branch when `conditions[]` is empty, so traversal considers every out-edge and each successor runs on every pass. The gateway is decoration. The rule could not see that shape, because it was framed as "an unconditional edge undercuts a guarded one" and read zero guarded edges as nothing to undercut — so the strictly worse gateway was the one case that stayed silent, and it is the harder one to notice in review, because the node still says `type: 'decision'`.
395
+
396
+ Same rule id, same `warning` tier, with its own message: it names the out-edges that run unconditionally and offers the three fixes (a `condition` per branch plus `isDefault: true` on the fallback, a `config.conditions[]` whose `label` matches an out-edge, or dropping `type: 'decision'` for the node the gateway already behaves as). The mixed shape — one guarded out-edge beside an unconditional one — keeps its existing wording and its single finding.
397
+
398
+ Decisions that do declare their routing stay silent, including the two that are easiest to catch by mistake: an ordinary gateway with guarded edges, and a decision that routes by `config.conditions[]` labels alone with bare out-edges. A decision declaring a label no out-edge claims remains the gating `flow-branch-label-unmatched` on its own, with no second finding piled on the same node.
399
+ - d91dff4: `validateStackExpressions` no longer throws on a non-record entry of a flow's `nodes` list.
400
+
401
+ An empty item in a YAML `nodes:` list deserialises to `null`, so this is an authorable shape — the same one #15552, #15636 and #15742 closed for stack collections and for `objects[].fields`. Here it crashed the linter instead of producing a finding: `flow.nodes: [null, ...]` threw `Cannot read properties of null (reading 'type')`, which presents to an author as a broken tool rather than as a problem with their metadata.
402
+
403
+ Both of the file's inline casts now read through `recordsOf`, the one home of this coercion, instead of asserting that `Array.isArray` proves anything about a list's MEMBERS:
404
+
405
+ - The flow walk reads `flow.nodes` through `recordsOf`, and — the half that actually removes the crash — hands that coerced array to `collectFlowGraphs` rather than the raw flow. `collectFlowGraphs` declares its input as already-parsed `FlowNodeParsed[]` and is transparent about members, so passing raw authored metadata was calling it out of contract; coercing only the local variable relocated the throw into `@objectstack/spec` instead of ending it. The producer's contract is unchanged, deliberately: widening it to tolerate malformed members is the wrong direction.
406
+ - The per-graph walk reads `graph.nodes` through `recordsOf` in place of an `as unknown as` double cast. A nested region's node list is only `Array.isArray`-checked before it becomes a graph, so that list carries the producer's word about its members and not a check.
407
+
408
+ A non-record member is dropped whole and in silence, exactly as the file's sibling field readers already did; a flow standing beside the junk entry is still judged, and a `nodes` list holding a plain string still reports exactly what it reported before.
409
+ - dff0bdd: `flow-template-unknown-field` and `flow-template-lookup-traversal` now reach a `{record.<field>}` template that sits outside a node filter — the `warning` half both rules already declared, and never emitted.
410
+
411
+ A `{record.<field>}` token in a filter has always been reported as an `error`: an unresolved token there erases the condition and the CRUD node refuses to run. A token anywhere else — a message body, an http request payload, a created row's field values — is the quiet failure the rules were written for: it renders as an empty string on every run, and nothing reports it at either end — no build-time finding, no run-time error — so a hand-off payload naming a renamed field ships an empty value and the run is recorded as a success. That half was silent.
412
+
413
+ The cause was one key, in the shared flow walk rather than in either rule. A rule that scans a node's config recursively has to read a view of it with the nested regions removed, or it reports every finding inside a `loop` / `try_catch` / `parallel` a second time against the container. That view was built by removing every key that holds a region on *any* node type — and `body` is `loop`'s region slot **and** the canonical request-payload key on an `http` node. So `config.body` was deleted from every node's view before any rule read it, and the whole of an http payload was invisible. The view now removes only the slots the node's own type declares, which is exactly the set the walk descended into: nothing is double-reported, and nothing that was never a region is dropped.
414
+
415
+ Expect new `warning` findings on flows that publish clean today. Each one names a token that renders empty at run time; `warning` does not change `os validate`'s exit code, so a build that passed still passes.
416
+ - 7a01847: Fix: a name-keyed `pages:` map no longer passes every source-page lint vacuously.
417
+
418
+ `pages` has two authoring carriers — a list, or a map keyed by page name that
419
+ `normalizeStackInput` folds into a list before the schema sees it. Four rules
420
+ (`validate-jsx-pages`, `validate-page-source-styling`,
421
+ `validate-react-page-props`, `validate-react-pages`) read the collection through
422
+ a private coercion that answered a map with an empty list, and they run on the
423
+ raw `os lint` path where nothing has normalized it yet. On a map-shaped stack
424
+ all four therefore returned no findings by never walking a single page: an
425
+ empty source, a syntax error, an unparseable component and a Tailwind
426
+ `className` were all reported as clean. They now read `collectionEntries`,
427
+ which handles both carriers, and a finding on the map carrier is located by the
428
+ author's own key (`pages.home.source`) rather than a synthetic index.
429
+
430
+ The same change removes the last sixteen private copies of the collection
431
+ coercion in this package. Twelve rules — the `function` form, which had already
432
+ grown the non-record filter locally in two different spellings — now read
433
+ `recordsOf` from `object-graph.ts`. Two behaviour changes fall out, both on
434
+ input that was already malformed: an array-typed member of `agents:` /
435
+ `skills:` / `tools:` used to survive the looser local filter and draw one
436
+ reference-integrity finding at a position nobody authored, and is now dropped;
437
+ a member of a name-keyed `validations:` map whose value is not a record is now
438
+ carried as `{ name }` rather than discarded, which reaches no check that reads
439
+ it. No rule id, message or severity changes, and every finding path on the list
440
+ carrier is unchanged.
441
+ - f36eef5: Flow-node-list readers no longer throw on a non-record member — `lintFlowPatterns`, `collectFlowVariableNames` and the three record-change template-path readers now coerce through `recordsOf`.
442
+
443
+ `lintFlowPatterns` crashed on an ordinary flow. A YAML `nodes:` list item left empty deserialises to `null`, and the rule read `nodes.find(n => n.type === 'start')` off a list it had only `Array.isArray`-checked, so an author's own metadata turned `objectstack validate` into an uncaught `TypeError` out of a function contractually typed `(stack) => Finding[]`:
444
+
445
+ ```
446
+ TypeError: Cannot read properties of null (reading 'type')
447
+ at lint-flow-patterns.ts:1430
448
+ ```
449
+
450
+ `Array.isArray` proves the LIST, never its MEMBERS — the same sentence removed from `validate-expressions.ts` one file over. All seven readers now go through `recordsOf` (`object-graph.ts`), which stays the single home for this coercion; no new copy of the predicate is declared.
451
+
452
+ - **`lintFlowPatterns`** — `flow.nodes` is coerced once, and that coerced array is what is handed on to `collectFlowGraphs`. That second half is the load-bearing one: `collectFlowGraphs` is transparent about members (it forwards the caller's array and re-exposes the same objects), so coercing only for the local read would have moved the crash into `packages/spec` rather than removing it. Its two `graph.nodes` readers are coerced as well, because a nested region's node list reaches them with only an `Array.isArray` behind it.
453
+ - **`collectFlowVariableNames`** — the `graph.nodes` walk had no member guard while the `flow.variables` walk seven lines above it did. Reachable today only at a region nest of exactly `MAX_REGION_DEPTH`; it now cannot throw at any depth.
454
+ - **The three record-change template-path readers** (`boundObjectOf`, `declaredExpandOf` and the per-flow start lookup in `validateFlowTemplatePaths`) were **not** throwing. They survived on an optional chain in the `.find` predicate — one character's difference from the reader that did throw, maintained by nothing and looking redundant next to the `Array.isArray` above it. They are coerced for the same reason and the optional chain goes with it. This half is a hardening, not a bug fix.
455
+
456
+ A malformed member is dropped, in silence, exactly as `recordsOf` drops one everywhere else; the valid nodes standing beside it are still judged and the findings a flow draws are unchanged.
457
+ - 36a6082: `flow-double-brace-interpolation` and `flow-bare-dollar-reference` now read an `http` node's request payload. Both rules were blind to the whole of `config.body` on every node type — the one key where an uninterpolated token has an outbound consequence.
458
+
459
+ The recursive template scan in `lint-flow-patterns.ts` read a region-stripped view of each node's config, and it built that view from the FLAT UNION of every config key that holds a region on *any* node type (`body`, `try`, `catch`, `branches`) rather than from the slots the node in hand actually owns. `body` is `loop`'s region slot **and** the canonical request-payload key on an `http` node, so `config.body` was deleted from every node's view before the scan ever read it.
460
+
461
+ That made the two rules silent exactly where they matter most: `http-nodes.ts` interpolates the raw config wholesale, so a double-brace `{{ record.title }}` or a bare `$source.id` written in a payload is never interpolated and ships to the endpoint as literal text. Measured before this change, an `http` node whose `body` carried either token shape — at the top level or nested inside a `try_catch` region — produced zero findings from either rule.
462
+
463
+ - **The call site passes its own slots.** `stripRegions(node.config, ownRegionKeys(node.type))`. The remedy was already written in `stripRegions`' own docblock ("Pass the OWNING node's slots, not the flat union") and the sibling call site in `flow-walk.ts` already followed it; this one did not.
464
+ - **The trapping default is gone.** `stripRegions`' `regionKeys` parameter is now REQUIRED. The flat union survived as a default only to bound an earlier change, and the cost of leaving it was this defect: the shorter call compiled and quietly asked a different question. A caller that has not decided which set it means now fails to compile instead.
465
+ - **The double-count direction is unchanged and pinned.** A token inside a `loop` body is still reported exactly ONCE, against the node that carries it and not also against the container — the reason the strip exists, and the direction that breaks if a repair over-corrects to stripping nothing.
466
+
467
+ Both rules keep their existing severity. New findings appear only where a `{{ }}` or bare `$ref.field` sits in a previously-hidden key; measured across `examples/app-showcase`, `app-crm` and `app-todo` (34 flows, `http` payloads inside a `parallel` branch and a `try_catch` try among them), the count is unchanged at zero — those payloads use correct single-brace tokens.
468
+ - 7ad2ca0: `validateFormLayout` now resolves the bound object for a view container's default `form` (and its `formViews.*` entries that declare no binding of their own) when that container names its object only on the `list` block (`list.data.object`, `list.object` or `list.objectName`) and nowhere on the container itself.
469
+
470
+ Before this fix, `containerObject` had no way to see a list-only binding, so `objName` stayed `undefined` for every site under such a container — and `form-field-unknown` / `form-section-group-unknown` never fired there, however wrong the section content was. This is the same fallback rung `validate-translatable-sections.ts` already carries for its own sites; it is now shared by both. `absolute-colspan-discouraged` is unaffected by this change — it was never gated on the object binding (it needs only a field's `colSpan`), so it already fired on a list-bound container's form sections before this fix.
471
+
472
+ Consequence: a view whose object binding lives only on `list` and whose default `form` (or an unbound `formViews.*` entry) references a nonexistent field or an undeclared `section.group` now gets a `warning` finding it did not get before. A stack with no such dangling reference sees no new output.
473
+ - 7dafaae: No authoring rule throws on a non-record entry of any stack collection.
474
+
475
+ A collection is authored either as a list or as a name-keyed map, so every rule that reads one coerces `unknown` into an array of records first. That coercion had been hand-copied into 39 modules, and 23 of the copies spelled the array branch as an unchecked cast — every member was asserted to be a record. A YAML list item left empty deserialises to `null`, so a single stray `-` under `flows:`, `pages:`, `dashboards:`, `datasets:`, `apps:`, `permissions:`, `capabilities:`, `data:`, `hooks:`, `views:`, `actions:`, `translations:` (or a per-object `fields:` / `actions:` / `views:`) reached a property read on `null` and threw a stack trace out of `os lint` / `os validate` instead of reporting a finding. The rules are pure `(stack) => Finding[]` running on the raw path, so nothing upstream had judged the entry's shape.
476
+
477
+ Twenty-two of those readers now read through the shared, guarded `recordsOf`, which drops a non-record member of the array shape whole and keeps the author's key on the map shape. Nothing else about what the rules judge changes: a valid entry standing beside a junk one is still read, and still draws exactly the findings it drew before.
478
+
479
+ The remaining copies are pinned by a new source-text test in the package, so the predicate cannot be pasted back in: it asserts that `recordsOf` is the only collection coercion, that every module still holding a private one is named in a dated ledger that is exact in both directions, and that no coercion outside a dated single-file allowance casts its array branch unchecked.
480
+ - 52b59d6: fix(lint): every `stack.objects` reader skips a non-record entry, so no authoring rule throws on the publish door
481
+
482
+ A `null` member of `stack.objects` — what an empty YAML list item
483
+ deserialises to, and what a partial editor write leaves behind — crashed
484
+ 13 of the 42 `AUTHORING_RULES` with
485
+ `TypeError: Cannot read properties of null (reading 'name')`. The
486
+ authoring rules are pure `(stack) => Finding[]` (ADR-0019) and run on the
487
+ RAW `lint` path as well as the parsed one, so nothing upstream had judged
488
+ the entry's shape. At the runtime publish gate they are called inside the
489
+ gate rather than behind a try/catch of their own, so the throw was an
490
+ exception on a WRITE path, not a skipped finding; on the CLI, `os lint` /
491
+ `os validate` / `os compile` died on the first one instead of reporting
492
+ the stack.
493
+
494
+ The repair before this one guarded ONE seam — the object-graph index every
495
+ field-path rule opens with. The crash stood at fourteen more readers of
496
+ the same collection, each a hand-copied `asArray` whose array branch was
497
+ an unchecked `v as AnyRec[]`. Copies are why: the defensive spelling was
498
+ already present in about a dozen siblings and absent in the rest, so
499
+ fixing one left the others answering the old way.
500
+
501
+ So the copies are gone. `recordsOf` — the guarded reader, exported from
502
+ `object-graph.ts` and package-private — is now the one coercion from a
503
+ collection authored as an array OR as a name-keyed map into the records it
504
+ holds, and fifteen files call it:
505
+
506
+ - `validate-expressions.ts`, `validate-list-view-mode.ts`,
507
+ `validate-widget-bindings.ts`, `filter-walk.ts`,
508
+ `validate-object-references.ts`, `validate-record-title.ts`,
509
+ `validate-form-layout.ts`, `lint-autonumber-formats.ts`,
510
+ `lint-view-refs.ts`, `validate-org-axis-red-lines.ts`,
511
+ `validate-sharing-rule-enforceability.ts` — the eleven sites that threw.
512
+ - `validate-searchable-fields.ts`'s `indexObjectSearchTargets` and
513
+ `validate-page-field-bindings.ts`'s `indexObjectFields` — two shared
514
+ indexers inside the reference-integrity suite, each in front of two
515
+ rules and both hidden behind whichever suite member threw first.
516
+ - `object-field-groups.ts`'s `indexObjectFieldGroups`, which the
517
+ re-measure surfaced only once the eleven above stopped throwing.
518
+ - `validate-security-posture.ts`, the one that never threw: an `[]`
519
+ member passed its `typeof v === 'object'` read and drew a second
520
+ `security-owd-unset` at `object "(object 0)"` — an `error` about an
521
+ entry no author wrote.
522
+
523
+ The verdict is a SKIP, not a finding, matching the seam it extends: a junk
524
+ `objects` member is a SHAPE defect and belongs to the schema, every rule
525
+ already re-answers the question in its own per-object guard, and reporting
526
+ it at the reader would emit one finding per member for one bad entry. On
527
+ the name-keyed map shape a member whose VALUE is unreadable keeps its key
528
+ (`{ name }`) — the author named it, only its body is illegible.
529
+
530
+ No rule tier, id, message or accept-set changes. A valid object standing
531
+ beside a junk one is judged exactly as it is judged alone; only a path
532
+ index moves, and only for the rules that index `objects` raw, where
533
+ `objects[1]` is the honest position.
534
+ - 434ca2d: `validateStackExpressions` no longer throws on a non-record entry in an object's `fields:` list.
535
+
536
+ An empty item in a YAML `fields:` list deserialises to `null`, and `buildFieldIndex` cast each member of the list inline (`fields.map(f => (f as AnyRec).name)`) before the `.filter` two calls later could drop it. `Array.isArray` proves the LIST, never its MEMBERS, so linting such a stack failed with `TypeError: Cannot read properties of null (reading 'name')` out of the whole rule instead of reporting anything about the file.
537
+
538
+ The list is now read through `recordsOf` — the one place that coercion is decided — which drops a non-record member of the array shape whole and in **silence**: it carries no author-written name, so there is nothing to report about it. That matches what the two sibling field readers in the same module (`buildFieldTypeIndex`, `fieldEntries`) already did with the same member, so the three readers now agree. The readable siblings of the junk member are still indexed, so unknown-field findings on that object continue to be reported.
539
+
540
+ The map shape (`fields: { amount: { … } }`) is unchanged: there the author's key is the field name, which is what this index needs.
541
+ - 25a3d91: Stop reporting a declarative `operation: 'update'` action as "a button wired to nothing"
542
+
543
+ The boot action-governance inventory (ADR-0110 D5) built its `unboundDeclarations`
544
+ finding from a `type`-only test. The declarative single-record field write
545
+ (`operation: 'update'` + `patch`, #14092) is exactly the shape that test mistakes
546
+ for a dead button: `ActionSchema` refuses `target` and `body` beside it and keeps
547
+ `type` at its default `script`, because the platform action route is where the
548
+ write is performed. Every such action was named at every boot and every
549
+ `metadata:reloaded` — with a prescription ("add a `body`, or register a handler
550
+ under the declared `target`") that parse itself refuses.
551
+
552
+ Both readers now read `operation` before `type`, the precedence the runtime doors
553
+ already use: the engine inventory, and the authoring-time AI tool-reference rule,
554
+ which had diverged from the runtime's listing door and reported a resolvable
555
+ `action_<name>` reference as fictional.
556
+ - ce21963: `nav-object-ungranted`'s hint no longer tells you to gate the nav entry with `requiredPermissions`/`visible` — that never cleared the finding, because the rule never reads either key. Gating restricts who can see the entry; it doesn't grant the object read, so a holder who clears the gate could still hit permission-denied, and the warning kept firing anyway. The hint (and the module doc-block) now name the two remedies that actually clear it: grant read on the object in a permission set (`allowRead: true` or `viewAllRecords`), or drop the nav entry. No behavior change — the rule fires and stays silent on exactly the same inputs as before; only the wording of the hint moved.
557
+ - ba426b0: A junk entry in `stack.objects` no longer crashes the reference-integrity rules, and a probe rule that throws is reported instead of read as "nothing wrong".
558
+
559
+ `indexObjectGraph` is the first statement of every rule that resolves a field path, and it read each `stack.objects` member without checking it was a record — so a `null` entry (an empty YAML list item, a partial editor write) threw `TypeError: Cannot read properties of null (reading 'name')` before any rule's own per-object guard could run. Because these rules also run inside the runtime publish gate, that was an exception on a write path rather than a missed finding. The seam now drops non-record entries — silently, matching every sibling collection reader in the package — and the valid objects beside them are judged exactly as before.
560
+
561
+ On the publish receipt, `runBuildProbes`' object plane wrapped its rule call in a catch that produced an empty finding list, so a crashed rule was indistinguishable from a clean object while `checked.objects` had already counted it. A rule that throws now surfaces as a `runtime`-layer `object_field_ref_rule_failed` error carrying the thrown message, so an unverified object never reads as a verified one. Probes still never fail the publish they verify.
562
+ - eda26ce: `security-owd-alias` no longer tells authors that `sharingModel: 'public'` is a retired ADR-0090 D4 alias. It never was one — no shipped schema ever accepted it — and the map that said so is now split along the two histories it was conflating.
563
+
564
+ `OWD_ALIAS_FIX` carried four keys under one comment, `[ADR-0090 D4] Legacy alias → canonical fix-it mapping`, while D4 names three: "The legacy aliases `read`, `read_write`, `full` are **removed from the zod enum**". Those three have a retirement behind them — the `owd-legacy-read-aliases` ADR-0087 stored-row conversion for the two `read*` spellings, the `13.owd-full-alias-removed` semantic entry for `full`. `public` has neither, and correctly so: a conversion rewrites a spelling some shipped schema once took, and this one never was taken, so its stored population is zero by construction. The missing conversion was the mislabel's shadow, not a gap.
565
+
566
+ - **Two maps, one union.** `OWD_RETIRED_ALIAS_FIX` holds the three D4 aliases; `OWD_WRONG_LAYER_FIX` holds `public`; `OWD_ALIAS_FIX` stays as their union, so every key still earns the same rule id, the same path and the same fix-it. No accept set moves and no value starts or stops being reported.
567
+ - **The `public` fix-it is KEPT.** It catches a real authoring mistake: three neighbouring keys on the same `ObjectSchema` take `'public'` legally — `access.default` (`z.enum(['public', 'private'])`, ADR-0066) and `publicSharing.allowedAudiences` (`z.enum(['public', 'link_only', 'signed_in', 'email'])`) — and off-schema so does the sharing runtime's own internal vocabulary, `effectiveSharingModel(): 'private' | 'read' | 'public'`. `sharingModel` is the one neighbour that refuses it, and it fails CLOSED to `private` with no notice on the read path, so this fix-it is the author's only signal.
568
+ - **The message says which group it is in.** One shared clause, used by both the `sharingModel` and the `externalSharingModel` branch. A retired alias still reads `is a retired alias (ADR-0090 D4)`; `public` now reads that it is not an OWD value and never was, and names the neighbouring keys that do take it. The fix-it text, severity, rule id and path are byte-identical either way.
569
+
570
+ Why the wording mattered enough to change: a diagnostic that credits `public` to D4 sends its reader looking for the conversion and the semantic entry that would exist if the acceptance had happened, and finding them absent reads as a data-fidelity defect in the conversion registry. It is not one.
571
+ - ed6579b: `objectstack build` now refuses to lower a hook/action body that calls `.create(`, and the shared write-pattern ledger stops advertising the verb. Three layers used to disagree about `ctx.api.object('x').create({ … })`, and the loudest one was wrong.
572
+
573
+ - The spec contract `IScopedObjectRepository` (`packages/spec/src/contracts/scoped-context.ts`) declares `insert` and names `create` as measured-and-deliberately-excluded.
574
+ - The QuickJS sandbox installs exactly `insert / update / delete / updateMany / deleteMany / upsert` as the `ctx.api.object()` write leaves — no `create`. An L2 body calling `.create()` therefore threw `TypeError: not a function` on its **first run**, and under a hook's default `onError: 'abort'` that throw aborted the triggering write, with a message naming no member.
575
+ - The extractor ledger nonetheless advertised `.create({…})` as legal `api-crud-literal` syntax and mapped it in `API_WRITE_METHODS`, so `hook-body-write-unknown-field` graded the payload as a live write and stayed silent when the field existed — a clean bill of health for a call that cannot run. Build time said nothing at all.
576
+
577
+ What changes:
578
+
579
+ - **`@objectstack/cli`** — `.create(` joins `FORBIDDEN_PATTERNS` in the hook/action body extractor, beside `.sudo(` and for the same reason (a member real on the in-process `ScopedContext` / `ObjectRepository` and absent from the VM). The refusal names `.insert({ ... })` as the spelling the sandbox actually has. Behaviour is the `forbidden-token` fallback every other entry has: the callable is still registered and still shipped through the back-compat `.mjs` bundle, so a handler keeps running in-process where the host `create()` alias exists — `objectstack build` merely declines to *also* emit it as a body that cannot run. Under `--strict-body` it is a hard failure, correctly. The rule is receiver-loose like `.sudo(` (`const repo = ctx.api.object('x'); repo.create(…)` is refused too) with one carve-out: `Object.create()` is a real sandbox global and is **not** affected.
580
+ - **`@objectstack/lint`** — `create` is withdrawn from `HOOK_BODY_WRITE_PATTERNS`' advertised `api-crud-literal` syntax and from `API_WRITE_METHODS`, on the hook and action surfaces alike. `hook-body-write-unknown-field` / `action-body-write-unknown-field` no longer grade a `.create()` payload; `hook-api-update-readonly-field` keeps its existing `create` exclusion, whose *reason* is updated — it is no longer "the call throws, so a silently-dropped finding would be false" but "the shape can no longer reach this rule at all".
581
+
582
+ **Migration.** If a hook or action body calls `ctx.api.object('x').create({ … })`, spell it `ctx.api.object('x').insert({ … })` — the same host method, the one the sandbox installs and the only insert verb the contract declares. The host-side `ObjectRepository.create()` alias is untouched and stays reachable from in-process handlers and actions.
583
+ - 89758ac: `chart-axis-not-selected` resolves a report chart against its own `chart.yAxis`, not `report.values` (#15734)
584
+
585
+ **Behaviour change — one false finding removed on the report surface.** A report chart whose `chart.yAxis` names a declared measure that `report.values` does not select no longer raises a `chart-axis-not-selected` warning. Nothing else about the rule moves, and no other surface moves at all.
586
+
587
+ The warning stated a query consequence the renderer refutes. Read at the `@object-ui` revision this repo pins (`.objectui-sha`), `plugin-report/src/DatasetReportRenderer.tsx` does not query `report.values` for the chart at all — it runs the chart's own, narrower query out of the two axis strings:
588
+
589
+ ```
590
+ const state = useDatasetRows(
591
+ dataset,
592
+ plan.kind === 'series' && xAxis ? [xAxis] : [],
593
+ wantsQuery && yAxis ? [yAxis] : [],
594
+ ```
595
+
596
+ and says so in that file's own words at the `scopeOrder` docblock: *"the embedded chart queries only `chart.xAxis` × `chart.yAxis`"*. So the measure the warning said "the query does not return" is exactly the one the query asks for, and the chart plots it. `report.values` is the selection of the TABLE beneath the chart.
597
+
598
+ Both limbs follow from that one measurement:
599
+
600
+ - **No not-selected check at the report `chart.yAxis`.** That position IS the chart's query, so it cannot fail to select itself. `chart-measure-unknown` there is untouched: an UNDECLARED measure is still no column at all, and still an `error`.
601
+ - **`chart.series[].name` resolves against the singleton `{ chart.yAxis }`.** The entry is a display-name override paired with a DERIVED series, and the chart derives exactly one (`buildChartSeries(…, [xAxis], [yAxis], …)`). An entry naming `chart.yAxis` now lands however the table is selected, and one naming any other declared measure is still reported — including a measure `report.values` does select, which it could not reach before.
602
+
603
+ The list-view and page-component surfaces are unchanged, and carry firing controls that say so: on both, `values` IS the measure set the query asks for (`ObjectView` hands it to the chart; `ObjectChart` queries `{ dimensions: schema.dimensions, measures: schema.values }`), so the existing resolution is the right one there.
604
+
605
+ The per-position tier and consequence wording is untouched — only the SET the report surface resolves against moves.
606
+ - d83d079: A report's `dataset`, `rows`, `columns` and `values` are checked whether or not the report draws a chart (#16105)
607
+
608
+ **Behaviour change — new findings on reports that used to publish clean.** `validateChartBindings` reached a report through one closure that opened `if (!isRec(chart)) return`, and that closure was the only place a report's `dataset` was ever passed to the resolver. Two gaps followed, and both are closed:
609
+
610
+ - **A report authored without a `chart` was not checked at all.** Bind it to a dataset that does not exist and `os lint` exited 0 and named nothing. It now reports `chart-dataset-unknown` at `error`, the same as a charted report always did.
611
+ - **`rows` and `columns` were resolved on no report, charted or not.** On one and the same report object the measure selection (`values`) was resolved against the dataset and the dimension selection beside it was not. Both now report `chart-dimension-unknown` at `error` for a name the bound dataset does not declare as a dimension, at `reports[i].rows[j]` / `reports[i].columns[j]`. A chartless report's `values` is resolved for the first time too, under the existing `chart-measure-unknown`.
612
+
613
+ `ReportSchema` is what makes these bindings rather than free text: it requires `dataset` + `values` on every non-`joined` report, and declares `rows` (the down axis) and `columns` (the across axis a `matrix` pivots on, ADR-0021 D2) as dimension names taken from that dataset. The chart is optional decoration on top of a binding the report already has. So a report bound to a missing dataset, or grouping on a dimension its dataset does not declare, now fails authoring instead of rendering blank or mis-grouped in production.
614
+
615
+ No new rule id, no severity moved, and the charted path is unchanged — `chart-axis-not-selected` stays a `warning` and still resolves against the chart's own `chart.yAxis`. Two smaller corrections come with the restructure, both on messages an author reads:
616
+
617
+ - The dataset finding on a report now points at `reports[i].dataset`, the key the author wrote. It used to say `reports[i].chart.dataset`, a position a report does not have.
618
+ - Its sentence ends "there is no data to render" rather than "the chart has no data to render", which is not true of a report that draws no chart.
619
+
620
+ Blocks of a `joined` report carry the same keys and take the same checks. An unresolvable dataset is still exactly one finding per report or block.
621
+ - c5d6803: Published `.js.map` files no longer embed the complete original source text (`sourcesContent`) — comments included. `sourcemap: true` was esbuild shorthand, and esbuild's own default for `sourcesContent` is `true`; nobody had decided to publish every package's full source (including `@internal`/test-only comments) to npm inside its source maps, it fell out of a default nobody had looked at. Measured before this change: 55 of 57 publishable packages shipped embedded source text, and maps were roughly half of `@objectstack/spec`'s published bytes.
622
+
623
+ `sourcesContent: false` is now set at one shared place (`scripts/tsup-drop-sources-content.mjs`, wired into every `tsup.config.ts` via tsup's `esbuildOptions` hook — most packages build through the repo-root config directly and pick this up with no config change of their own). `mappings` are untouched, so stack-trace positions still resolve correctly to the original file/line/column; only the embedded source text is gone.
624
+
625
+ `@objectstack/cli` (built with `tsc`, not `tsup`) never embedded source text to begin with — its maps' `sources` entries point at `src/**` paths that are not part of the published tarball either way. That is not a defect unique to `cli`: every `tsup`-built package's `sources` entries are `../src/**`-relative paths that are equally outside `files: ["dist", …]`, and were merely masked by the embedded content that just stopped shipping. Shipping `src/**` in `files[]` to make `sources` resolve was rejected — it would put most of the removed bytes straight back. So `cli`'s maps are left exactly as `tsc` emits them: this is now the fleet-consistent shape (accurate `mappings`, non-resolving-but-honest `sources` labels, no embedded text), not an outlier.
626
+
627
+ A new gate, `pnpm check:sourcemap-no-sources-content`, sweeps every built, non-private package's `dist/**/*.map` and fails if any of them carries a non-empty `sourcesContent` array — so a future `tsup.config.ts` that skips the shared hook, or a toolchain upgrade that changes esbuild's default back, is caught rather than silently re-publishing source text.
628
+ - b398ad2: **BREAKING (behaviour):** a static `readonly` field is now stripped from a **non-system caller's INSERT payload inside `engine.insert`**, exactly as it already was on `engine.update`. A non-system create that used to write a read-only column now has that column dropped, reported through `onFieldsDropped` / `droppedFields`, logged at `warn`, and refused outright under `strictReadonlyWrites`. Seeding a read-only column at create time is a **system** act — use `context.isSystem`, a flow's `runAs: 'system'`, a system hook or a seed.
629
+
630
+ Until now the create-side strip lived only at the DataProtocol ingress (`stripReadonlyForInsert` in `@objectstack/metadata-protocol`), so `readonly` meant one thing on insert and another on update: every external REST/GraphQL/MCP create was stripped, while a caller reaching `engine.insert` directly — the automation engine's `create_record` among them — wrote the column with no refusal, no `WARN` and no dropped-field event.
631
+
632
+ - `stripReadonlyForInsert` and its five call sites in `@objectstack/metadata-protocol` are **deleted**, not kept as a second implementation; every create face — `createData`, `cloneData`, `createManyData`, `insertManyData`, and `batchData`'s `create` rows and both arms of `upsert` that create — now hands the caller's payload to the engine whole, and every face whose response carries `droppedFields` (`createData`, `createManyData`, `insertManyData`, every `batchData` row that created) reports the engine's own verdict there, so `droppedFields` says the same thing at each of those seams. `cloneData` forwards whole but reports nothing on the wire: its response contract (`CloneDataResponseSchema`, declared as produced) has no `droppedFields` member, so a clone that carried or overrode a read-only column is stripped and logged at `warn` but not reported in the 201 body — adding that key is a spec change, not part of this one.
633
+ - `create_record` (`@objectstack/service-automation`) starts receiving readonly drops on the `onFieldsDropped` channel it has been wired for since #3407 — a flow without `runAs: 'system'` that seeds a read-only column now reports a node warning and `output.droppedFields` instead of a clean success. That package's own code changes only in prose; the traffic is new, the surface is not.
634
+ - Unchanged, deliberately: `isSystem` is still the exemption; `preserveAudit` is still an UPDATE-path exemption and a create that asks for it is told so out loud; runtime-owned types (`autonumber`) keep their own pass and their own wider whitelist; platform objects (`managedBy`, the `sys_` namespace) are still left to their own field-write guards; `readonlyWhen` still has no create-side strip. A stripped key's `defaultValue` is re-derived, so a forged `approval_status` becomes `draft` rather than NULL.
635
+ - `@objectstack/service-settings` is `patch`: prose only — the `upsertRow` docblock, which ships in the package's `.d.ts`, no longer states the superseded INSERT exemption; it names the platform-object carve-out that actually keeps a `sys_setting` insert outside the strip.
636
+ - `@objectstack/lint` and `@objectstack/spec` are `patch`: both change prose only. All three lint rules — `validate-readonly-action-writes`, `validate-readonly-flow-writes`, `validate-readonly-hook-writes` — drop the superseded "INSERT is exempt" premise from their docblocks and from the justification of their green control cases; the two non-elevated rules now name their `insert`/`create` silence as a scan gap rather than an exemption (the action rule additionally records its now-reasoned refusal as a module-local constant that its `index` does not re-export, so no public surface widens). The spec change is prose only: one docblock sentence that named the deleted function, the `strictReadonlyWrites` contract docblock (which now states what strict refuses on insert), and the `readonly` liveness-ledger verdict, whose evidence pointer named the deleted ingress strip.
637
+
638
+ <!-- adr-0087: not-required (no-migration-prescription) The BREAKING here is a WRITE-PATH BEHAVIOUR change, not a retirement of an authorable or published surface, so there is nothing for the ledger to carry to `objectstack migrate meta`, `spec-changes.json` or the upgrade guide: no spec property, metadata key, accepted value or exported symbol disappears, and this body prescribes no FROM/TO migration. The remedy for an affected caller is to declare the write trusted (`context.isSystem` / `runAs: 'system'`), which is application code, not a metadata migration. The obvious retirement candidate is a non-question in the same direction: `stripReadonlyForInsert` was a bare module-private `function` in `packages/metadata-protocol/src/protocol.ts`, absent from that package's `index.ts` (the only path its `exports` map offers), so no consumer could name it. -->
639
+ - 60ff091: `visibility-bare-identifier` now reports an identifier written bare beside a `has()` guard in the same visibility predicate.
640
+
641
+ `has(status) && status == "qualified"` published clean while `status == "qualified"` — the same defect, without the guard — gated at `error`. The guarded spelling is the one the totality discipline pushes authors toward, so an author who correctly adds `has()` and forgets the `record.` prefix on both halves landed in the silent row. That predicate never evaluates for any record, and an unevaluable `visibleWhen` on a form surface fails OPEN: the field renders and carries its `required: true` into the console's submit check.
642
+
643
+ The cause was not the exclusion a `has()` argument earns — that is correct and stays. `firstUndeclaredReference` reads the first error the CEL checker reports and acts only on `Unknown variable: X`; a bare `has(x)` fails that check with `has() invalid argument` instead, and a first error of a different class masked every undeclared reference behind it in the same predicate, whatever it was called. Each `has(…)` call is now masked out of the source before the checker sees it, using the canonical AST's own spans, so the argument occurrence is excluded and every other occurrence is judged exactly as it would be with no guard written beside it.
644
+
645
+ Expect new `error` findings on predicates that used to publish clean: a guarded-but-unprefixed `visibleWhen` on a view, page component or form section is now refused at build, validate and lint alike. That is the fail-open shape the rule exists to catch. A `has()` argument that is the only bare occurrence — `has(status)` on its own — stays silent, as it did before.
646
+ - Updated dependencies [fe0d9a4]
647
+ - Updated dependencies [ecd2158]
648
+ - Updated dependencies [f2b5e46]
649
+ - Updated dependencies [ed7243d]
650
+ - Updated dependencies [6ba0db4]
651
+ - Updated dependencies [625b0c3]
652
+ - Updated dependencies [233222e]
653
+ - Updated dependencies [07f40e5]
654
+ - Updated dependencies [ceb4877]
655
+ - Updated dependencies [e9fcd6b]
656
+ - Updated dependencies [90e7e6d]
657
+ - Updated dependencies [2bdabe6]
658
+ - Updated dependencies [ca326b5]
659
+ - Updated dependencies [8f404a5]
660
+ - Updated dependencies [68437d4]
661
+ - Updated dependencies [abb140c]
662
+ - Updated dependencies [8333a6c]
663
+ - Updated dependencies [3e3ecb0]
664
+ - Updated dependencies [3030369]
665
+ - Updated dependencies [d5d8d50]
666
+ - Updated dependencies [e08892d]
667
+ - Updated dependencies [ae05f2e]
668
+ - Updated dependencies [b548e43]
669
+ - Updated dependencies [c463d03]
670
+ - Updated dependencies [64bd6a3]
671
+ - Updated dependencies [13c48c2]
672
+ - Updated dependencies [132742f]
673
+ - Updated dependencies [85a2459]
674
+ - Updated dependencies [50dc214]
675
+ - Updated dependencies [e89fa92]
676
+ - Updated dependencies [e9fcd6b]
677
+ - Updated dependencies [8976ea1]
678
+ - Updated dependencies [56fe8c2]
679
+ - Updated dependencies [acabd24]
680
+ - Updated dependencies [ab50c8f]
681
+ - Updated dependencies [6491463]
682
+ - Updated dependencies [89cf4d6]
683
+ - Updated dependencies [21c5dcb]
684
+ - Updated dependencies [6d4d5d3]
685
+ - Updated dependencies [ed5d557]
686
+ - Updated dependencies [bca21f7]
687
+ - Updated dependencies [e9fcd6b]
688
+ - Updated dependencies [1a7a7c9]
689
+ - Updated dependencies [e9fcd6b]
690
+ - Updated dependencies [ef3a138]
691
+ - Updated dependencies [68d5dfd]
692
+ - Updated dependencies [3e21cf0]
693
+ - Updated dependencies [4cfc93b]
694
+ - Updated dependencies [098cbb7]
695
+ - Updated dependencies [efd6b43]
696
+ - Updated dependencies [859ded3]
697
+ - Updated dependencies [fa125f3]
698
+ - Updated dependencies [74628d9]
699
+ - Updated dependencies [a646120]
700
+ - Updated dependencies [6f1ce7d]
701
+ - Updated dependencies [7778115]
702
+ - Updated dependencies [86c75f4]
703
+ - Updated dependencies [2c753fe]
704
+ - Updated dependencies [52804cd]
705
+ - Updated dependencies [3f89967]
706
+ - Updated dependencies [53cf263]
707
+ - Updated dependencies [21aabbc]
708
+ - Updated dependencies [9c270bb]
709
+ - Updated dependencies [76c8c5a]
710
+ - Updated dependencies [a84e1ce]
711
+ - Updated dependencies [bf1054a]
712
+ - Updated dependencies [d8d2776]
713
+ - Updated dependencies [222dc0f]
714
+ - Updated dependencies [e9fcd6b]
715
+ - Updated dependencies [32c917d]
716
+ - Updated dependencies [f9a3c32]
717
+ - Updated dependencies [f502898]
718
+ - Updated dependencies [af7edfe]
719
+ - Updated dependencies [b60f48b]
720
+ - Updated dependencies [c78c918]
721
+ - Updated dependencies [cf9bda4]
722
+ - Updated dependencies [784cb92]
723
+ - Updated dependencies [7629f4d]
724
+ - Updated dependencies [51df9fd]
725
+ - Updated dependencies [a7da4de]
726
+ - Updated dependencies [de0bcdd]
727
+ - Updated dependencies [70f7d6d]
728
+ - Updated dependencies [c677cda]
729
+ - Updated dependencies [554a160]
730
+ - Updated dependencies [f7da71e]
731
+ - Updated dependencies [7f745c3]
732
+ - Updated dependencies [5eb24f8]
733
+ - Updated dependencies [2a3decc]
734
+ - Updated dependencies [cc00df2]
735
+ - Updated dependencies [f4e6adf]
736
+ - Updated dependencies [ee4a59b]
737
+ - Updated dependencies [4db3c61]
738
+ - Updated dependencies [5ca314a]
739
+ - Updated dependencies [e0af1a8]
740
+ - Updated dependencies [414c1fc]
741
+ - Updated dependencies [22c0279]
742
+ - Updated dependencies [0db2947]
743
+ - Updated dependencies [92b5d7f]
744
+ - Updated dependencies [613bfbd]
745
+ - Updated dependencies [abae16a]
746
+ - Updated dependencies [094b8fd]
747
+ - Updated dependencies [c7aca0d]
748
+ - Updated dependencies [c1d8f98]
749
+ - Updated dependencies [8e0b297]
750
+ - Updated dependencies [5f7fa1d]
751
+ - Updated dependencies [87f0ccc]
752
+ - Updated dependencies [aedbaef]
753
+ - Updated dependencies [c5d6803]
754
+ - Updated dependencies [10d05bb]
755
+ - Updated dependencies [69602e5]
756
+ - Updated dependencies [c3ce76c]
757
+ - Updated dependencies [7936b29]
758
+ - Updated dependencies [46803fa]
759
+ - Updated dependencies [c2a336c]
760
+ - Updated dependencies [9f890d3]
761
+ - Updated dependencies [0bb2318]
762
+ - Updated dependencies [f7db8f4]
763
+ - Updated dependencies [1ecee3e]
764
+ - Updated dependencies [9408b7f]
765
+ - Updated dependencies [e9fcd6b]
766
+ - Updated dependencies [9bcd9be]
767
+ - Updated dependencies [b398ad2]
768
+ - Updated dependencies [99261a7]
769
+ - Updated dependencies [81b426f]
770
+ - Updated dependencies [001af1c]
771
+ - Updated dependencies [fb77aa5]
772
+ - Updated dependencies [581d8f8]
773
+ - Updated dependencies [f81afe3]
774
+ - Updated dependencies [40a44b9]
775
+ - Updated dependencies [7a7fb03]
776
+ - Updated dependencies [8fd246d]
777
+ - @objectstack/spec@17.4.0
778
+ - @objectstack/formula@17.4.0
779
+ - @objectstack/sdui-parser@17.4.0
780
+
781
+ ## 17.3.0
782
+
783
+ ### Minor Changes
784
+
785
+ - 345fc33: Resolve an ADR-0021 dataset's own references — base object, `include[]`,
786
+ `dimensions[].field` / `measures[].field`, and filter KEYS — at
787
+ `validate`/`build` (#14105)
788
+
789
+ A dataset could name a **base object that does not exist**, join a
790
+ **relationship that does not exist**, and bind every dimension and measure to
791
+ **fields that do not exist**, and `objectstack validate` exited **0** with
792
+ `✓ Validation passed`. `objectstack build` also exited 0 and wrote the dangling
793
+ dataset into `dist/objectstack.json`.
794
+
795
+ The sting was that the author-time rule pass **already walked those exact
796
+ nodes**. Measured on published 17.2.0, each mutation applied on its own and
797
+ confirmed on disk before running:
798
+
799
+ | mutation | before | after |
800
+ |:----------------------------------------------------|:-------|:------|
801
+ | dimension `field` → a base field that does not exist | passed | `dataset-field-unknown` |
802
+ | dimension `field` → a joined field that does not exist | passed | `dataset-field-unknown` |
803
+ | measure `field` → a field that does not exist | passed | `dataset-field-unknown` |
804
+ | measure filter KEY → a field that does not exist | passed | `dataset-filter-field-unknown` |
805
+ | `include[]` → a relationship that does not exist | passed | `dataset-include-unknown` |
806
+ | `object` → an object that does not exist | passed | `object-reference-unknown` |
807
+
808
+ The two controls in that measurement — a duplicate measure name
809
+ (`DatasetSchema.superRefine`) and a bad date macro in a **measure filter**
810
+ (`filter-token-unknown`) — both failed the build, so datasets were
811
+ demonstrably in the validation path the whole time. `filter-token-unknown`
812
+ already stood at `datasets[1].measures[1].filter.last_update_at.$lt` and
813
+ reasoned about the **value**; nothing standing in that same position resolved
814
+ the **key**, or the sibling `field` one level up.
815
+
816
+ This matters more for a dataset than for most metadata because a dataset is the
817
+ semantic layer: dashboards and reports bind its dimensions and measures by name
818
+ (ADR-0021), and the consumer end of that binding is already guarded
819
+ (`widget-dataset-unknown` / `widget-dimension-unknown` / `widget-measure-unknown`,
820
+ #7529/#8902). So the surviving hole was the quiet one — every binding resolves,
821
+ the board renders, and the charts are empty or subtly wrong because the dataset
822
+ underneath addresses columns that do not exist.
823
+
824
+ **Five verdicts, all `error`.** Four are new rule ids on a new suite member,
825
+ `validateDatasetReferences`:
826
+
827
+ - `dataset-include-unknown` — an `include[]` entry that resolves to nothing, or
828
+ to a field that is not a relationship, so no join can be derived from it.
829
+ - `dataset-field-unknown` — a dimension or measure `field` path that resolves to
830
+ no column, on the base object or on any joined object along the path.
831
+ - `dataset-field-not-included` — the second real check: a dotted path that
832
+ RESOLVES, but whose relationship prefix was never declared in `include`.
833
+ ADR-0021 D-C joins only declared paths, so the column is out of the query's
834
+ reach however real it is.
835
+ - `dataset-filter-field-unknown` — a filter key on `Dataset.filter` or
836
+ `measures[].filter`, in any of the three authored filter shapes.
837
+
838
+ The fifth, the base object itself, lands on `validateObjectReferences` as a new
839
+ `datasets[].object` reference site rather than as a sixth id here. That rule's
840
+ charter IS object-name references that are plain `z.string()`, and putting it
841
+ there buys the curated cross-package severity ladder: the platform's own
842
+ `system.datasets.ts` declares five datasets over `sys_*` objects, three of which
843
+ live in packages a stack compiling plugin-auth alone cannot see. All five
844
+ resolve through `PLATFORM_PROVIDED_OBJECT_NAMES`; a local "not in this stack ⇒
845
+ error" check would have reported every one of them. When the base object does
846
+ not resolve, `validateDatasetReferences` skips the dataset entirely, so one typo
847
+ yields one finding rather than one per dimension, measure and filter key.
848
+
849
+ **Skips, so a finding is never a guess** (ADR-0072 D1): an object this stack
850
+ does not define, an object with no readable field map (ADR-0015 `external` and
851
+ introspected schemas), a registry-injected system column, and any hop *through*
852
+ one — an injected `owner_id` is a lookup at the registry whose target is
853
+ invisible here, so `owner_id.name` is unanswerable rather than a miss. The
854
+ shipped `showcase_task_metrics` dimension `{ field: 'created_at' }` is that skip's
855
+ live case, and every shipped dataset in the repo is silent under the new rule.
856
+
857
+ **Two reusable seams ship with it**, newly exported, because the same two
858
+ questions are asked at a dashboard widget's filter keys and `sortBy` and at a
859
+ list view's field positions, and three independent copies of a hop-walker drift:
860
+
861
+ - `object-graph.ts` — `indexObjectGraph` / `resolveFieldPath` / `isUnjudgeable`,
862
+ answering "what does this `relationship[.relationship].field` path resolve to?"
863
+ as a discriminated **verdict** union rather than a boolean, so a caller can
864
+ tell "this hop is not a relationship" from "this leaf does not exist" and write
865
+ the right prescription. Plus `nearestName` / `suggestName` / `listNames`.
866
+ - `walkFilterFieldKeys` (`filter-walk.ts`) — the FIELD-KEY half of a filter
867
+ subtree, beside the subtree-finding half that module already owns. It handles
868
+ all three authored shapes (Mongo condition object, `{ field, operator, value }`
869
+ rules, `[field, op, value]` triples), because a reader that handles only one
870
+ shape is the exact bug #3574 was filed against, and it composes a nested
871
+ condition object into one relationship path so `{ account: { region: … } }`
872
+ reports `account.region` rather than a bare `region` resolved against the
873
+ wrong object.
874
+
875
+ Both hold mechanism only — no rule ids, no severities, no findings.
876
+ - 09ae32e: Judge `visibleWhen` / `readonlyWhen` / `requiredWhen` examples in the docs corpus
877
+ as CEL — where the enclosing structure says which layer they are about
878
+
879
+ `{/* os:check */}` blocks are type-checked by `tsc --noEmit`, and every CEL
880
+ string is the same type as every other CEL string, so
881
+ `visibleWhen: "record.status != 'closed' && user.hasRole('admin')"` type-checked
882
+ perfectly. `hasRole` is a CEL function that exists nowhere — it is in no stdlib
883
+ registry and on no contract — so the predicate faults at runtime, and a
884
+ field-level `visibleWhen` fault is fail-**open**: `resolveFieldRuleState`
885
+ evaluates visibility with `fallback: true`, so the element the author wrote the
886
+ predicate to hide is shown to everyone who copies the page. That is not a
887
+ hypothetical shape — a shipped doc taught it (#11034 fixed the instance).
888
+
889
+ `check:doc-formula-expressions` gains this as a third scan surface rather than a
890
+ second gate, because two gates with opinions about one contract is the thing
891
+ Prime Directive #12 exists to prevent. The verdict is imported whole: syntax, the
892
+ unknown-function catch and the bare-reference rule come from
893
+ `@objectstack/formula`'s `validateExpression`, and the closed-root rule comes
894
+ from `fieldRuleRootIssue` — the same two the metadata walk applies to the same
895
+ slot, in the same order, in the same words.
896
+
897
+ **The layer is decided first, and a layer that cannot be decided is skipped and
898
+ printed.** `visibleWhen` is one key spelling several unrelated contracts, and the
899
+ binding root really does differ: an object field binds `record` + `previous`
900
+ (+ `parent`), a per-option predicate binds `record` plus the host predicate scope
901
+ including `current_user`, a page component binds the user roots and `app`, and a
902
+ flow-screen field **flattens its own field names to top level**. A gate keyed on
903
+ the key alone would have gone red on
904
+ `content/docs/automation/flows.mdx`'s correct `visibleWhen:
905
+ 'createOpportunity == true'` and on `content/docs/ui/pages.mdx`'s correct
906
+ `'sales_manager' in current_user.positions` — and a gate whose reds are wrong is
907
+ worse than no gate, because it teaches people to add ignores.
908
+
909
+ So admission is structural and schema-backed, never keyed on the key: a
910
+ `Field.*({ … })` factory call, or a raw field definition carrying `type:` inside
911
+ an object-literal `fields:` **map**. The map-versus-array test is the load-bearing
912
+ half and it is read off the schemas — `ObjectSchema.fields` is
913
+ `z.record(name, FieldSchema)` while `FormFieldSchema` and `ScreenFieldConfigSchema`
914
+ are both `z.array(…)`, so a `fields:` map is the object-field layer and nothing
915
+ else, and a `fields:` array is exactly the case that cannot be told apart.
916
+
917
+ **The skip list is printed and counted on every run, including green ones.** A
918
+ gate that skips in silence is the same false-green one level up, so the summary
919
+ names every skipped site and why. Measured on the corpus as it stands: 23
920
+ text-level `*When:` occurrences, of which 13 are admitted and judged, 7 are
921
+ listed as skipped, and 3 are the ADR quoting `field.zod.ts`'s schema
922
+ (`visibleWhen: ExpressionInputSchema.optional()`) rather than authoring a
923
+ predicate. Three of those seven were invisible to an AST-only walk — a bare
924
+ `visibleWhen: "…"` line at statement position is a labelled statement, not a
925
+ property — so a text-level tripwire reconciles the two counts and any site the
926
+ parser never surfaced is listed rather than dropped.
927
+
928
+ `@objectstack/lint` newly exports `fieldRuleRootIssue` and
929
+ `FIELD_RULE_BOUND_ROOTS`. The field-rule root decision was a closure inside
930
+ `validateStackExpressions` — correct while it had one caller, and exactly how a
931
+ second caller comes to own a dialect of a rule instead of the rule. Behaviour is
932
+ unchanged: the metadata walk now calls the extracted function and its 2271 tests
933
+ pass untouched.
934
+ - 12e306a: The canonical-expression-envelope detector for raw-literal `Page` exports gets a shared home in `@objectstack/lint` (#11480). New public API beside `walkPageComponents`: `auditPageExpressionEnvelopes(page, label)` runs the three parse doors (`PageSchema` / `PageComponentSchema` / `ComponentPropsMap`) over one authored page and reports bare-expression findings plus every door's precondition failures; `renderBareExpressionFindings(findings)` renders the actionable red; types `BareExpressionFinding`, `PageEnvelopeAudit`, `EnvelopeAuditDoor`. The detector previously lived package-local to `@objectstack/platform-objects`' gate, which could not reach raw-literal pages shipped by other packages. `@objectstack/cloud-connection`'s two shipped pages are now covered by the same gate, and `MarketplaceInstalledPage` is declared `: Page` (type-level only; no runtime change) so export-shape page discovery sees it.
935
+ - 661275d: security lint: report a `controlled_by_parent` object whose master is decided by FIELD DECLARATION ORDER (#14747)
936
+
937
+ `SecurityPlugin.resolveCbpRelation` resolves the master a `controlled_by_parent`
938
+ object derives record-level access from through three tiers — a required
939
+ `master_detail`, then any `master_detail`, then a required `lookup` — and picks
940
+ inside a tier with `Array.prototype.find`. So when two or more candidates sit in
941
+ the tier that wins, the master is whichever one the field map happens to list
942
+ first. Measured on a real kernel: an object declaring two required lookups
943
+ resolved its security master to the first-declared one, and swapping the two
944
+ field declarations — nothing else — repointed every row's record-level access
945
+ to the other object. Nothing reported it: not `os validate`, not `os lint`, not
946
+ a boot warning.
947
+
948
+ New error id **`security-controlled-by-parent-ambiguous-relation`**, the mirror
949
+ image of `security-controlled-by-parent-no-relation` (#7503): that one reports
950
+ ZERO candidates, this one reports two or more. The message names every
951
+ candidate — field, type and master — in declaration order, says which tier was
952
+ tested, and says which candidate wins today and therefore which object access
953
+ derives from right now.
954
+
955
+ Only the **winning** tier is judged, and that is not a shortcut: the runtime's
956
+ `??` chain stops at the first tier that resolves, so a tie in a lower tier is
957
+ masked by a higher tier's single winner and is not a decision the platform ever
958
+ makes. An object with one required `master_detail` and two required lookups is
959
+ silent, and stays silent.
960
+
961
+ `error` rather than advisory, for the inverse of the usual reason. The other
962
+ error rules in this linter mirror a hard runtime refusal; this one has none to
963
+ mirror precisely BECAUSE the runtime does not refuse — it silently picks — so
964
+ author time is the only place the ambiguity can ever surface. What it does meet
965
+ is the admissibility bar the #7503 rule states: a self-contained property of the
966
+ object document, no per-permission-set nuance to adjudicate, and no legitimate
967
+ reading, since two tied candidates is not an author saying which master they
968
+ meant.
969
+
970
+ This **narrows the accept set of a gating rule** — error findings fail
971
+ `os validate` / `os compile`. Measured over the shipped corpus: the three
972
+ `controlled_by_parent` objects in the example apps (`showcase_invoice_line`,
973
+ `showcase_expense_line`, `crm_opportunity_line_item`) plus the 27
974
+ `ObjectSchema.create` sites the `check:doc-security-posture` gate reads across
975
+ 226 marked prose blocks — **0 findings before and 0 after**. Each of the three
976
+ declares exactly one required `master_detail`, so tier 1 wins with a single
977
+ candidate. `showcase_invoice_line` is the interesting one: it also carries a
978
+ required `lookup`, and the rule is silent because that tie-free lower tier is
979
+ never reached.
980
+
981
+ No runtime behaviour changes. `resolveCbpRelation` in this package now reads its
982
+ tiers from one shared table so the two rules cannot disagree about which tier
983
+ wins, and its answer is unchanged by construction: `find` over a tier is the
984
+ first element `filter` over that tier keeps. The mirror's one deliberate
985
+ divergence from the runtime is kept — `reference` is the only spelling accepted
986
+ here (#5017), so a field carrying the rejected `reference_to` alias is not a
987
+ candidate and cannot create a tie.
988
+ - 9b30cc1: `lintLivenessProperties` no longer crashes on the `live-elsewhere` verdict — and never tells an author to remove a key a sibling repo enforces
989
+
990
+ `describe()` in `lint-liveness-properties.ts` knew three verdicts
991
+ (`experimental`, `planned`, `dead`) and threw, loudly and by design, on any
992
+ other. #13483 then shipped the ledger's fifth status — `live-elsewhere`: dead
993
+ HERE by measurement, genuinely enforced in a sibling repo — and migrated
994
+ `manifest.runtime` onto it (its enforcer is the cloud marketplace publish
995
+ gate). Nothing taught `describe()` about it, so the day any `live-elsewhere`
996
+ row opts into `authorWarn: true`, `os lint` would raise that
997
+ shipped-ledger-integrity error instead of the advisory warning the author
998
+ should get. No shipped row carries `authorWarn` today, so this was a fuse
999
+ rather than a fire.
1000
+
1001
+ `describe()` now has a fourth branch. `live-elsewhere` gets its own rule id —
1002
+ `liveness-live-elsewhere-property`, exported as `LIVENESS_LIVE_ELSEWHERE_PROPERTY`
1003
+ beside `LIVENESS_DEAD_PROPERTY` / `LIVENESS_EXPERIMENTAL_PROPERTY` /
1004
+ `LIVENESS_PLANNED_PROPERTY` and advisory-only like them — plus its own message
1005
+ (`is enforced in a sibling repo, not here`) and its own default hint, which keeps
1006
+ the property and points at the ledger row's `evidence` for the enforcer. It
1007
+ deliberately does **not** reuse the `dead` branch: that is the #11384 lesson,
1008
+ which is that verdicts imply OPPOSITE author actions, and "Remove it" is the
1009
+ single most damaging sentence available about a key whose enforcement is real
1010
+ and remote — deleting it tears out a live gate's input. The sentinel throw
1011
+ stays for genuinely unknown statuses, with its enumeration of the known ones
1012
+ updated.
1013
+
1014
+ The suite gains a coverage pin derived from the shipped ledgers rather than from
1015
+ a hand-written list: every distinct `status` those ledgers actually carry must be
1016
+ answered by `describe()` with a rule id of its own, or (for `live`, which reaches
1017
+ `describe()` only through a ledger-authoring mistake) must still fail loud. A
1018
+ sixth status now fails that pin by name instead of waiting for an author to trip
1019
+ the sentinel.
1020
+ - 8d3f093: fix(lint): refuse a list view's dotted field reference at author time where the runtime door refuses it (#14282)
1021
+
1022
+ An accept-set narrowing on `validateListViewFieldRefs`, the #14107 rule — shipped
1023
+ as `minor`, matching the level that landing and the two family landings before it
1024
+ (#14105, #14148) were given.
1025
+
1026
+ #14107 judges only the HEAD segment of a list-view field reference, so a dotted
1027
+ path whose head resolves to a real relationship field (`columns: [{ field:
1028
+ 'owner.name' }]`) passed `os validate` and `os build` clean while every query
1029
+ door a list view reaches refuses it by name. That half was recorded in the rule's
1030
+ docblock and pinned in tests rather than closed, because its failure mode is the
1031
+ opposite of the silent-blank class #14107 gates: a loud `400 INVALID_FIELD` on
1032
+ the first fetch. This is the ruled resolution of that half, as a second finding
1033
+ class with its own id, `list-view-field-dotted`, so one class can be suppressed
1034
+ or filtered without silencing the other (the convention
1035
+ `validate-sortable-fields` and `validate-searchable-fields` already follow).
1036
+
1037
+ The class is scoped by the DOOR, not by the position table, because some
1038
+ list-view positions are read client-side out of the fetched row and walk a dotted
1039
+ path perfectly well:
1040
+
1041
+ - **Projection** — `columns[]`, in both authored spellings. Clients build the
1042
+ `$select` projection from them, and both doors refuse a dotted entry
1043
+ unconditionally (`assertProjectionHasNoDottedPaths` on the engine boundary,
1044
+ `assertProjectionFieldsExist` at the REST ingress).
1045
+ - **Filter** — the view's `filter`, its `tabs[].filter`, its
1046
+ `userFilters.tabs[].filter`, and the two positions declaring which names an end
1047
+ user may filter on (`filterableFields`, `userFilters.fields`). Here the rule
1048
+ asks the same `classifyDottedFilterHead` the runtime doors ask, so the #8371
1049
+ carve-outs the doors serve — structured/JSON heads, array-valued heads, heads
1050
+ whose type is unreadable — are NOT refused at author time.
1051
+
1052
+ Deliberately excluded, each measured rather than assumed:
1053
+ `gantt.quickFilters[].field` and `gantt.tooltipFields[]`, which the renderer
1054
+ resolves IN MEMORY over already-fetched rows through walkers that split on `.`
1055
+ (the spec describes the former as "Record field / dot-path", and the measurement
1056
+ agreed); and every renderer binding that reaches no query door, which stays
1057
+ unjudged rather than acquiring a verdict nobody measured.
1058
+
1059
+ Existing behaviour is untouched: a dotted path whose head resolves to nothing
1060
+ still reports `list-view-field-unknown`, `sort[]` keeps its owner, and the
1061
+ shipped example corpus was measured at zero findings both before and after.
1062
+ - aca23ab: feat(lint): resolve a list view's field references at validate/build (#14107)
1063
+
1064
+ Accept-set narrowing, `minor` under the family precedent (#14105, #14148).
1065
+
1066
+ A list view names fields in more than twenty places and **none of them was
1067
+ resolved against the bound object** — not by `os validate`, and not by `os
1068
+ build`, which is the publish gate. Measured on `@objectstack/cli` 17.2.0 from a
1069
+ real app, each mutation applied on its own and confirmed on disk: a
1070
+ `columns[].field`, a `filter[].field`, a `grouping.fields[].field`, a
1071
+ `kanban.groupByField` and a `gantt.startDateField` naming a field that does not
1072
+ exist all left `os validate` at `valid: true, warnings: []` and `os build` at
1073
+ exit 0, `✓ Build complete`.
1074
+
1075
+ Each one fails silently at render, in the way ADR-0078 and the
1076
+ `view/layout-without-binding` rule already treat as worth gating: a bad column
1077
+ renders blanks, a bad filter key is sent to the engine and matches nothing (an
1078
+ empty list indistinguishable from a true zero), a bad gantt start date leaves a
1079
+ blank chart, a bad kanban group-by collapses every card into the uncolumned
1080
+ bucket. The platform already shipped the *harder* half of this check —
1081
+ `view/layout-without-binding` warns when a binding block is **absent**; a block
1082
+ that is present but points at a field that does not exist reaches the identical
1083
+ end state and got nothing.
1084
+
1085
+ The new rule `list-view-field-unknown` (`validateListViewFieldRefs`, a member of
1086
+ the reference-integrity suite, so it runs on `validate` / `lint` / `compile` and
1087
+ on `view` per-write publish snapshots) resolves every field-naming position on a
1088
+ list view against the object graph:
1089
+
1090
+ - `columns[]` (bare-string and `{ field }` forms, plus `summary.field` and
1091
+ `prefix.field`), `filter[]` keys, `tabs[].filter[]` keys, `grouping.fields[]`,
1092
+ `rowColor.field`, `userFilters.fields[]`, `userFilters.tabs[].filter[]` keys,
1093
+ `filterableFields[]`, `hiddenFields[]`, `fieldOrder[]`;
1094
+ - every field binding inside the `kanban`, `calendar`, `gantt`, `timeline`,
1095
+ `gallery`, `map` and `tree` blocks.
1096
+
1097
+ `sort[]` and `searchableFields[]` are deliberately untouched — they already have
1098
+ owners (`sort-field-unknown` #9257, `searchable-field-unknown` #6674/#4830),
1099
+ each with a runtime-admissibility verdict on top of existence.
1100
+
1101
+ Two severity tiers, the `validateFlowTemplatePaths` precedent: `error` where the
1102
+ miss changes the data the view returns or collapses the layout it configures
1103
+ (every position in the card's measured table), `warning` where the renderer
1104
+ drops one decoration and renders the rest (optional colour/title/tooltip/cover
1105
+ bindings, a stale `hiddenFields` or `fieldOrder` entry).
1106
+
1107
+ Resolution goes through the shared `object-graph.ts` seam (#14105/#14148) — no
1108
+ second field-resolution implementation — and judges the **head segment** of a
1109
+ dotted reference rather than walking relationship hops: a list view compiles no
1110
+ joins, and all three query axes it reaches refuse a dotted path by name
1111
+ (`assertProjectionFieldsExist` #7532 / `assertProjectionHasNoDottedPaths` #7589,
1112
+ the #8371 dotted filter door, `assertSortFieldsExist` #6994). This is strictly
1113
+ wider than "skip dotted paths": `ownr.name` is now reported, where a skip would
1114
+ have passed it.
1115
+
1116
+ **Migration.** A list view refused by the new rule names a field the bound
1117
+ object does not have: correct the spelling (the finding carries a "did you mean"
1118
+ and the object's field list) or drop the entry. The three standard skips apply —
1119
+ an object this stack does not define, an object with no readable field map
1120
+ (ADR-0015 `external`), and registry-injected system columns — plus a fourth on
1121
+ this surface: a list view whose `data.provider` is not `object`.
1122
+ - a39b02a: Resolve an app navigation entry's `viewName` against its object's list views at `validate` and `build`
1123
+
1124
+ `AppNavigationItemSchema.viewName` is documented as *"Default list view to open"*, so an
1125
+ unresolvable name never failed — it **fell back**. A nav entry keeping its authored label and
1126
+ icon would open a different view, and nothing said so: `os validate --json` reported
1127
+ `valid: true` and `os build` was green. The decay mode was worse than the typo mode — renaming
1128
+ a list view silently degraded every nav entry pointing at it, with every gate green and the
1129
+ diff reading correctly in review.
1130
+
1131
+ `lintViewRefs` now walks `app.navigation` (and the `areas[]` container) recursively and reports
1132
+ `view-ref-nav-view-missing` as an **error** when a `viewName` resolves to no list view on the
1133
+ object it names. This extends #2554's existing rule to the second, more travelled door into the
1134
+ same `listViews` namespace rather than adding a new rule class.
1135
+
1136
+ Resolution mirrors the runtime matcher (objectui's `resolveViewId`) in all three directions —
1137
+ exact id, short name retried as `<object>.<name>`, and qualified name with the prefix stripped —
1138
+ so a name that works at runtime is never reported. The accept set narrows only where the stack
1139
+ itself declares the object's list views: an entry is skipped when the `viewName` is interpolated,
1140
+ when `recordId` is set (the schema documents `viewName` as ignored there), when the item carries
1141
+ `requiresObject`, or when this stack contributed no list view for that object.
1142
+ - 225e769: Author-time rejection for unknown `PageComponentSchema.type` strings inside the spec's own namespaces — the type-vocabulary half of the "Component Placeholder" gap.
1143
+
1144
+ `PageComponentSchema.type` is `z.union([PageComponentType, z.string()])`, and the open string arm is deliberate: custom and registered components (`object-grid`, `mcp:connect-agent`, `custom.widget`, kebab SDUI blocks) keep parsing exactly as before — nothing about the parse changed. What is new is that the spec now answers for its own namespaces (`page:` `record:` `nav:` `global:` `user:` `ai:` `app:` `element:`, derived from the enum): a type inside them that the vocabulary does not declare is refused at author time by the new gating rule `component-type-unknown` (`os validate` / `os build` / `os lint`), with the closest declared spellings suggested. Previously `global:serch` validated clean and the published page drew a literal "Component Placeholder" scaffold in front of the end user.
1145
+
1146
+ - `@objectstack/spec` exports the vocabulary claim from `@objectstack/spec/ui`: `RESERVED_COMPONENT_TYPE_NAMESPACES` (derived), `KNOWN_COMPONENT_TYPES` / `KNOWN_COMPONENT_TYPE_CANDIDATES`, `STRING_ARM_REGISTERED_TYPES` (the evidenced ledger of registered-but-row-less types, currently `record:line_items`), and the `hasReservedComponentNamespace` / `isKnownComponentType` predicates.
1147
+ - `@objectstack/lint` ships `validateComponentTypes` (rule id `component-type-unknown`, severity `error`) on all three CLI commands; the runtime publish door is deliberately deferred pending a measured false-refusal budget over stored tenant page rows.
1148
+
1149
+ If a page authored a type in a reserved namespace that nothing declares, the fix is the rule's own hint: rename to the suggested declared type, or move a genuinely custom component to its own namespace (e.g. `my-plugin:widget`) so it cannot be mistaken for platform vocabulary.
1150
+ - e38da2b: feat(lint): resolve an interface page's whitelisted visualizations at validate/build (#14073)
1151
+
1152
+ Accept-set narrowing, `minor` under the family precedent (#14107, #14105, #14148).
1153
+
1154
+ An interface `list` page whitelists renderers with
1155
+ `interfaceConfig.appearance.allowedVisualizations`, and `InterfacePageConfigSchema`
1156
+ is a closed shape with **no per-visualization binding key at all** — no
1157
+ `calendar:`, no `kanban:`, no `map:`. So #13817's parse-time refinement, which
1158
+ demands a `calendar:` block on a list VIEW that whitelists `calendar`, was
1159
+ correctly not extended to this door: a requirement the page surface cannot
1160
+ satisfy would be unauthorable.
1161
+
1162
+ That left the page door with no check of any kind. Measured on objectui
1163
+ `f0f774b0` (after objectui#7029 removed the invented `due_date` default), the
1164
+ renderer derives each binding from the source object's fields
1165
+ (`InterfaceListPage.tsx`, `view.<viz> ?? deriveFromObject(objectDef)`), and when
1166
+ nothing derives there are exactly two outcomes, neither of which reaches the
1167
+ author:
1168
+
1169
+ - the entry LEADS the whitelist — it becomes the page's forced view type, is
1170
+ force-pushed into the switcher's resolvable set, and every visitor lands on
1171
+ the renderer's "Calendar configuration required" refusal screen;
1172
+ - the entry is anywhere else — it is filtered out of the switcher **silently**,
1173
+ while the switcher chrome still appears (it is shown on whitelist length).
1174
+
1175
+ The new rule `page/visualization-without-binding`
1176
+ (`validatePageVisualizationBindings`, a member of the reference-integrity suite,
1177
+ so it runs on `validate` / `lint` / `compile`) asks the renderer's own question
1178
+ at authoring time. For every `list` page, each whitelisted visualization must be
1179
+ either derivable from the source object's declared fields — using the SAME
1180
+ predicates the renderer applies, the field TYPE first and then the NAME regex
1181
+ fallback (kanban: select-like type or status-like name; calendar and timeline: a
1182
+ date-typed non-hidden non-system field, else a date-like name; gallery:
1183
+ image-typed, no name leg; gantt: two distinct date fields; map: location-typed or
1184
+ a geo-like name) — or bound by the block of the list view the page references
1185
+ through `sourceView` (a `calendar:` block also binds a `timeline`, which is what
1186
+ `resolveTimelineDateBinding` accepts). `grid` always passes.
1187
+
1188
+ Severity tracks what the visitor sees: **`error`** when the unbound entry is
1189
+ `allowedVisualizations[0]`, **`warning`** otherwise. Every message names
1190
+ `sourceView` as the remedy, because on this door it is the one schema-legal
1191
+ channel for a per-visualization binding — exactly how the shipped showcase map
1192
+ page binds its `locationField`.
1193
+
1194
+ Deliberately NOT stricter than the renderer: mirroring both predicates rather
1195
+ than the type half alone means a page whose only date is a text field called
1196
+ `due_date` still passes, because it still renders. The mirrored table is exported
1197
+ (`OBJECTUI_DERIVATION_PREDICATES`) and pinned verbatim by a fixture test, and the
1198
+ seven shipped `showcase_task` interface pages are pinned as a live regression
1199
+ corpus — the two halves catch drift on either side of a mirror no build edge
1200
+ connects. `chart` and `tree` get no verdict: the renderer derives no binding for
1201
+ them on this seam, so the rule says nothing rather than guessing.
1202
+
1203
+ **Migration.** A page reported by the new rule whitelists a visualization that
1204
+ renders nothing: point the page at a list view that declares the block
1205
+ (`interfaceConfig.sourceView`), give the source object a field the derivation can
1206
+ find, or drop the entry from `appearance.allowedVisualizations`. Four skips keep
1207
+ it quiet where it cannot know: a page that is not `type: 'list'`, an object this
1208
+ stack does not define, an object with no readable field map (ADR-0015
1209
+ `external`), and a `sourceView` naming a view this stack does not declare (the
1210
+ runtime hydrates stored view bodies over the network).
1211
+
1212
+ No `packages/spec` change — the page surface stays closed, which is what ADR-0047
1213
+ §7 open question 3 asks for.
1214
+ - 5383fa6: React-tier vocabulary converges on the metadata-tier spelling, deprecate-first (#11284, maintainer ruling 2026-08-23). `<ListView>`'s canonical bindings are now the spec ListView schema's own props: `data={{ provider: 'object', object: '…' }}` for the object binding (objectui#2890 A6) and `type` for the visualization kind. `objectName` and `viewType` remain published and accepted as deprecated aliases for the whole deprecation window — nothing is removed in this release — with the deprecation visible at authoring time: `[DEPRECATED → …]` markers in the generated react-blocks contract, and a new `react-prop-deprecated` lint warning (never an error) on every use of a deprecated spelling. The lint accepted either spelling as satisfying `<ListView>`'s required binding and resolved field-name props (`columns`, `searchableFields`, filter positions, …) against the object bound by whichever spelling is present, canonical winning when both are — true at this entry's own commit `5383fa6`, and already false in the release that published it. `22b0081` (#15025) landed before the 17.3.0 version cut and tightened the lint back to what actually renders, so as released here every clause is the other way round: a `<ListView>` bound only by `data={{ provider: 'object', object }}` does NOT satisfy the required binding and `os validate` refuses it, field-name props resolve against `objectName` only, and `objectName` is the spelling that decides when a page carries both, because it is the one the renderer queries. `objectName` / `viewType` are therefore the spellings to write. `<ObjectForm>` / `<ObjectChart>` `objectName` are unchanged: the form's spec counterpart is explicitly not 1:1 (objectui#2890 Scope B), and the chart has no metadata-tier object binding to converge on (charts bind through a dashboard `dataset` there — see chart.zod.ts guidance). Removal of the deprecated aliases was described here as a later card after the deprecation window; that is no longer the plan. The maintainer ruling of 2026-09-07 on #14791 — the card that measured the missing consumer half — retires `objectName` / `viewType` with no deprecation window at all, leaving `data` / `type` as the only spellings. objectui has since landed the renderer fold the canonical spelling needed (present at the pinned `.objectui-sha`, `packages/core/src/utils/normalize-list-view.ts`), and the spec-side retirement is authored as PR #16777, still draft and awaiting a maintainer merge — so within this release the deprecated spellings remain the working ones. (Corrected after publication, #15026.)
1215
+ - 46b53a2: Add `validateReadonlyActionWrites` — an author-time warning on an action body writing a `readonlyWhen` field through `ctx.api`.
1216
+
1217
+ The action surface is the third write surface in the readonly family, after `flow-update-readonly-field` and `hook-api-update-readonly-field`, and it is the one where the family's answer differs. An action body's `ctx.api` is `createContext({ ...callerEnvelope, isSystem: true })` — elevated by design, so RLS/FLS-bypassing trusted execution is the documented posture — and the engine's **static** readonly strip runs only for non-system callers. Measured against a real engine over a memory driver:
1218
+
1219
+ | channel | static `readonly` | `readonlyWhen`, predicate TRUE |
1220
+ | --- | --- | --- |
1221
+ | action body `ctx.api` | lands | **stripped** |
1222
+ | hook body `ctx.api`, non-system trigger | stripped | stripped |
1223
+ | `ctx.api.sudo()` | lands | **stripped** |
1224
+
1225
+ So exactly one shape is a silent no-op on this surface, and that is what the new rule reports:
1226
+
1227
+ - `action-api-update-readonly-when-field` — **warning**. A literal `ctx.api.object('…').update()` / `.updateById()` in an action body writing a field the named object declares `readonlyWhen`. The conditional strip takes no `isSystem` exemption, so elevation is not a workaround and the hint does not offer one: confirm the call only targets records whose predicate is FALSE, or derive the field in a `beforeUpdate` hook on the target object (a hook-written value is not caller-supplied and does land).
1228
+
1229
+ A static-`readonly` counterpart is deliberately **not** shipped: an elevated action write lands on such a field, so the finding would state a falsehood and, at the hook rule's `error` grade, would gate a build over working code.
1230
+
1231
+ Wired through `REFERENCE_INTEGRITY_RULES`, so it runs on `os validate`, `os lint` and `os compile` at once. It reuses the existing machinery rather than adding any: `buildReadonlyIndex` from the flow rule for the field metadata, and `collectActionBodies` from the action rule for the body walk (both registration sites, with the merged-action de-duplication that walk owns).
1232
+
1233
+ `ctx.record` is excluded from the match set, and that exclusion is the rule's load-bearing decision: an action's `ctx.record` is a dead snapshot the runtime never writes back, so no readonly strip is ever consulted on it and a readonly verdict there would be false on every occurrence. `action-record-write-discarded` already owns that shape and states its real reason. Also skipped, each for a stated reason: `insert` / `create` (INSERT is exempt from both strips), `ctx.input` writes (an action's `ctx.input` is its params bag), dynamic object names, non-literal payloads, objects this stack does not declare, fields the object does not declare, and `id` in an `update` payload (the row address, not a field write).
1234
+ - 36d2878: Add `validateReadonlyHookWrites` — an author-time gate on a hook body writing a `readonly` field through `ctx.api`.
1235
+
1236
+ A hook's `ctx.api` is a `ScopedContext` over the **triggering** operation's execution context, so `ctx.api.object('x').update({ someReadonlyField })` reaches the engine as an ordinary non-system caller and the update path strips the key. The call returns success, the step looks clean, and the column is simply always null — a failure only an end-to-end read-back detects. This completes the hook side of the flow-side gate that shipped as `flow-update-readonly-field`.
1237
+
1238
+ Two new rule ids, wired through `REFERENCE_INTEGRITY_RULES` so they run on `os validate`, `os lint` and `os compile`:
1239
+
1240
+ - `hook-api-update-readonly-field` — **error**. A literal `ctx.api.object('…').update()` / `.updateById()` writing a field the named object declares `readonly: true`.
1241
+ - `hook-api-update-readonly-when-field` — **warning**. The same write against a `readonlyWhen` field, which strips per record state.
1242
+
1243
+ The rule keys on the write **channel**, not on the field, so the correct and widely used pairing is untouched: a `beforeInsert`/`beforeUpdate` body stamping `ctx.input.<field> = …` writes a server value that survives the strip and is **never** flagged. Also skipped, each for a stated reason: `ctx.api.sudo()` chains (elevated — the intended channel), `insert`/`create` (INSERT is engine-exempt), dynamic object names, non-literal payloads, objects this stack does not declare, fields the object does not declare, and `id` in an `update` payload (the row address, not a field write).
1244
+ - 39404f3: feat(spec,lint): a layout section can reference a declared field group instead of copying its members (#13855)
1245
+
1246
+ Additive accept widening. Maintainer ruling 2026-08-31 (option B on #13855):
1247
+ 「直接处理b」.
1248
+
1249
+ ADR-0085 makes `fieldGroups` + `Field.group` the canonical grouping, assembled in
1250
+ one place — `deriveFieldGroupLayout` (ADR-0085 §5). The two layout escape hatches
1251
+ were whole-takeover shapes with no way back to it: a custom record page's
1252
+ `record:details` `properties.sections` and a view-level `form.sections` each
1253
+ enumerated their members by hand, so an author who reached for either had to
1254
+ hand-copy the same membership fact a second and third time. Nothing linked the
1255
+ copies to the declaration, so every field added to the object afterwards made
1256
+ them quietly staler — measured on a real app as three disagreeing groupings of
1257
+ one object, with the detail page missing two fields the form showed.
1258
+
1259
+ **A section may now name the group instead.** On both surfaces:
1260
+
1261
+ ```ts
1262
+ sections: [
1263
+ { group: 'contact_info' }, // members + presentation derived
1264
+ { label: 'Notes', fields: ['note'] }, // enumerated, unchanged
1265
+ ]
1266
+ ```
1267
+
1268
+ Members (every visible field whose `Field.group` points at the key, in
1269
+ field-declaration order) and the group's own presentation (label, icon,
1270
+ description, `collapse`, `visibleWhen`, and the drop when a group has no visible
1271
+ members) all come from `deriveFieldGroupLayout`. Nothing is re-implemented in
1272
+ section land.
1273
+
1274
+ **The mixing rule**, declared once for both surfaces and pinned:
1275
+
1276
+ - `group` and `fields` are mutually exclusive; a section declaring neither is
1277
+ refused (before this change it was unrepresentable, because `fields` was
1278
+ required).
1279
+ - A group-referencing section carries no key the group already declares —
1280
+ `name`, `label`, `icon`/`description`, the collapse pair, `visibleWhen` (and
1281
+ its deprecated `visibleOn` spelling) are refused beside `group`, each with the
1282
+ pointer to the `fieldGroups` entry that owns it. Not a precedence rule: the
1283
+ absence of one. The surface keys the group says nothing about — `columns`,
1284
+ `pane`, `hideEmpty`, `showBorder`, `headerColor` — ride alongside as usual.
1285
+ - Across sections, both kinds coexist in declared array order; a
1286
+ group-referencing section occupies one slot and expands in place.
1287
+ - ⛔ Not on a wizard step: a group carries `visibleWhen` and `collapse`, and a
1288
+ wizard step has no slot for either (the #13704 refusals, reached through the
1289
+ object's declaration instead of the step's own keys).
1290
+
1291
+ **Existence is checked by reference diagnostics, not at parse.** The key names
1292
+ something on a different schema, so the spec door takes any well-formed
1293
+ snake_case key — the `UserFilterFieldSchema.field` precedent. `@objectstack/lint`
1294
+ reports a dangling one as `page-section-group-unknown` (`record:details`) or
1295
+ `form-section-group-unknown` (form views, both the canonical `sections` and the
1296
+ legacy `groups` bucket), advisory like every other dangling-reference finding in
1297
+ that family, with the object's declared groups listed in the hint.
1298
+
1299
+ The key grammar is now single-sourced as `FIELD_GROUP_KEY_PATTERN` beside the
1300
+ derivation, so the declaring surface (`ObjectFieldGroupSchema.key`) and the two
1301
+ referencing surfaces cannot drift into accepting different keys.
1302
+
1303
+ **Type-surface note for consumers.** `fields` becomes optional on both section
1304
+ shapes (that is what makes `group` the other way to declare the same fact), so
1305
+ `z.infer` now types it `… | undefined`. A consumer that reads `section.fields`
1306
+ unconditionally must handle the reference form; every in-repo reader already
1307
+ guards it. No authored metadata changes shape, and nothing that parsed before
1308
+ stops parsing — `fields: []` included.
1309
+
1310
+ The renderer half (objectui) is tracked separately; until it lands, a
1311
+ group-referencing section is declared and diagnosed but not yet rendered.
1312
+ - 06ee8bf: Warn when a bare identifier in a flow node/edge condition is shadowed by a declared flow variable
1313
+
1314
+ A flow `condition` is evaluated in a flattened scope, so a bare `status` normally
1315
+ resolves to the trigger record's field and is the correct, canon-taught spelling.
1316
+ `objectstack validate` deliberately never judged a bare identifier there, and it
1317
+ still does not — with one exception it now names.
1318
+
1319
+ When the same name is BOTH a declared flow variable and a field on the bound
1320
+ object, the two collide silently: a run seeds its declared variables first and
1321
+ flattens the record's fields only where nothing is bound yet, so the variable
1322
+ wins, the field is unreachable under its own name, and nothing anywhere reports
1323
+ it. The author reads `status` and gets the variable. On this surface that is the
1324
+ least visible failure there is — a flow condition that never fires produces no
1325
+ record, no error and no log line.
1326
+
1327
+ `validateStackExpressions` now emits a `warning` (never an error) on exactly that
1328
+ case, naming the mechanism and both repairs: `record.status` for the field, or
1329
+ rename the variable. A bare name that is only a field, or only a variable, stays
1330
+ silent as before.
1331
+
1332
+ The variable set is collected across every ADR-0031 region of the flow, since a
1333
+ run holds one variable map: flow-level declarations, loop/map iterator and index
1334
+ variables, the try/catch error variable, node output variables, assignment
1335
+ targets in all three shapes the executor accepts (including a legacy assignment
1336
+ node with no `assignments` wrapper, whose top-level config keys are the variable
1337
+ names), and node ids, which are bare CEL roots at runtime.
1338
+ - d23dc08: feat(spec,lint,metadata-protocol): a `page` member on the `view` type enum — mount an already-published page on an object view (#13216)
1339
+
1340
+ A custom page created and published at runtime through the metadata API had no
1341
+ in-protocol way to reach an end user (#13100's evidence map). App navigation is
1342
+ closed to runtime content (`app.allowOrgOverride: false`), and the `view` `type`
1343
+ enum — on one of the five types the platform deliberately leaves open
1344
+ (`allowOrgOverride: true`, `allowRuntimeCreate: true`) — was closed over
1345
+ declarative row renderers, so a published page could not be mounted as an
1346
+ object's list view or tab.
1347
+
1348
+ Maintainer ruling 2026-08-29 (live director session, verbatim 「同意」), 方向 1:
1349
+
1350
+ > `view` 的 `type` 枚举新增 `page` 成员——对象的列表视图/标签页可挂载一个已发布页面。走平台**有意开着**的门(`view` 本就 `allowOrgOverride=true` + 运行时可创建),零新增授权面;设计要点:`page` 型 view 需声明 `pageName` 绑定,校验目标页面存在,渲染委托既有页面渲染器
1351
+
1352
+ **Zero new authorization surface, as the ruling's basis requires.** Nothing in
1353
+ this change touches a metadata type's `allowOrgOverride` / `allowRuntimeCreate`
1354
+ flags, adds a write door, or adds a read door. A `page` view is a `view` written
1355
+ through the door `view` already opens, and it holds a NAME — the page itself is
1356
+ still fetched through the page read path it already had, and still renders
1357
+ through the existing page renderer, so the page's own audience gate
1358
+ (`page.assignedProfiles`) rides along unchanged. Delegation is what preserves
1359
+ that: a second renderer is what would have introduced a second gate.
1360
+
1361
+ **The binding, refused in both directions at parse.** `ListViewSchema` gains
1362
+ `pageName`, declared with `SnakeCaseIdentifierSchema` — the same grammar
1363
+ `PageSchema.name` carries, so the accepted set is exactly the set of strings that
1364
+ could name a page. `checkListViewPageMount` then refuses:
1365
+
1366
+ - `type: 'page'` with no `pageName` — unlike every other view type there is no
1367
+ degraded rendering to fall back to, so the view would be blank;
1368
+ - `pageName` on any other view type — the accepted-and-ignored shape;
1369
+ - a non-empty `columns` beside a page mount — `columns` is the one required key
1370
+ on a list view, and the only truthful value for a page mount is `[]`.
1371
+
1372
+ The check is attached at all three list-view doors (`ListViewSchema`,
1373
+ `ObjectListViewSchema`, and the flattened runtime overlay behind
1374
+ `PUT /api/v1/meta/view`), with a pinned test that fails if any attachment is
1375
+ dropped.
1376
+
1377
+ **Existence of the target page** is answered where the collection is visible:
1378
+ `defineStack`'s `validateCrossReferences` refuses at build time (same
1379
+ `pageNames.size > 0` policy the two other page references in that function
1380
+ already use), and the new `@objectstack/lint` rule `view-page-unresolved`
1381
+ (`validateViewPageRefs`) resolves it on `os validate` / `os lint` / `os compile`
1382
+ **and** at the runtime publish gate. Advisory, not gating, for its nav twin's
1383
+ reason: with no curated cross-package page registry, "unresolved here" cannot be
1384
+ told apart from "provided by a package this stack cannot see".
1385
+
1386
+ Reaching the runtime publish gate needed the per-write snapshot to carry the
1387
+ `pages` collection (`RuntimeStackContext.pages`, threaded through
1388
+ `evaluateRuntimeAuthoringGate` and read off the live registry in
1389
+ `saveMetaItem`'s gate call). That is the one-key widening `RuntimeStackContext`
1390
+ documents, made when a rule that reads the collection crossed the wall — never
1391
+ in advance — and the false-positive channel it closes is measured both ways in
1392
+ `runtime-gate.view-page-refs.test.ts`. `pages` joined `NAME_KEYED_STACK_KEYS` in
1393
+ the same edit, because a collection that is both context-filled and
1394
+ write-targeted must have its finding paths name-keyed (#10064).
1395
+
1396
+ **Downstream note (not an accept-set narrowing).** No previously valid metadata
1397
+ becomes invalid: `pageName` is a new key and `page` a new enum member, so every
1398
+ refusal above can only fire on a document that could not be written before.
1399
+ What does change for a downstream schema author is composition: `ListViewSchema`
1400
+ now carries a refinement, and zod 4 refuses `.omit()` / key-overwriting
1401
+ `.extend()` on a refined object. The unrefined shape stays module-private
1402
+ (publishing it would mint a duplicate protocol def and a second full set of
1403
+ ratcheted authorable-surface keys), so a consumer that derived from
1404
+ `ListViewSchema` by omission should compose with `.safeExtend()` or narrow after
1405
+ parsing. `FormViewSchema` has had this property since its own refinement landed,
1406
+ so this is the established shape for view schemas rather than a new one.
1407
+
1408
+ **Deliberately out of scope**, per the same ruling: 方向 2 (registering app
1409
+ navigation at publish time) is deferred to its own design card — it would
1410
+ require reversing the `app.allowOrgOverride: false` authorization decision — and
1411
+ with it the known limitation the ruling accepts on the record, that a page
1412
+ belonging to no object still has no browse-to entry. `page` is also NOT added to
1413
+ `VisualizationTypeSchema`: the switcher offers alternative ways to draw the same
1414
+ rows, and a page draws none.
1415
+ - 038f333: feat(lint,formula): refuse a visibility predicate that calls a function the CEL environment does not register (#13594)
1416
+
1417
+ An accept-set narrowing at `objectstack validate` and at the runtime publish
1418
+ door, ruled by the maintainer on 2026-08-31 (director batch #21) on a censused
1419
+ premise.
1420
+
1421
+ **The hole.** `validate-visibility-predicates` — the gate that judges
1422
+ `visibleWhen` on view form sections/fields and page components — was
1423
+ deliberately parse-only. So a predicate that parses perfectly and calls a
1424
+ function that does not exist passed CLEAN, measured side by side with two
1425
+ controls that fired:
1426
+
1427
+ ```text
1428
+ source lint gate (before) validateExpression
1429
+ totallyBogusFn(1,2) CLEAN ok=false
1430
+ record.x.nosuchmethod('a') CLEAN ok=false
1431
+ country === "USA" syntax ok=false <- control
1432
+ status == 'active' bare-identifier ok=true <- control
1433
+ ```
1434
+
1435
+ The runtime fault it hides is the worst-shaped one the platform has: on a view
1436
+ or page surface it falls OPEN (the element renders unconditionally, identical
1437
+ to carrying no predicate at all), and on an action surface — evaluated with
1438
+ `throwOnError: true` — it falls CLOSED, so the action disappears for every
1439
+ user *including one who holds the grant*, behind a single deduped
1440
+ `console.warn` (objectui#4421). A plausible-looking function name that does not
1441
+ exist is exactly what a generator invents.
1442
+
1443
+ **What changed.**
1444
+
1445
+ - `@objectstack/formula` publishes `firstUnknownFunctionCall(source)` — the
1446
+ function-EXISTENCE verdict, isolated from everything else cel-js's `check()`
1447
+ has an opinion about. The oracle is the evaluation environment's own
1448
+ registration set, read through the same `buildEnv` seam `celEngine.compile`
1449
+ and `celEngine.evaluate` build with — never the advertised
1450
+ `CEL_STDLIB_FUNCTIONS` catalog, which lists 35 of the 72 registered names and
1451
+ would have refused 37 functions that resolve and evaluate today (`type`,
1452
+ `map`, `filter`, `split`, `getFullYear`, `json`, …).
1453
+ - `@objectstack/lint` gains `visibility-predicate-unknown-function`
1454
+ (**error**), covering both call forms — global (`totallyBogusFn(1,2)`) and
1455
+ receiver/member (`record.x.nosuchmethod('a')`). The message quotes the
1456
+ engine's own `found no matching overload for '…'` verbatim so publish time
1457
+ and run time read as one system, and offers **no** "did you mean" suggestion:
1458
+ nearest-name matching over the function namespace was measured to answer
1459
+ `min` for `can`.
1460
+
1461
+ **Scoped supersession, not a widening.** The module's parse-only ruling stands
1462
+ for everything except function existence. A registered name called with wrong
1463
+ arguments (`upper(1, 2)`), a registered name called in the wrong position
1464
+ (bare `split('a,b')`), the CEL-type blind spot (`type == 'grid'`) and every
1465
+ operator-overload fault (`1 + 'a'`) are all still unreported — `type(record.x)
1466
+ == string` and every other legal `dyn` predicate is untouched. Refusing an
1467
+ unregistered call cannot be a false positive: the validation and runtime
1468
+ environments are the same builder (53 probes, 0 divergence), so the call this
1469
+ refuses is a call that would have faulted.
1470
+
1471
+ **Migration.** A refused predicate names a function that does not exist and
1472
+ never evaluated — replace it with an advertised callable, or precompute the
1473
+ value into a formula field on the object and test that field. The census found
1474
+ **0** host-registered extra CEL functions across the reachable corpus
1475
+ (objectstack `packages/`/`examples/`/`apps/`, objectui, one shipped host app),
1476
+ with a firing positive control, and this repo's `examples/**` and `apps/**`
1477
+ sweep produces **0** new refusals. `cloud`, `objectos` and published
1478
+ third-party apps were NOT MEASURED — if a host in one of those registers extra
1479
+ CEL functions, its predicates are refused; that gap was declared before the
1480
+ ruling and accepted with it.
1481
+ - fa1eca3: Resolve a dashboard widget's OWN `filter` keys and `options.sortBy` at validate/build
1482
+
1483
+ A dashboard widget could filter by a column that does not exist, and order by a name
1484
+ it never selected, and `objectstack validate` exited 0 with "Validation passed";
1485
+ `build` — the publish gate — wrote the dashboard into `dist/objectstack.json`. The
1486
+ widget then rendered **empty**.
1487
+
1488
+ The surrounding surface was already covered, which is what made the two misses so
1489
+ narrow: `widget-dataset-unknown`, `widget-dimension-unknown`, `widget-measure-unknown`,
1490
+ `filter-token-unknown` and `dashboard-filter-field-unknown` all failed both gates on the
1491
+ same dashboard. On the very same node, the filter TOKEN was checked and the filter
1492
+ COLUMN was not — `filter-token-unknown` fires path-precise at
1493
+ `…widgets[4].filter.due_date.$lte`, so the traversal already walked the filter tree and
1494
+ already knew the widget's dataset. Only the key resolution was missing. And
1495
+ `options.sortBy` was declared-≠-enforced in the plainest way available: the spec states
1496
+ the contract in its own prose — *"must be one this widget actually selects"* — and
1497
+ nothing enforced it.
1498
+
1499
+ Why this class of miss is expensive rather than untidy, in the reporter's words: the
1500
+ dashboard it was measured on leads with a "not moving" tile — open work untouched more
1501
+ than 14 days — and *"an empty tile is indistinguishable from a healthy team: a missing
1502
+ number reads as zero, and zero is the answer the manager is hoping for."* The failure is
1503
+ silent in the direction the reader wants to believe.
1504
+
1505
+ Three gating rule ids, all at the site that already emits `widget-dataset-unknown` /
1506
+ `dashboard-filter-field-unknown`, and all failing **`validate` and `build`** (pinned
1507
+ end-to-end, not inferred from the registry entry):
1508
+
1509
+ - `widget-filter-field-unknown` — a key of the widget's own `filter` resolves to no
1510
+ column on the bound dataset's object graph. Reported path-precise at
1511
+ `dashboards[i].widgets[j].filter.<key>`, matching `filter-token-unknown`'s precision in
1512
+ that same subtree.
1513
+ - `widget-filter-field-not-included` — the key resolves, but its relationship prefix is
1514
+ not declared in the dataset's `include`, so ADR-0021 compiles no join and the column is
1515
+ out of the query's reach.
1516
+ - `widget-sortby-unselected` — `options.sortBy` names neither a `dimensions[]` nor a
1517
+ `values[]` entry of the widget. A name the dataset declares but the widget did not
1518
+ select gets its own message, because the fix is a selection rather than a spelling.
1519
+
1520
+ **A dotted path through a declared `include` is RESOLVED, not skipped.** A widget's
1521
+ `filter` is ANDed into the dataset query as `runtimeFilter`, and that compiled query
1522
+ carries only the joins `include` declared — so the same two clauses the dataset rule
1523
+ applies one level down (existence, then joinability) apply here. The runtime is not a
1524
+ backstop for the second: the dataset compiler's `assertDeclared` runs over `dimensions`
1525
+ and `measures` only, never over `runtimeFilter`.
1526
+
1527
+ Built on the seams that shipped with the dataset-level sibling rather than a second
1528
+ implementation: `walkFilterFieldKeys` (all three authored filter shapes) and
1529
+ `indexObjectGraph` / `resolveFieldPath`. Two helpers that were local to that rule —
1530
+ `joinablePrefixes` and `describeFieldPathVerdict` — moved into the shared seam and are
1531
+ now exported, because both are answers this position asks identically and copying either
1532
+ would have been the second implementation the seam exists to prevent.
1533
+
1534
+ Minor rather than patch: this narrows the accept set. Metadata that built yesterday and
1535
+ names a column or an order that does not exist now fails the build — which is the point.
1536
+ The three skips every field-existence rule in this package takes are unchanged, so an
1537
+ object the stack does not define, an ADR-0015 `external` object with no readable field
1538
+ map, and a registry-injected system column are never reported.
1539
+
1540
+ ### Patch Changes
1541
+
1542
+ - e5ed943: fix(lint): guard `collectBare`'s recursion so a self-referential page terminates instead of killing the stack (#13235)
1543
+
1544
+ `page-envelope-audit`'s `collectBare` is a lockstep raw/parsed value walker,
1545
+ separate from the shared `walkPageComponents` traversal, and it carried no cycle
1546
+ guard. A page whose component tree contains itself (`A -> B -> A` through
1547
+ `properties.children`) is input the schema **admits** — `properties` is
1548
+ `z.record(z.unknown())` and `properties.children` is `z.array(z.unknown())`, so
1549
+ `PageSchema.safeParse` succeeds — and door 1 then recursed until
1550
+ `RangeError: Maximum call stack size exceeded`. Door 1 runs over the whole page
1551
+ before any other door, so this is the first thing that died on such a page.
1552
+
1553
+ The guard is an **ancestor set on the authored side**: objects are added on
1554
+ entry to the descent and removed on exit, so a node is skipped only when it is
1555
+ its own ancestor. Two consequences are pinned by tests:
1556
+
1557
+ - **Report-neutral on acyclic input, by construction.** No node is ever its own
1558
+ ancestor on an acyclic page, so the guard never fires and findings are
1559
+ unchanged. A visited-set would instead have skipped merely *shared* subtrees —
1560
+ the same component literal referenced from two slots is legal authoring — and
1561
+ silently dropped their findings.
1562
+ - **Nothing distinct is lost on a cyclic page.** Every node of a finite graph is
1563
+ reachable by a simple path, so each authored position is still visited; only
1564
+ the infinite tail of re-reports at ever-longer paths is dropped.
1565
+
1566
+ Cycles through arrays are covered as well as cycles through records.
1567
+
1568
+ Scope: this is door 1 only. `walkPageComponents` carries its own separate
1569
+ unguarded recursion, so `auditPageExpressionEnvelopes` end-to-end still dies at
1570
+ the walk on the same input until that lands (#13217). No published type changes
1571
+ and no accept/reject behaviour changes on any page that parses today.
1572
+ - 834da6f: lint: `dashboard-filter-field-unknown` resolves dotted dashboard-filter fields on the object graph, and answers system columns per object
1573
+
1574
+ A dashboard-level filter (`dateRange`, or a `globalFilters[]` entry) is ANDed into
1575
+ **every** widget's analytics query, so its effective field — after any
1576
+ `filterBindings` re-target — has to resolve on each bound widget's dataset object.
1577
+ The rule that enforces that shipped with two holes, and this closes both by
1578
+ migrating the check onto the shared `resolveFieldPath` / `joinablePrefixes` seam
1579
+ the widget's own `filter` keys already use one position over.
1580
+
1581
+ - **Dotted paths are no longer skipped.** The branch carried
1582
+ `if (field.includes('.')) continue;`, accurate when nothing in the package could
1583
+ walk relationship hops and false since the object-graph seam landed. A filter
1584
+ re-targeted to `account.signed_at` was unjudged whether or not `account` existed,
1585
+ whether or not `signed_at` existed on it, and whether or not `account` was
1586
+ declared in the dataset's `include`. It is now walked hop by hop, and a miss
1587
+ names **which** hop failed.
1588
+ - **System columns are resolved per object, not through the flat union.** The old
1589
+ test was `objectFields.has(field) || SYSTEM_FIELDS.has(field)`, which answers
1590
+ "could this be a system column *anywhere*". On an `ownership: 'none'` object the
1591
+ platform injects no `owner_id`, and on `systemFields: { audit: false }` no
1592
+ `created_at` — both were answered as resolvable and are now reported.
1593
+
1594
+ New error id **`dashboard-filter-field-not-included`**: the effective field
1595
+ resolves, but its relationship prefix is not declared in the bound dataset's
1596
+ `include`, so ADR-0021 compiles no join and the column is out of the broadcast
1597
+ query's reach. It mirrors `widget-filter-field-not-included` one level down, and is
1598
+ its own id because the fix is a different edit (declare the join, versus point the
1599
+ filter at something real).
1600
+
1601
+ This **narrows the accept set of a shipped gating rule**. Both new answers are
1602
+ error-tier, so like the rule's other errors they fail `os validate` / `os build`
1603
+ and the runtime publish gate for `dashboard` writes. Measured over the shipped
1604
+ dashboard corpus — the three example apps plus the platform's own
1605
+ `system_overview` — the change is 0 findings before and 0 after; the
1606
+ `dashboard-filter-field-unprovisioned` warning is unchanged and now travels with
1607
+ the verdict, so it answers a dotted path landing on an ADR-0015 `external` object
1608
+ too.
1609
+ - a392dbf: fix(ai): author the CANONICAL agent id everywhere the platform teaches one — Studio's pin, the MCP prompt example, and the lint's value roster (#14461)
1610
+
1611
+ `skills/objectstack-ai` tells authors that `data_chat` and `metadata_assistant`
1612
+ "are **not** vocabulary — always write `ask` / `build`". The platform then
1613
+ taught the opposite from every live example it ships. Nothing was broken at
1614
+ runtime; what was wrong is what an author copies.
1615
+
1616
+ **Studio's pin.** `studio.app.ts` was the repo's ONLY `app.defaultAgent` usage,
1617
+ and it spelled the alias:
1618
+
1619
+ ```
1620
+ - defaultAgent: 'metadata_assistant',
1621
+ + defaultAgent: 'build',
1622
+ ```
1623
+
1624
+ The triage card left this undecidable — if the cloud plugin registered the
1625
+ agent under the legacy id, re-pinning would be a behaviour change in a
1626
+ consumer this repo cannot see. Measured instead of assumed, at `cloud`
1627
+ `main@3856fbf7`: `service-ai-studio/src/agents/metadata-assistant-agent.ts:12,40`
1628
+ ships the record as `name: BUILD_AGENT_NAME` = `'build'`, and `plugin.ts:58`
1629
+ registers `metadata_assistant` as a **one-way, resolution-only** legacy alias.
1630
+ The canonical id *is* `build`; the old pin reached it by detour.
1631
+
1632
+ Nor is the re-pin cosmetic. Alias resolution depends on an in-memory
1633
+ `registerAgentAlias` call having run at plugin init, and cloud carries two
1634
+ defensive docblocks about that registration silently no-op'ing for real under
1635
+ bundle load ordering (`service-ai-studio/src/plugin.ts:44-57`,
1636
+ `service-ai/src/agent-runtime.ts:30-41` — "a missed alias must never hide a
1637
+ real platform agent like `build`"). The canonical id never touches the alias
1638
+ table, so this drops a load-order dependency from the platform's own flagship
1639
+ authoring surface. On the UI side nothing moves: `objectui`'s
1640
+ `AGENT_ALIAS_GROUPS` is bidirectional and canonical-first, and
1641
+ `SURFACE_DEFAULT['studio-build']` was already `'build'`.
1642
+
1643
+ **The MCP prompt example.** `mcp-server-runtime.ts`'s `agent_prompt` argument
1644
+ described itself as `'Name of the agent to load (e.g. "data_chat",
1645
+ "metadata_assistant")'` — two retired aliases, neither canonical id present.
1646
+ That string is served to every MCP client asking what to pass, so the one
1647
+ surface that suggests a spelling to an LLM suggested the two the catalogue
1648
+ forbids. Now `(e.g. "ask", "build")`.
1649
+
1650
+ **The lint's value roster.** `validate-ai-agent-authoring`'s `defaultAgent`
1651
+ **value** limb reused the four-name `PLATFORM_AGENT_NAMES` set, so it
1652
+ deliberately passed `metadata_assistant` — the gate that exists to make
1653
+ authoring mistakes loud waved through the exact spelling the catalogue bans,
1654
+ which is the silent-tolerance shape ADR-0078 exists to close, committed by the
1655
+ gate itself. The two limbs now read different tables, because they ask
1656
+ different questions:
1657
+
1658
+ - **declaration limb** — unchanged, still all four names. Declaring
1659
+ `metadata_assistant` shadows the `build` record through the alias exactly as
1660
+ declaring `build` does.
1661
+ - **value limb** — canonical `ask` / `build` only. A legacy alias gets its own
1662
+ rule id `default-agent-legacy-alias` (exported) and its own wording, because
1663
+ an alias **resolves** (the app gets the agent it meant — a spelling defect)
1664
+ while an unknown name does **not** (the pin is inert). Describing the alias
1665
+ as "no effect" would send an author hunting a bug that is not there.
1666
+
1667
+ Both of the #6041 ruling's operative decisions are kept intact: still
1668
+ `warning` tier, still no Zod enum narrowing. `defaultAgent: 'metadata_assistant'`
1669
+ keeps parsing, building, and resolving — the only change is that authoring it
1670
+ now says so.
1671
+
1672
+ Not breaking: nothing an author can write was removed, and both aliases stay
1673
+ resolvable for old bookmarks and persisted `agent_id`s, which is the only job
1674
+ ADR-0063 §2 ever gave them.
1675
+ - 948dd6b: `defineStack` now refuses a stack that declares an auto-launched flow while `requires` omits `'triggers'` (#14153) — **BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes.
1676
+
1677
+ A `record_change`, `schedule`, `time_relative` or `api` flow fires only when its trigger is mounted, and every one of those triggers ships in `@objectstack/trigger-*` behind ONE capability token, `requires: ['triggers']`. `defineStack` already hard-errors the same declared-capability class for the hierarchy scopes (`unit` / `unit_and_below` / `own_and_reports` need `'hierarchy-security'`), which fail CLOSED when the capability is missing — a user notices the missing rows. The trigger half failed SILENT: the flow registered, `validate` / `typecheck` / `test` / `build` all exited 0, and the automation simply never happened. Measured downstream: an app shipped four correctly-authored flows, zero bound, across five merged rounds, and the only diagnostic was a boot-banner line printed after deploy.
1678
+
1679
+ The refusal lands in the same throw-site family as its sibling (`defineStack trigger capability validation failed (N issue(s)):` with one `✗` line per flow) and reuses the boot audit's own wording — the flow name, the resolved trigger kind, and the exact remedy (`Add requires: ['triggers'] (record_change/schedule/time_relative/api ship in @objectstack/trigger-*)`). An absent `requires` counts as omitting the token: the CLI reads it as `[]` and appends only the always-on slate, which mounts neither `automation` nor `triggers`, so a stack that declares nothing gets no trigger either. Flows whose `status` disables them (`obsolete` / `invalid`) are skipped, exactly as the engine's boot audit skips them. A stack whose flows are all `screen` or hand-launched `autolaunched` owes nothing. The fix for a refused stack is the one line the message names; a flow that was genuinely meant to be launched only by hand declares `type: 'autolaunched'` (or `'screen'`) instead of a trigger it never intended to bind.
1680
+
1681
+ The kind a flow asks for is now one shared derivation, `resolveFlowTriggerKind` (`@objectstack/spec/automation`), the authoring-time mirror of the automation engine's binding chain — same start-node reads, same precedence (a `timeRelative` descriptor outranks its sibling `schedule` cadence). `@objectstack/lint`'s `validate-flow-trigger-readiness` reads it as the auto-triggered predicate behind its draft-status rule, so the two authoring surfaces cannot disagree on which flows auto-launch; its findings are unchanged.
1682
+
1683
+ In-tree corpus: `examples/app-todo` declared two `schedule` flows and a `record_change` flow with no `requires` at all and now declares `requires: ['automation', 'triggers']`.
1684
+
1685
+ <!-- adr-0087: not-required (no-migration-prescription) the refusal message itself names the one-line fix (declare the `triggers` token), and nothing is renamed or removed — no authorable key changes spelling and no export moves, so the ledger has no rewrite to carry. -->
1686
+ - 4301f78: refactor(lint): derive the runtime gate's name-keyed collection set instead of hand-listing it (#13390)
1687
+
1688
+ The set of collections the runtime publish gate carries in a per-write snapshot was
1689
+ written down in five places, and `NAME_KEYED_STACK_KEYS` was the one with no guard of
1690
+ any kind — `CONTEXT_STACK_KEYS` carries a `satisfies` clause, which is validity rather
1691
+ than completeness, and the compiler held nothing else.
1692
+
1693
+ That list carries a real invariant: a collection the CONTEXT fills **and** that some
1694
+ write type maps into must be name-keyed, or a finding's `path` is a positional index
1695
+ into an in-memory snapshot the caller has never seen and cannot enumerate — the defect
1696
+ #10064 fixed for `objects` / `permissions` / `books`. Omitting a member did not fail to
1697
+ build, fail a test, or fail a gate; it produced correct-LOOKING findings with paths the
1698
+ receiver cannot resolve. Adding the `pages` collection had to touch all five spellings
1699
+ and only one of them announced itself.
1700
+
1701
+ `NAME_KEYED_STACK_KEYS` and the `TOP_LEVEL_INDEX` pattern built from it are now derived
1702
+ from the two inputs that already state the answer: `CONTEXT_STACK_KEYS` intersected with
1703
+ the values of `TYPE_TO_STACK_KEY`. The intersection was measured against the list it
1704
+ replaces before anything changed — same four members (`objects`, `permissions`, `books`,
1705
+ `pages`) in the same order, and `datasets` excluded on its own because no write type maps
1706
+ into it, so no member needed a hand-written exception and none is kept.
1707
+
1708
+ Constructive preservation, not a tightening or a loosening: the derived pattern's `source`
1709
+ is byte-identical to the literal it replaces, and the gate returns the same findings for
1710
+ the same inputs. No published entry point changed — `@objectstack/lint` and
1711
+ `@objectstack/lint/runtime` export exactly the names they did before.
1712
+ - e7191ce: fix(build): give each `exports` condition its own `types` target in the 28 dual-build packages (#13112)
1713
+
1714
+ **Published-surface change, zero runtime change.** No emitted byte moves; what
1715
+ moves is which declaration file a resolver READS. Maintainer ruling 2026-08-29
1716
+ (decision batch #3, verbatim 「同意」) chose declaring the files over deleting
1717
+ them.
1718
+
1719
+ ## What was wrong
1720
+
1721
+ These 28 packages are `"type": "module"` and dual-built, and each spelled one
1722
+ `types` condition as a **sibling** of `import`/`require`:
1723
+
1724
+ ```json
1725
+ "exports": { ".": {
1726
+ "types": "./dist/index.d.ts", "import": "./dist/index.js", "require": "./dist/index.cjs"
1727
+ } }
1728
+ ```
1729
+
1730
+ A sibling `types` answers for **both** conditions, so a CommonJS consumer was
1731
+ handed `dist/index.d.ts` — an ES-module declaration, because the package is
1732
+ `"type": "module"` — for an entry point it reaches with `require`. Measured with
1733
+ `tsc --traceResolution` on a `"type": "commonjs"` fixture at `moduleResolution:
1734
+ node16`:
1735
+
1736
+ ```
1737
+ error TS1479: The current file is a CommonJS module whose imports will produce
1738
+ 'require' calls; however, the referenced file is an ECMAScript module and cannot
1739
+ be imported with 'require'.
1740
+ ```
1741
+
1742
+ The JavaScript at `dist/index.cjs` loads perfectly (`check:dual-build-cjs-loads`
1743
+ has asserted that for months). It is the **types** that told the consumer the
1744
+ supported `require` entry point could not be required. The `dist/index.d.cts`
1745
+ twin tsup emits beside it — 36 files, 5,517,701 B on this build — was named by
1746
+ no condition at all and shipped in every tarball unreachable.
1747
+
1748
+ ## What changed
1749
+
1750
+ Each condition now names its own declaration, the shape TypeScript documents:
1751
+
1752
+ ```json
1753
+ "exports": { ".": {
1754
+ "import": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
1755
+ "require": { "types": "./dist/index.d.cts", "default": "./dist/index.cjs" }
1756
+ } }
1757
+ ```
1758
+
1759
+ 33 entry points across 27 packages, subpaths included. The root `types` field is
1760
+ untouched, so `node10` resolvers are unaffected; the `import` condition resolves
1761
+ exactly what it resolved before, measured as an unchanged control in the same
1762
+ run.
1763
+
1764
+ ## `@objectstack/core` is deliberately NOT changed
1765
+
1766
+ Splitting a declaration in two makes TypeScript compare it nominally, and
1767
+ `ObjectKernel` carries a `private plugins` member that reaches every plugin
1768
+ through `PluginContext.getKernel()`. With core split, whole-repo `pnpm build`
1769
+ fails in `@objectstack/verify` with 5 × TS2345 ("Types have separate
1770
+ declarations of a private property 'plugins'"); with core held back and the
1771
+ other 27 split, 71/71 tasks pass. So core keeps the sibling-`types` shape and
1772
+ its two `.d.cts` files (220,854 B) stay unreachable, declared as such in
1773
+ `check:dual-build-cjs-loads`. Splitting it needs a decision about core's public
1774
+ types, not about an exports map.
1775
+
1776
+ ## For consumers
1777
+
1778
+ - **ESM consumers: nothing changes.** Same declaration file, byte for byte.
1779
+ - **CJS consumers under `node16`/`nodenext`: TS1479 goes away** and the
1780
+ declarations they get are the ones built for CommonJS.
1781
+ - **`node10` / `moduleResolution: node` consumers: nothing changes** — they never
1782
+ read `exports`.
1783
+ - Nothing is removed: every path that resolved before still resolves.
1784
+
1785
+ Packages that are CJS-first (`require` → `./dist/index.js`, no `"type": "module"`)
1786
+ were already correct and are untouched — their `dist/index.d.ts` really is the
1787
+ CommonJS declaration. Their ESM mirror (an unreachable `.d.mts` under the
1788
+ `import` condition) is a separate, larger population and is filed separately per
1789
+ the ruling, not fixed here.
1790
+
1791
+ `check:dual-build-cjs-loads` grew a fourth invariant (TYPED) that reds on the old
1792
+ shape, so the drift cannot return silently.
1793
+ - 1fb6281: Walk object-nested `list` / `listViews.*` through the view completeness rules.
1794
+
1795
+ `validateFunctionalCompleteness` walked only the top-level `views[]` containers, so
1796
+ a `timeline` / `gantt` / `map` / `tree` view authored on the object itself — the
1797
+ ADR-0017 "Object has-many View" spelling that `objects[].list` and
1798
+ `objects[].listViews.*` carry — never reached `checkViewCompleteness`. Both doors
1799
+ register the same expanded view items and reach the same renderer, so `os validate`
1800
+ and `os build` were silent on exactly the half of the stack the sibling rules
1801
+ (`lint-view-refs`, `validate-list-view-field-refs`) already walk.
1802
+
1803
+ Both authorable object spellings (array-form and name-keyed map) are covered, and a
1804
+ list view's own `data.object` retarget (ADR-0047) resolves the bound object the same
1805
+ way it does on the top-level door — so `view/layout-without-binding` and
1806
+ `view/tree-without-parent-field` now reach the nested door by construction rather
1807
+ than by a second wiring step. Findings report as
1808
+ `object "<name>" › listViews.<key>` / `objects[<i>].listViews.<key>.<block>`.
1809
+ - 7345308: fix(lint): drop the `element:form` entry from `COMPONENT_FIELD_SPECS` (#9249)
1810
+
1811
+ The whole `element:form` element retired at element grain (ADR-0049 — no
1812
+ renderer ever shipped for it; the #9220 shape one element over), so every
1813
+ `ElementFormProps` key is a `retiredKey()` tombstone and no spec-conformant
1814
+ page carries `fields` on it. The field-binding rule's job (resolve a field
1815
+ NAME against the object) is not the question a retired key raises: an authored
1816
+ key is already reported by name with the element-retirement prescription —
1817
+ which names the live replacement, the object-bound `object-form` block —
1818
+ through the #5068 props gate, and the binding entry would only add a second
1819
+ finding about a key that no longer exists — the #5775/#6629 residue class the
1820
+ package's own `component-field-specs-liveness` gate refuses.
1821
+ - 8ed9c54: flows: warn on a `loop` body with a fallible node and no containment, and on a `try_catch` with no `catch` (#14394)
1822
+
1823
+ Two authoring-time rules in the flow anti-pattern family, both `warning`:
1824
+
1825
+ - **`flow-loop-body-uncontained`** — a `loop` whose `body` region runs a node
1826
+ that can end the run (a record read/write, `http`, `notify`,
1827
+ `connector_action`, `script`, `subflow`, `map`, `approval`) with no
1828
+ `try_catch` between the loop and that node. The `loop` executor iterates with
1829
+ a bare `await` and has no `try`/`catch` at all, so the first failing item ends
1830
+ the whole run: later items are never processed, and the work already done is
1831
+ not even reported. The finding names the loop, the node, and the prescribed
1832
+ spelling.
1833
+ - **`flow-try-catch-without-catch`** — the near-miss, and the first target
1834
+ rather than an extra: `catch` is optional in the schema, and omitting it makes
1835
+ the container fail through, so an author who wrapped the node and stopped
1836
+ there gets **zero** containment and previously got no diagnostic either.
1837
+ Measured, the no-`catch` run and the unwrapped control produce identical
1838
+ output; a `retry` policy only delays that.
1839
+
1840
+ Both stay warnings under the family's severity bar: a loop deliberately allowed
1841
+ to stop at the first failure, and a retry-then-fail `try_catch`, are legitimate
1842
+ readings the rule cannot disprove.
1843
+
1844
+ `content/docs/automation/flows.mdx` documents `loop { try_catch { … } }` as the
1845
+ per-iteration containment spelling, with the measured minimal handler — one bare
1846
+ `assignment` node, `edges` and `errorVariable` omitted — and the three `catch`
1847
+ spellings the schema refuses (`catch` omitted gives no containment; `catch: {}`
1848
+ and `catch: { nodes: [] }` are rejected, the region's `nodes` being `.min(1)`).
1849
+
1850
+ No spec, engine or runtime change: the containment capability already exists and
1851
+ was measured working (5 of 5 iterations, items 4-5 processed, run completes).
1852
+ - f887e52: Re-measure four stale `current_user` binding-text sites, including the form SECTION slot
1853
+
1854
+ The claim that `current_user` is unbound on a form-view **section** predicate was true when
1855
+ it was written and is not any more: the console form renderer threads the host shell's
1856
+ predicate scope into `isSectionVisible` (objectui#6110), and the object-view chain now
1857
+ carries an authored `section.visibleWhen` through to an evaluator via the `section-divider`
1858
+ pseudo-field (objectui#6111). Text only — no schema, no verdict and no runtime behaviour
1859
+ moves.
1860
+
1861
+ - `FormSectionSchema.visibleWhen` (`ui/view.zod.ts`) — the JSDoc and `describe()` now say the
1862
+ root resolves, carrying the two qualifications the field-slot text already carried: the
1863
+ binding is **client-side only** (no write-path evaluator reads a form-view section or field
1864
+ `visibleWhen` — the rule validator's list is field `readonlyWhen` / `requiredWhen` and
1865
+ per-option `visibleWhen`), and the scope is **empty on the public `/f/:slug` route**, which
1866
+ is mounted outside any provider on purpose. The `features.*` refusal sentence is unchanged:
1867
+ that root is unbound on both standalone form routes.
1868
+ - `SelectOptionSchema.visibleWhen` (`data/field.zod.ts`) and
1869
+ `SELECT_OPTION_EDITABILITY_GUIDANCE` (`shared/editability-boundary.ts`) — the retired
1870
+ exclusivity claim ("the one `*When` surface where `current_user` resolves") is trimmed. The
1871
+ durable grounding stays and is now what the prescription rests on: per-option is the one
1872
+ visibility predicate the **server** enforces, so the rule validator refuses a write of a
1873
+ value whose predicate is false.
1874
+ - `@objectstack/lint`'s field-rule message — the `visibleWhen` consequence clause is
1875
+ re-measured. Under a scope-publishing host the predicate no longer faults: it resolves, the
1876
+ control is hidden client-side, and the server still returns the value to every other reader
1877
+ — a silent enforcement gap. The fault-open leg survives wherever no host publishes a scope.
1878
+ The verdict is unchanged and the message says why it is now *more* justified: trading a loud
1879
+ lint error for a gap nobody can see is worse than the error.
1880
+ - 8b04c75: fix(cli,lint): stop lowering hook handlers that call `ctx.api.sudo()` into bodies that cannot run it (#14010)
1881
+
1882
+ `ScopedContext.sudo()` is real in-process and is **not** marshalled into the
1883
+ QuickJS sandbox: the VM's `ctx.api` carries `object()` and the transaction
1884
+ surface, and nothing else. Every consumer of that fact had it backwards.
1885
+
1886
+ The failure this closes is the expensive shape, not a cosmetic one. An author
1887
+ writes an inline `handler`, tests it the way the docs teach — calling
1888
+ `hook.handler(ctx)` natively, against the in-process `ScopedContext`, where
1889
+ `sudo()` exists — and the suite is green. `objectstack build` then lowers that
1890
+ same source into an L2 `body`, and in production the call is
1891
+ `TypeError: ctx.api.sudo is not a function`. Under a hook's default
1892
+ `onError: 'abort'` the TypeError aborts the **triggering write**, so the
1893
+ symptom surfaces as an unrelated save being refused. Green tests, dead feature.
1894
+
1895
+ - **`@objectstack/cli`** — `.sudo(` joins `FORBIDDEN_PATTERNS` in
1896
+ `extractHookBody`, so the build declines to emit such a handler as
1897
+ `body.source`. This is a repair, not just a refusal: `lowerCallables` already
1898
+ registers the callable and ships it through the `.mjs` bundle when extraction
1899
+ throws, so the handler keeps running **in-process, where `sudo()` is real**.
1900
+ The build prints the reason; `--strict-body`, which demands a body for every
1901
+ callable, turns it into a hard failure — correctly, since a body needing
1902
+ elevation genuinely cannot be one. Same family as the `crypto.hash`
1903
+ retirement (#4391): a member advertised ahead of its implementation, where
1904
+ build-time inference was the amplifier rather than the safety net.
1905
+ - **`@objectstack/lint`** — `hook-api-update-readonly-field` (severity
1906
+ `error`, gating) and its `readonlyWhen` sibling both *prescribed*
1907
+ `ctx.api.sudo()` as the remedy. That rule reads L2 body sources and nothing
1908
+ else, so the prescribed shape was a TypeError for **100%** of its population:
1909
+ a gating rule pointing at a dead feature. Both hints now name the own-hook
1910
+ stamp and say plainly that `sudo()` is not reachable from a body. The rule's
1911
+ findings, severities and exclusions are unchanged — only the advice.
1912
+
1913
+ Docs: the `readonly` table in `automation/hook-bodies.mdx` claimed the
1914
+ `sudo()` row **Lands**; it now records what actually happens.
1915
+
1916
+ Not addressed here, and the reason this is only half the card: a hook still has
1917
+ **no declared elevation knob** — there is no hook-side `runAs` the way
1918
+ `FlowSchema` has one — so "this column is computed by automation and never
1919
+ hand-written" remains inexpressible whenever the maintaining write is
1920
+ cross-object. That is a contract-surface decision (see #14010), left to the
1921
+ review chain rather than guessed at here.
1922
+ - 33681ea: feat(hooks): `runAs` on a hook — `'system' | 'user' | 'inherit'`, default `'inherit'`
1923
+
1924
+ A hook's `ctx.api` runs with the context of the write that fired it, so a column
1925
+ an app wants **computed and never hand-written** could not be expressed: author
1926
+ `editable: false` for the persona and the direct `PATCH` is refused — and so is
1927
+ the hook that maintains the column, by the same field-level check. The guard and
1928
+ the legitimate writer were the same door. The only elevation a hook had was the
1929
+ in-process `ctx.api.sudo()`, which is not marshalled into the sandbox (a
1930
+ `TypeError` once a build lowers the handler into a body) and which rides the L3
1931
+ bundle path that is being retired.
1932
+
1933
+ `HookSchema` now accepts `runAs`:
1934
+
1935
+ | value | the hook's `ctx.api` data operations run as |
1936
+ | --- | --- |
1937
+ | `'inherit'` (default) | the context of the triggering write — exactly the behaviour every hook has today |
1938
+ | `'system'` | elevated: a full-access, RLS-bypassing system principal |
1939
+ | `'user'` | the triggering user; a hook whose trigger resolved no user has its data operations **refused** (`HOOK_UNSCOPED_DATA_ACCESS`) rather than run unscoped |
1940
+
1941
+ `'system'` and `'user'` mean here exactly what they mean on `flow.runAs` — same
1942
+ word, same semantics. `'inherit'` is the hook-only third value, because only a
1943
+ hook has a context to inherit; a flow establishes its identity from nothing,
1944
+ which is why its default is `'user'` and this one's is `'inherit'`. Nothing on
1945
+ `FlowSchema` changes.
1946
+
1947
+ **Purely additive: no migration, no behaviour change for any existing hook.**
1948
+ The default reproduces today's behaviour by handing the engine-built `ctx.api`
1949
+ through unchanged, and an absent key parses to it.
1950
+
1951
+ Scope, deliberately narrow: `ctx.api` data operations only. `condition`
1952
+ evaluation, the `readonly` strip applied to the hook's own `ctx.input` payload,
1953
+ `ctx.session` and `async` semantics all keep reading the triggering operation's
1954
+ context, and declaring `runAs: 'system'` does not elevate the write that fired
1955
+ the hook.
1956
+
1957
+ Elevation is authorization, not anonymity: a `runAs: 'system'` write still
1958
+ carries the triggering user, so `created_by` / `updated_by` and the audit row
1959
+ still name the operator.
1960
+
1961
+ Honoured on both execution surfaces — the in-process `handler` and the
1962
+ sandboxed `body`.
1963
+
1964
+ Authoring notes:
1965
+
1966
+ - `sudo`, `elevate`, `elevated` and `isSystem` are refused with a prescription
1967
+ naming `runAs`, and `run_as` is answered as a rename.
1968
+ - `@objectstack/lint`'s gating `hook-api-update-readonly-field` rule now skips a
1969
+ hook that declares `runAs: 'system'` — the static `readonly` strip skips a
1970
+ system context, so the write it exists to catch does not happen — and its
1971
+ hints name the knob. The `readonlyWhen` warning is unchanged: a system context
1972
+ does not waive a conditional lock.
1973
+ - 68c5dba: fix(cli,lint): stop `os lint` demanding translation keys the liveness ledger warns authors for writing (#11624)
1974
+
1975
+ `os lint` computes i18n coverage and runs the authoring-rule registry in a
1976
+ single pass over the same stack, and for the `flows` translation group the two
1977
+ halves pointed opposite ways:
1978
+
1979
+ | the author does | which rule fires | what it says |
1980
+ |---|---|---|
1981
+ | omits `flows.*` from the bundle | `i18n/missing-flow` | the key is missing a translation for locale X |
1982
+ | adds it (`os i18n extract` scaffolds it) | `liveness-planned-property` | the `flows` group is `planned` — nothing reads it |
1983
+
1984
+ Measured on one stack, one run: omitting produced **4** `i18n/missing-flow`
1985
+ findings and 0 liveness findings; authoring produced 0 demands and **2**
1986
+ `liveness-planned-property` findings ("sets `flows` but this translation
1987
+ property is planned"). There is no per-rule suppression in `os lint`, only
1988
+ `--skip-i18n`, which silences the entire `i18n/missing-*` family — so the
1989
+ author's only escape cost them every other coverage signal. Under
1990
+ `--i18n-strict` the demand side is an **error**, so a project could be forced
1991
+ to author keys it is then warned for.
1992
+
1993
+ ⛔ The warning is not the bug and is unchanged: no shipped screen-flow runner
1994
+ reads the group, so a translated wizard string is stored and never shown — the
1995
+ failure mode `validationMessages` was removed in 17.0.0 for. The premature half
1996
+ is the demand.
1997
+
1998
+ **The fix.** `collectExpectedEntries` — the single definition of what is
1999
+ translatable at all, shared by the coverage gate and the `os i18n extract`
2000
+ skeleton — now leaves out any translation group the liveness ledger warns
2001
+ authors for authoring. It reads that set from `@objectstack/lint`'s new
2002
+ `authorWarnedProperties(type)`, which returns the very warn-map
2003
+ `lintLivenessProperties` iterates, so the demand side and the warn side cannot
2004
+ drift into disagreeing about the same keys again.
2005
+
2006
+ Two properties fall out of reading the ledger rather than switching on `flows`
2007
+ by name: the bucket **turns itself back on** the day an objectui screen-flow
2008
+ runner lands and the row flips to `live` (no flag, no follow-up edit), and any
2009
+ future group that acquires an `authorWarn` is covered on the day it is marked
2010
+ rather than re-opening this collision one group at a time. Today `flows` is the
2011
+ only such group — pinned as an equality so a second one goes red instead of
2012
+ shipping.
2013
+
2014
+ No other bucket changes: `objects`, `apps`, `pages`, `dashboards`,
2015
+ `globalActions` and `metadataForms` are all `live` and are reported exactly as
2016
+ before. `@objectstack/spec` is untouched — the `flows` row keeps `planned` +
2017
+ `authorWarn: true`.
2018
+ - b2eab95: feat(spec,objectql): give three authored display surfaces a bundle key — bulk-action defs, custom validation messages, dataset labels (#14253)
2019
+
2020
+ Purely additive: three new translation groups, one new dispatch-table entry, one
2021
+ new resolution step on the write path. No existing key changes shape, no
2022
+ resolution order changes, and every surface still falls back to the authored
2023
+ literal when the bundle carries nothing.
2024
+
2025
+ Each of the three carried **authored, user-facing display text that no key in
2026
+ `TranslationDataSchema` could reach** — not a drifted key, no key. Each rendered
2027
+ in the source locale inside an otherwise fully translated screen, which is the
2028
+ bad failure mode: it reads as a styling quirk rather than as a missing
2029
+ translation. Measured on a real `zh-CN` deployment.
2030
+
2031
+ **1. A list view's `bulkActionDefs[]`** —
2032
+ `objects.<object>._views.<view>.bulkActions.<def_name>.{label,confirmText,confirmLabel,params.<p>.{label,help,placeholder}}`,
2033
+ resolved in `translateView` against `config.bulkActionDefs` (the one address a
2034
+ served def has: both `ViewItemSchema` and `expandViewContainer` nest the whole
2035
+ ListView under `config`). A def is part of the *view* document, not an action
2036
+ document, so it never reached `translateAction`; the selection bar read
2037
+ `已选择 1 项 · Complete · Skip · 清除`. The def's `label` deliberately stays a
2038
+ plain `z.string()` on the authoring side — the bar renders it as a React child,
2039
+ so an inline locale map would be a blank cell rather than a parse error — and
2040
+ overlaying at the metadata boundary keeps the wire value a plain string. The
2041
+ documented workaround (`bulkActions: ['<name>']`, promoting a declared action)
2042
+ is not equivalent: it is N elevated per-record dispatches instead of one
2043
+ data-plane `updateMany`.
2044
+
2045
+ **2. A custom validation rule's `message`** —
2046
+ `objects.<object>._validations.<rule_name>.message`, spelled by the new
2047
+ `objectValidationMessageKey` and read on the write path by the rule evaluator.
2048
+ ⚠️ **This adds a key shape, not a channel**: the lookup runs on the *existing*
2049
+ `i18nService` hook that has localized built-in field-catalog messages and field
2050
+ labels since #3957. Before it, a deployment got platform-generated refusals in
2051
+ the caller's language and author-written refusals in the source language inside
2052
+ one `400 VALIDATION_FAILED` envelope. All five authored-message emitters route
2053
+ through one seat; a nested `conditional` branch is addressed by the branch's own
2054
+ name; a platform-generated rejection (an unevaluable predicate) is deliberately
2055
+ left alone. `messages['validation.field.*']` is unchanged and still overrides the
2056
+ built-in catalog only.
2057
+
2058
+ **3. Dataset labels** — `datasets.<name>.{label,description,dimensions.<d>.label,measures.<m>.label}`
2059
+ plus `translateDataset` in `METADATA_DOCUMENT_TRANSLATORS`. A dataset reads like
2060
+ a back-office definition, but a measure label is drawn on the dashboard, under
2061
+ every metric tile and on every chart axis. Registering the translator is the
2062
+ whole wiring — `TRANSLATABLE_METADATA_TYPES` is derived from that table and
2063
+ `@objectstack/rest` reads the derived set (#3786) — so `GET /api/v1/meta/datasets?locale=…`
2064
+ localizes with nothing else to remember.
2065
+
2066
+ Key faces are measured against the authoring schemas rather than mirrored from
2067
+ the report, so nothing here parses clean and translates nothing: a bulk param's
2068
+ hint is `help` (not the action-param `helpText`), per-param `options` are refused
2069
+ because `options[].value` is unconstrained and a value-keyed map cannot address
2070
+ `true` and `"true"` apart, a def has no `successMessage`, and a dataset dimension
2071
+ or measure has no `description` — the authoring schema says so itself. Every
2072
+ exclusion carries `guidance` naming the right home.
2073
+
2074
+ Two tombstones stop asserting that no route exists: the retired
2075
+ `validationMessages` and `errors` guidance now point at
2076
+ `objects.<object>._validations.<rule>.message`. Retiring `validationMessages`
2077
+ (17.0.0, #4667, ADR-0049) is **not** reversed — that group was keyed by rule name
2078
+ at the bundle's top level, so it could not tell two objects' rules apart, and,
2079
+ the reason it was retired, nothing read it. Its ADR-0087 conversion still strips
2080
+ it from stored bundles. The replacement is object-scoped and ships its reader in
2081
+ the same change.
2082
+
2083
+ Authors upgrading need do nothing; a bundle that writes none of the three new
2084
+ groups behaves exactly as before.
2085
+
2086
+ <!-- adr-0087: not-required (unpublished) Purely additive: three new optional groups on `TranslationData` / `TranslationItem`, one new dispatch-table entry, and one new lookup on the write path. No authorable key is removed, renamed or re-shaped, so there is no tombstone, no stored shape to rewrite, and nothing mechanical for `objectstack migrate meta` to prescribe. The retired `validationMessages` conversion entry is untouched and still strips the key it always stripped — its guidance text now names a live replacement instead of asserting none exists, which changes what an author is told, not what a stored bundle becomes. -->
2087
+ - 5228e52: fix(lint): `lintDataModel` reads only the canonical `reference` target (#13250)
2088
+
2089
+ `refOf` in `packages/lint/src/data-model-rules.ts` resolved
2090
+ `def?.reference || def?.reference_to`, so a relationship field spelled with the
2091
+ rejected alias resolved a target. #11567 settled that `reference` is the only
2092
+ relationship spelling `@objectstack/spec` declares — `FieldSchema` answers
2093
+ `reference_to` with `unrecognized_keys` and *"Did you mean `reference_to` →
2094
+ `reference`?"* — and put it as "one key, one answer, on both doors".
2095
+
2096
+ `@objectstack/lint` runs over an in-memory, schema-parsed stack, so the alias
2097
+ cannot legitimately appear here at all: the tolerance was inert. Where it did
2098
+ fire, it made the rule whose entire job is to catch a relationship with no
2099
+ target — `relationship/missing-reference` — report a valid target for a field
2100
+ that has none, i.e. the one component that exists to tell an author their
2101
+ metadata is wrong was the component accepting the wrong spelling.
2102
+
2103
+ This mirrors the deliberate canonical-only narrowing already recorded in-file
2104
+ for `refOf` in `packages/lint/src/validate-security-posture.ts`, including its
2105
+ `typeof r === 'string'` guard — which also makes the declared
2106
+ `string | undefined` return type true, where the old `||` chain returned
2107
+ whatever truthy value it found (a non-string `reference` was reported as a
2108
+ resolved target).
2109
+
2110
+ What changes for a consumer, only for metadata the spec already refuses:
2111
+ `relationship/missing-reference` (error) now fires on a relationship field
2112
+ whose only target spelling is `reference_to`, and the rules that need a
2113
+ resolved target (`relationship/master-detail-required`, `rollup/missing-summary`
2114
+ and the rest of the relationship family) no longer treat such a field as
2115
+ pointing anywhere. Canonical `reference` is untouched.
2116
+
2117
+ Scope note: the two remaining tolerant readers named in #13250 —
2118
+ `packages/verify/src/derive.ts` and
2119
+ `packages/plugins/plugin-security/src/security-plugin.ts` — are deliberately
2120
+ NOT narrowed here. Both were measured to sit on populations the alias can
2121
+ actually reach (raw `registerObject`, which skips Zod by design, and an app
2122
+ config that never passes through a `define*` parse), so narrowing them is a
2123
+ triage call rather than a defect fix.
2124
+ - 9057811: fix(lint): `relationship/delete-behavior` suggestion no longer names `set_null` as declarable on a `master_detail`
2125
+
2126
+ `lintDataModel`'s `relationship/delete-behavior` suggestion told an author an
2127
+ undeclared `master_detail.deleteBehavior` could be `cascade`, `restrict`, or
2128
+ `set_null`. Since #9689 (PR #11406, maintainer ruling 2026-08-19), an authored
2129
+ `deleteBehavior: 'set_null'` on a `master_detail` field is a named parse-time
2130
+ rejection — a detail row cannot outlive its master, so the engine resolves
2131
+ every value except `restrict` to `cascade` on this type. Following the
2132
+ suggestion's own `set_null` mention literally walked an author into that
2133
+ rejection at publish time.
2134
+
2135
+ The message now enumerates only the two values `FieldSchema` actually accepts
2136
+ on a `master_detail` (`cascade`/`restrict` — matching the vocabulary already
2137
+ offered by the metadata-admin field form, `object.form.ts`'s `master_detail`
2138
+ `deleteBehavior` options), and keeps the same outcome-naming courtesy as the
2139
+ parse-time rejection message: it still names `set_null` to say plainly that it
2140
+ is not honored on this type, and points to `lookup` for the case where
2141
+ children must survive the parent. The `fix` payload (`deleteBehavior:
2142
+ 'cascade'`) was already correct and is unchanged.
2143
+ - 1af8286: fix(lint): a field-level `*When` reading `app` gets the scope diagnostic, not the false `record.app` prescription (#13935)
2144
+
2145
+ `fieldRuleRootIssue` judged field-rule roots against `@objectstack/formula`'s
2146
+ `SCOPE_ROOTS`, which answers "is this root declared **platform-wide**". The
2147
+ question this rule needs answered is "is this root bound at **some** evaluation
2148
+ site". The two agreed for all 27 baseline roots and disagreed for exactly one:
2149
+ `app`, which objectui's `ExpressionProvider` binds on the form-view surface an
2150
+ author migrates a field rule *down* from.
2151
+
2152
+ Falling outside the membership test sent `app` to the generic bare-reference
2153
+ check, whose prescription is ``Write `record.app` `` — and following that
2154
+ advice earns ``unknown field `app` on `invoice` `` from the field-existence
2155
+ pass. A first diagnostic that asserts something false about where the root
2156
+ binds, plus a wasted correction cycle. `current_user`, `user`, `ctx`, `os`,
2157
+ `features` and `data` all got the correct message; `app` alone did not.
2158
+
2159
+ Authoring a field-level `visibleWhen` / `readonlyWhen` / `requiredWhen` on
2160
+ `app` now earns the same scope diagnostic every other unbound root gets —
2161
+ "a field-level conditional rule binds only `record` (plus `previous`, and
2162
+ `parent` on a master-detail line item)" — with a prescription tier of its own
2163
+ that says what is actually true of an ambient root: it is *not* declared
2164
+ platform-wide, it is mounted only by the renderer, and `record.app` is
2165
+ explicitly refused rather than merely omitted, because that is the advice the
2166
+ author just followed out of the old diagnostic.
2167
+
2168
+ **No accept set moves.** `SCOPE_ROOTS` is `@objectstack/formula`'s published
2169
+ strict-lint baseline — adding `app` there would stop *every* surface that
2170
+ judges bare identifiers from faulting it, to fix one surface's wording. The
2171
+ widened vocabulary is assembled in `@objectstack/lint` instead, where the
2172
+ per-surface question is asked, and both diagnostics involved were already
2173
+ `severity: 'error'`, so this changes which message an author reads and nothing
2174
+ about what lints clean.
2175
+
2176
+ `FIELD_RULE_AMBIENT_ROOTS` and `FIELD_RULE_JUDGED_ROOTS` are exported beside
2177
+ the existing `FIELD_RULE_BOUND_ROOTS`.
2178
+ - 365e334: **Fix:** `lintLivenessProperties` walks `stack.translations` as the locale-keyed bundle it is, so the `translation` liveness ledger finally reaches the author (#11288).
2179
+
2180
+ `stack.translations` is `z.array(TranslationBundleSchema)` — each item is a `TranslationBundle`, i.e. `z.record(LocaleSchema, TranslationDataSchema)`, whose top-level keys are locale codes. The lint registered `{ type: 'translation', key: 'translations' }` in `TYPE_COLLECTIONS` and then walked those items flat, the way every other collection there is walked: `checkItem` read `bundle['flows']` for the ledger's one `authorWarn` row. A bundle has no `flows` key at any depth reachable that way — the groups live one level down, under each locale — so every warned lookup missed and the whole `translation` ledger was silent for file-authored bundles, the only way apps author translations today.
2181
+
2182
+ That is the failure mode the comment above `TYPE_COLLECTIONS` names ("a newly governed type needs its collection registered or its ledger warns nobody"), reached from the other side: the collection *was* registered, and the shape underneath it was the mismatch. Registering a collection is only half the contract — the walk has to match the collection's shape — so the row is now a tombstone comment saying exactly that, and `translation` joins `object`/`field` as a bespoke walk: for each bundle, each locale entry's `TranslationData` is checked, with the finding subject naming the bundle index and the locale (`translation bundle #0 · locale 'zh-CN'`).
2183
+
2184
+ Measured on a real app before the fix, as a guarded ablation: injecting a `flows:` section into a locale bundle and re-running `objectstack lint --json` produced **zero** delta — 91 issues before and after, 0 liveness findings naming `flows`. The author who reached for a `planned` translation group got silence, which is strictly worse than the ledger being absent, because the ledger's stated contract is that `authorWarn` is what tells them.
2185
+
2186
+ Advisory-only as before: the finding is a warning, and `os lint` exits on errors, never on warnings.
2187
+
2188
+ The regression test is pinned on the **bundle** shape, and a `TranslationItem`-shaped anti-fixture is pinned alongside it. That shape — `locale` plus the groups at the top level — is the runtime metadata door, and it *warned on the broken walk*, so a fixture written that way would have been green from the day the bug shipped and pinned nothing. Runtime-authored `translation` items are reached by this lint through no door at all: no stack collection carries them, and the rule is `surfaces: CLI_ONLY`, so it does not run at the runtime publish gate either. The two doors share the group vocabulary, not the container; only the file-authored one is lintable, and now it is linted.
2189
+ - 5c28b88: lint: `flow-update-readonly-when-field` now inspects `runAs:'system'` flows
2190
+
2191
+ The `runAs:'system'` exemption in `validate-readonly-flow-writes` was a single
2192
+ flow-level early return, so it removed an elevated flow from **both** branches of
2193
+ the rule. Only the static branch warrants it: the engine skips
2194
+ `stripReadonlyFields` under `if (!opCtx.context?.isSystem)`, but
2195
+ `stripReadonlyWhenFields` runs on the update path with no `isSystem` guard at all
2196
+ (`packages/objectql/src/engine.ts`, the #9107 note: "`isSystem` is still NOT an
2197
+ exemption here, unlike the static strip below"), pinned as "LOCK 2 — isSystem does
2198
+ NOT exempt a caller-supplied value".
2199
+
2200
+ The exemption now gates the static branch only. A `runAs:'system'` flow whose
2201
+ `update_record` node writes a `readonlyWhen` field reports the branch's existing
2202
+ `warning` — the same silent-no-op the rule exists to surface, on the flow class the
2203
+ rule's own hint tells the author elevation cannot save. A system flow writing a
2204
+ static `readonly:true` field stays silent, as before; rule ids and severities are
2205
+ unchanged, and the new finding is advisory and never blocks a build.
2206
+ - 996cb1d: fix(lint): run the flattened-scope shadowing warning on the descriptor-declared predicate slots too (#14288)
2207
+
2208
+ The #14089 shadowing warning — a bare name that is BOTH a declared flow
2209
+ variable AND a field on the bound object, where the variable silently wins at
2210
+ runtime — reached exactly two expression positions: the node `condition` and
2211
+ the edge `condition`. The `#4027` descriptor-declared predicate slots were
2212
+ validated for dialect by the same traversal but were never passed through the
2213
+ shadowing pass, so the identical mistake stayed silent on them.
2214
+
2215
+ The warning is about the **scope** an expression is evaluated in, not the key
2216
+ it was authored under, and the engine measurement says both `predicate` slots
2217
+ on the ledger share the run's one flattened variable map:
2218
+
2219
+ - `decision.conditions[].expression` — the decision executor evaluates against
2220
+ the very `variables` parameter the engine hands every node executor, which is
2221
+ the same `Map` object `seedRunVariables` built and a node `condition` is
2222
+ judged against. Nothing on the path clones or narrows it.
2223
+ - `screen.fields[].visibleWhen` — `refuseInvalidScreenInput` evaluates against
2224
+ `run.variables` (the persisted snapshot of that same seeded map) with the
2225
+ submitted bag overlaid. A superset, so the shadow still reaches it: the
2226
+ overlay carries the screen's own collected values, never the bound record's
2227
+ field, so it can never hand back a field the variable displaced.
2228
+
2229
+ `loop.collection` and `map.collection` are `flow-template`, not `predicate`,
2230
+ and the slot loop already skips them.
2231
+
2232
+ Warning-only and within the 2026-09-01 option-C ruling's letter: one more call
2233
+ site reusing the `declaredVariables` set already collected once per flow, no
2234
+ new rule id, no severity above `warning`, no accept set moved, and no bare
2235
+ identifier judged for being bare. Nothing that linted clean before can newly
2236
+ fail a build.
2237
+ - 6eb8e3c: fix(lint): `sharing-rule-runtime-variable-condition`'s fix-hint no longer sends authors to RLS to widen a `private` object (#14234)
2238
+
2239
+ The hint printed for a sharing-rule `condition` that reads `current_user.*`
2240
+ ended in an **unqualified** remedy: "express per-user access with the mechanism
2241
+ that runs per request instead — an RLS policy on a permission set
2242
+ (`rowLevelSecurity[].using`, where `current_user.*` IS resolved)". That advice
2243
+ is sound on an open OWD and **structurally impossible on `private`**, which is
2244
+ the sharing model an author hitting this rule is most likely to be on.
2245
+
2246
+ Measured, not argued. The security layers are AND-composed —
2247
+ `plugin-security`'s `getReadFilter` returns
2248
+ `andComposeLayers(andComposeLayers(filter, cbpFilter), sharingFilter)`, and its
2249
+ own prose promises "the same filter the engine middleware AND-s into" every
2250
+ find — while `plugin-sharing`'s `buildReadFilter` constrains `private` only
2251
+ (`effectiveSharingModel(schema) !== 'private'` returns `null`). So:
2252
+
2253
+ - `public_read` / `public_read_write` → the sharing layer imposes nothing on
2254
+ reads, and an RLS policy **narrows** an open baseline. The original advice is
2255
+ correct here and is kept, now with the models it holds for attached.
2256
+ - `private` → the sharing layer has already withheld the row, and **an AND term
2257
+ can only remove rows, never add one back**. An RLS "widener" there lints
2258
+ clean, passes every gate, and grants nothing — a silent failure at the
2259
+ security boundary, which is the worst possible feedback shape. The card
2260
+ records an application author who followed this path and shipped the grant
2261
+ unauthored/fail-closed instead.
2262
+
2263
+ The hint now splits on `sharingModel` and, for the `private` case, states what
2264
+ is true: the two doors that widen a private object for a non-admin principal
2265
+ both key on **who owns the row** or on an **explicit share row**, never on a
2266
+ property of the record — the ADR-0057 D1 depth scopes (`readScope`/`writeScope`,
2267
+ which widen the owner-match to `owner_id IN (…)`) and a `sys_record_share` row,
2268
+ which on this path only a criteria sharing rule writes. Where neither fits, it
2269
+ names the gap **in words** — a known platform limitation, not something a
2270
+ different spelling of this rule can close — rather than inventing a mechanism,
2271
+ and it warns that `position` recipients resolve **tenant-wide**: the
2272
+ neighbouring temptation is an over-broad grant that also lints clean, not the
2273
+ narrower one the author meant. The tracker anchor for that gap sits in an
2274
+ adjacent source comment, not in the message, because a runtime string reaches
2275
+ authors and generated surfaces that cannot resolve `#NNNN`
2276
+ (`check:doc-authoring`, maintainer ruling 2026-08-12) — a pin asserts the
2277
+ message carries no tracker id.
2278
+
2279
+ **The explanation is preserved byte-for-byte.** The opening sentence — the
2280
+ MATERIALISED/`criteria_json` explanation of *why* the condition is refused — is
2281
+ the most useful part of the diagnostic and is unchanged; a pin asserts it
2282
+ verbatim. The file's own docblock carried the same unqualified "the fix is RLS"
2283
+ claim one layer up and is corrected in the same edit.
2284
+
2285
+ **No behaviour change**: the rule's accept/reject verdict, its ids, severities,
2286
+ paths and `message` text are untouched — this is `hint` prose only. AND
2287
+ composition is correct by design and is not touched either.
2288
+
2289
+ A new pin holds the split, because prose is invisible to every other gate —
2290
+ nothing else in CI reads a word of this hint. Reverse-verified by restoring the
2291
+ shipped wording: **7 legs go red** (the unqualified-RLS leg, the AND-composition
2292
+ leg, the widening-doors leg, the `position` warning, the #14103 gap, and both
2293
+ vocabulary legs), while the verbatim-explanation leg correctly stays green. The
2294
+ vocabulary legs check every sharing model and depth scope the hint names against
2295
+ the **spec-owned** `OWDModel` / `ObjectAccessScopeSchema` enums, so a rename in
2296
+ the spec reds this file instead of leaving the hint quoting values the platform
2297
+ no longer has — without it the hint and its expectations would move together and
2298
+ nothing would go red.
2299
+ - 3a5b8c9: fix(lint): consolidate five more private "Did you mean?" copies onto the shared `suggestName` (#14577, follow-up to #14268/#14575)
2300
+
2301
+ `validate-action-name-refs.ts`, `validate-chart-bindings.ts` and
2302
+ `validate-searchable-fields.ts` each carried a private `suggest`/`distance`
2303
+ pair, byte-for-byte re-deriving the edit-distance-only budget that
2304
+ `object-graph.ts` already exports as `suggestName` (the shared helper
2305
+ #14268/#14575 consolidated three other rules onto). All three now import
2306
+ `suggestName` from `./object-graph` and their private copies are deleted.
2307
+
2308
+ `validate-ai-tool-references.ts` and `validate-translation-references.ts`
2309
+ each carry a one-line pre-pass ahead of the private pair — the `action_<name>`
2310
+ tool-family prefix, and a snake_case namespace-segment match — that is
2311
+ rule-local knowledge, not the shared helper's business. Both keep that
2312
+ pre-pass and now delegate the fallback to `suggestName` instead of a private
2313
+ Levenshtein copy.
2314
+
2315
+ The shared helper's containment pre-pass (a candidate that contains the
2316
+ target, or vice versa, scores ahead of any edit-distance match) is now every
2317
+ one of these five rules' behaviour too, so a hint may now appear where one was
2318
+ previously absent — it never removes a hint the private copy gave. Per site:
2319
+
2320
+ - `validate-action-name-refs.ts` — `archive` → `archive_completed_deals`
2321
+ (17 edits, over budget) now gets a hint; unaffected cases unchanged.
2322
+ - `validate-chart-bindings.ts` — the issue's own headline example,
2323
+ `amount` → `sum_amount` (4 edits, over the budget of 2) now gets a hint on
2324
+ a raw-field-instead-of-measure binding.
2325
+ - `validate-searchable-fields.ts` — `amount` → `sum_amount` (4 edits) now
2326
+ gets a hint on a stale `searchableFields` entry.
2327
+ - `validate-ai-tool-references.ts` — the `action_<name>` prefix pre-pass is
2328
+ unchanged and still wins first; a miss with no prefix match now also
2329
+ reaches `suggestName`'s containment scan (e.g. `knowledge_base` →
2330
+ `search_knowledge_base`), where the old private copy gave nothing.
2331
+ - `validate-translation-references.ts` — the namespace-segment pre-pass is
2332
+ unchanged and still wins first; a miss with no segment match now also
2333
+ reaches `suggestName`'s containment scan (e.g. `amount` →
2334
+ `amountsummary`), where the old private copy gave nothing.
2335
+
2336
+ `object-graph.ts`'s helper is untouched (already ruled by #14268/#14575);
2337
+ `validate-react-page-props.ts` and `validate-rule-schema-formats.ts` stay out
2338
+ — both are a different contract on purpose (see #14577's triage).
2339
+ - d754829: fix(lint): consolidate the four hand-copied "Did you mean?" helpers into `object-graph.ts`'s shared `nearestName`/`suggestName` (#14268)
2340
+
2341
+ `validate-object-references.ts`, `validate-sortable-fields.ts` and
2342
+ `validate-widget-bindings.ts` each carried a private `suggest`/`distance` (or
2343
+ `didYouMean`/`levenshtein`) pair, byte-for-byte re-deriving the same
2344
+ edit-distance budget `object-graph.ts` already exported on the package barrel
2345
+ as `nearestName`/`suggestName` — the same drift #4330 fixed one constant over
2346
+ for `SYSTEM_FIELDS`. All three now import `suggestName` (and `nearestName`
2347
+ where a rule needs the bare name) from `./object-graph` and their private
2348
+ copies are deleted.
2349
+
2350
+ The one decision the consolidation forced: `validate-widget-bindings.ts`
2351
+ scored a containment match (e.g. `amount` → `sum_amount`, the ADR-0021
2352
+ base-column → prefixed-measure-name drift) ahead of edit distance; the other
2353
+ two rules had no such pre-pass and suggested nothing for the same class of
2354
+ typo. That containment pre-pass is now `nearestName`'s behaviour for every
2355
+ caller — it only ever *adds* a suggestion where the edit-distance budget
2356
+ previously returned none, so a "Did you mean?" hint may now appear where one
2357
+ was previously absent. The full `@objectstack/lint` suite (93 files / 2812
2358
+ tests) was run against the pre-change and post-change trees and produced
2359
+ identical results, so no existing suggestion assertion was affected in
2360
+ practice.
2361
+ - fa7292c: fix(lint): wire `packages/lint`'s test layer into `check:test-typecheck`, so its 2,700-line rule pin file is actually type-checked (#14173)
2362
+
2363
+ `packages/lint/tsconfig.json` excluded `**/*.test.ts` and `**/*.spec.ts`, and
2364
+ the package's `typecheck` script was a bare `tsc --noEmit` against that very
2365
+ config — so no gate anywhere read a lint test file with a type checker.
2366
+ `src/validate-expressions.test.ts` alone is ~2,700 lines built almost entirely
2367
+ out of compile-time and meta pins (the #5017 receiver scan, the
2368
+ `TRACKED_UNDECLARED_READS` shrink-only list, the residual-root table), and none
2369
+ of it was type-checked by anything: vitest transpiles through esbuild (types
2370
+ stripped, never resolved), so a wrong key or a signature drift in a pin's own
2371
+ scaffolding was caught by nobody.
2372
+
2373
+ Onboarded by *wiring* to the mechanism #14062 (PR #14420) landed on
2374
+ (`scripts/check-test-typecheck.mts`), per the triage ruling on this card: a
2375
+ sibling `tsconfig.test.json` matching vitest's real module semantics
2376
+ (`module: esnext`, `moduleResolution: bundler`, `lib: ["ES2022"]`; strictness
2377
+ and `rootDir` untouched, inherited), named by `typecheck`. Measured (workspace
2378
+ closure built first): 6 residual errors over 2 files, all TS6059 (imports from
2379
+ `examples/app-showcase`, outside this package's `rootDir` — pre-existing,
2380
+ config-tier, not a lint defect), recorded EXACT and shrink-only in the new
2381
+ `test-typecheck-debt.json`.
2382
+
2383
+ This is a CONVERSION of the coverage gate's existing `@objectstack/lint`
2384
+ TEST_DEBT entry (`errors: 16`), not a new debt-opening decision: the same
2385
+ authority that recorded the 16 now holds the residue one level finer, per file
2386
+ and per signature, and the coverage-gate entry is deleted as the graduation
2387
+ that pairing forces. No test file is edited — opening the ratchet is not the
2388
+ same job as paying it down.
2389
+ - 22b0081: `os validate` now refuses a react-page `<ListView>` bound only by the metadata-tier data source instead of accepting it. The react-blocks contract deprecates `objectName` in favour of `data={{ provider: 'object', object }}`, but no renderer reads that spelling yet — so a page written to the contract's own summary validated green and then rendered an empty list with no diagnostic, and the check tightens back to what actually renders. The refusal names `objectName` as the spelling to write, and field-name props (`columns`, `searchableFields`, filter positions, …) again resolve against it, which is also the correct object when a page carries both spellings.
2390
+
2391
+ Scoped deliberately: pages binding with `objectName` are unaffected, and so are the `value` and `api` data providers, a plain-array `data`, and a non-static `data` prop — all of those do render. The deprecation warnings on `objectName` and `viewType` remain, with their text corrected; both are still the spellings to write until the renderer folds the canonical data source in. Pages that were green on the canonical spelling alone now fail by design.
2392
+ - 20a452e: `lintLivenessProperties` now honours its own docblock contract ("Advisory only
2393
+ — returns findings, never throws") when a collection item is `null` or
2394
+ otherwise not an object. The object walk, the field walk nested under it, and
2395
+ the flat `TYPE_COLLECTIONS` loop that covers every other governed type (flow,
2396
+ action, agent, tool, …) each read `item.name`/`item.object` straight off every
2397
+ element with no record guard, throwing `TypeError: Cannot read properties of
2398
+ null (reading 'name')` on a malformed item instead of skipping it — reachable
2399
+ via the exported `stack: AnyRec` signature on an unparsed or hand-built stack.
2400
+ The translation bundle walk already guarded its two levels (#11383); this
2401
+ closes the same hole on the three walks that did not (#11385).
2402
+ - de3c52b: Fix `validate-translation-references` reporting a nested `conditional` validation branch's legitimate `_validations` bundle entry as an orphan `translation-target-unknown`, with inverted advice.
2403
+
2404
+ The rule built its `_validations` universe with a flat walk of `objects[].validations[]`. A `conditional` rule's `then` / `otherwise` branch is itself a full rule carrying its own `name`, and that branch name — not the wrapper's — is the address `checkConditional` delegates to and `authoredRuleMessage` keys on at runtime (`packages/objectql/src/validation/rule-validator.ts`). The flat walk never saw a branch name, so a correct bundle entry for one was flagged as an orphan, and the finding's own text ("keeps its source locale in every refusal") was the opposite of the truth for that key — acting on the advice (deleting the entry) reintroduced the exact defect it fixed.
2405
+
2406
+ The walk now descends into `then` / `otherwise`, mirroring `evaluateRule`'s recursion (a branch may itself be a nested `conditional`, so depth is unbounded). The wrapper's own name stays in the universe, unchanged: its message is structurally unreachable at runtime, but a bundle entry for it is deliberately kept elsewhere so the bundle mirrors the declared rule set 1:1.
2407
+ - 289cf91: fix(lint): guard `walkPageComponents` against component cycles (#13217)
2408
+
2409
+ `walkPageComponents` — the one shared page-component traversal under every
2410
+ page-shaped lint rule and the CLI's i18n object-sections pass — descended the
2411
+ untyped composition slots inside `properties` with no cycle guard. Every one of
2412
+ those slots is `z.array(z.unknown())` authored data, so a component whose
2413
+ `properties.children` contains itself is **legal input**, and feeding one in
2414
+ recursed until the stack died with `RangeError: Maximum call stack size
2415
+ exceeded`. Because the walk is shared rather than copied, that crash was not
2416
+ scoped to one rule: it took every rule standing on the walk down in the same
2417
+ process.
2418
+
2419
+ The descent now carries an **ancestor set** — the node is added before
2420
+ descending and removed on the way out — so a node that is its own ancestor
2421
+ stops the descent. Measured on the shapes that matter: a direct self-reference,
2422
+ an indirect cycle (`A -> B -> A`) and a longer chain (`A -> B -> C -> A`) all
2423
+ terminate, through every descended slot (`properties.children`,
2424
+ `properties.items[].children`, `properties.body`, `properties.footer`).
2425
+
2426
+ Two deliberate non-changes, both pinned:
2427
+
2428
+ - **An ancestor set, not a visited set.** A component object placed twice as a
2429
+ *sibling*, or reached down two different branches, is legitimate re-use at two
2430
+ distinct config paths, and every rule built on this walk must see both
2431
+ placements. A visited set would yield the first and silently drop the rest —
2432
+ trading a loud crash for missing lint coverage. This matches the predicate the
2433
+ sibling resolver `translatePage` already settled on.
2434
+ - **No depth cap.** A cap and a cycle guard are different instruments. On a
2435
+ resolver a cap leaves copy untranslated; on a lint walk it would drop real
2436
+ components from the walk output and every rule would go quiet about them — a
2437
+ silent truncation that reads exactly like a clean page. With the cycle guard
2438
+ the descent is bounded by the document's own finite nesting, so a cap could
2439
+ only ever fire on acyclic input, which is the input it must not truncate.
2440
+
2441
+ The guard is silent: a cycle stops the descent and yields nothing extra, and no
2442
+ finding or warning is produced. Deciding that a self-referential page is itself
2443
+ an authoring error would be new reject behaviour on authored input, which is a
2444
+ contract call and not this walk's to make. Measured on a cyclic-but-otherwise
2445
+ valid page, all six rules that route through the walk report exactly what they
2446
+ report for the equivalent acyclic document (zero findings either way).
2447
+
2448
+ No authored page in this repo carries such a cycle — swept across 57
2449
+ page-shaped objects with a positive control, zero hits — so this fixes a
2450
+ reachable crash, not an active incident.
2451
+ - ba8420b: `lintLivenessProperties` no longer tells authors a `planned` property is `dead`
2452
+
2453
+ `describe()` in `lint-liveness-properties.ts` only knew two verdicts
2454
+ (`experimental`, everything else → `dead`), while the liveness ledger ships a
2455
+ third: `status: 'planned'` (declared, and a consumer is being built against
2456
+ it — contract-first, the opposite of `dead`). Every `planned` row fell through
2457
+ into the `dead` branch, so the finding's own **message** told the author to
2458
+ remove metadata the platform had asked them to write, while the same finding's
2459
+ **hint** (when the row carried one) said the opposite one sentence later. Three
2460
+ shipped rows hit this: `field.relatedListFilter`, `object.externalSharingModel`,
2461
+ `translation.flows`.
2462
+
2463
+ `describe()` now has a third branch: `status === 'planned'` gets its own rule
2464
+ id (`liveness-planned-property`, mirroring `liveness-dead-property` /
2465
+ `liveness-experimental-property`'s advisory-only posture — nothing downstream
2466
+ keys off these ids today) and its own message/default hint ("keep it — a
2467
+ consumer is being built against this property", never "Remove it").
2468
+
2469
+ The ledger's `status` field is a documented vocabulary, not a Zod-enforced
2470
+ enum — nothing rejects a ledger entry with an unrecognised status. `describe()`
2471
+ previously graded any such entry `dead` silently; it now throws, naming the
2472
+ offending status, so a ledger-authoring mistake (a typo, or a new status added
2473
+ without teaching this file about it) fails loudly at test time instead of
2474
+ mislabelling a finding.
2475
+ - b003cf2: Refuse an undeclared field a `before*` hook writes, identically on every driver
2476
+
2477
+ **BREAKING** accept-set narrowing at the post-hook write door, shipped as `minor`
2478
+ under the repo's launch-window convention for breaking changes.
2479
+
2480
+ **Bump level, argued**: `@objectstack/objectql` is `minor`, not `patch`. A
2481
+ `before*` hook or an L2 (`language:'js'`) body writing a key the object never
2482
+ declares **used to succeed** on the `memory` family — the value reached the
2483
+ store and persisted as a shadow column — and now **throws**, `INVALID_FIELD` /
2484
+ **400**, on every driver. That is a narrowing of the accept set on the record
2485
+ payload, a surface every hook body touches; it is not an instrument or a message
2486
+ fix, and a hook that relied on either driver-dependent outcome stops working at
2487
+ run time. The same-package sibling `.changeset/hook-input-symbol-key-refusal.md`
2488
+ argues exactly this shape — "used to succeed, and now throw. That is a narrowing
2489
+ of the accept set" — to `minor`, and the launch-window convention is what keeps
2490
+ it off `major` (pre-1.0 lockstep semantics: a breaking change does not burn a
2491
+ major version while the stack versions in lockstep — see
2492
+ `scripts/check-changeset-no-major.mjs`). `patch` would under-declare a change
2493
+ that turns a passing hook into a throwing one.
2494
+
2495
+ `'@objectstack/lint': patch` is deliberate and stays. That half of the diff is
2496
+ message and comment prose only: `validateHookBodyWrites` reports the same
2497
+ findings on the same bodies at the same severity, with wording that now names
2498
+ the runtime refusal instead of the driver split this change retires.
2499
+
2500
+ The declared-field door (#8682 on insert, #8738 on update) runs before the
2501
+ `before*` hooks — deliberately, so a payload about to be refused never consumes
2502
+ an autonumber (#8737). That left the payload the hooks themselves produce
2503
+ unjudged: a key a `beforeInsert` / `beforeUpdate` hook or an L2 (`language:'js'`)
2504
+ body wrote went straight to the driver, and the drivers disagreed. `memory`
2505
+ accepted it and stored a shadow column; `driver-sql` threw a raw `SQLITE_ERROR`
2506
+ with no `status` and the bound statement and its values quoted back in the
2507
+ message; `sqlite-wasm` threw a bare `Error` with neither. One app and one hook
2508
+ meant different things on two deployments, and nothing in the app could tell
2509
+ which one it was running on.
2510
+
2511
+ The same check now runs a second time over the post-hook payload, before any
2512
+ statement is built, so a hook-written undeclared key is refused with the caller
2513
+ path's envelope — `INVALID_FIELD` / **400**, `Unknown field 'x' on object 'y'` —
2514
+ on every driver, because none of them is reached. The existing pre-hook door is
2515
+ unchanged and stays exactly where it is.
2516
+
2517
+ This is a security fix as well as a consistency one: `fieldPermissions` is keyed
2518
+ by declared field name and reports only fields explicitly marked non-editable, so
2519
+ a key the object never declares can carry no entry and could never be gated by
2520
+ field-level security. On `memory`-family stores such a value was persisted where
2521
+ no view, formula, index or permission could name it.
2522
+
2523
+ The platform's own stamps are unaffected. `created_at` / `updated_at` — the two
2524
+ the built-in audit hook writes unconditionally, because SQL drivers create them
2525
+ as built-in columns on every table — are already tolerated by this check
2526
+ alongside `id`; every other stamp (`created_by`, `updated_by`, `tenant_id`) is
2527
+ guarded by an explicit declaration test in the hook that writes it.
2528
+
2529
+ <!-- adr-0087: not-required (no-migration-prescription) No metadata key, spec symbol, Zod schema, object definition or stored representation is added, removed or renamed. This narrows which run-time record payload the engine accepts after the `before*` hooks have run; an undeclared key was never a declarable metadata surface, so `objectstack migrate meta` has nothing in a stored source to rewrite. The remedy for an affected hook body is to declare the field on the object or stop writing it, which is authoring guidance, not a mechanical rewrite of stored metadata. -->
2530
+ - b5a2398: Correct three stale `current_user` binding claims about a form FIELD `visibleWhen`
2531
+
2532
+ A runtime form field's `visibleWhen` has resolved `current_user` — and the ADR-0068 D1 aliases `user` / `ctx.user` / `os.user` — since objectui#6010, but three texts shipped by these two packages still told authors the root was unbound there, and that per-option `visibleWhen` was "the only `*When` surface where it resolves".
2533
+
2534
+ - `@objectstack/spec`: `FormFieldSchema.visibleWhen`'s doc block and its `describe()` now state the binding together with the two limits it does not remove — it is a rendering rule that nothing on the write path evaluates, so a role test written there protects no data; and the scope belongs to the host, so it is empty on the console's public standalone form route, where the predicate faults and visibility fails open. The generated `content/docs/references/ui/view.mdx` rows follow from the `describe()`.
2535
+ - `@objectstack/lint`: the field-rule prescription no longer grounds "move it to the option's own `visibleWhen`" on exclusivity. It grounds it on enforcement — the rule validator evaluates a per-option predicate on every write — and names the form-view field predicate only to refuse it as a destination for a server-enforced object rule, since moving one there would trade a loud lint error for a silent enforcement gap.
2536
+
2537
+ No schema, validation or verdict change: the set of accepted metadata is byte-identical, and the rule still refuses a user root on an object field-level `*When`.
2538
+ - b992b1d: fix(lint): stop the `readonlyWhen` hints ruling out the remedy that works and offering one that does not (#13832)
2539
+
2540
+ Message text only. Rule ids, severities and match sets are untouched, and no
2541
+ finding changes shape — but the hint **is** the whole product of an advisory
2542
+ rule (neither finding blocks a build), so the sentence is all the author acts
2543
+ on, and both of these sentences were measured false against the engine.
2544
+
2545
+ `flow-update-readonly-flow-writes`' `flow-update-readonly-when-field` hint said:
2546
+
2547
+ > If automation must maintain this field regardless of record state, run the flow runAs:'system'.
2548
+
2549
+ It does not. The conditional strip has **no `isSystem` guard at all** —
2550
+ `stripReadonlyWhenFields` runs unconditionally on the update path, unlike the
2551
+ static `readonly` strip beside it that really is skipped for system callers.
2552
+ So the advice bought the author a `runAs:'system'` flow, a re-run, the same
2553
+ missing column, and an elevated run identity in the tree with no compensating
2554
+ behaviour: **a privilege widening for no effect**. Pinned as "LOCK 2 — isSystem
2555
+ does NOT exempt a caller-supplied value" in
2556
+ `engine-readonly-when-derived-writes.test.ts`, and from the strict-mode side as
2557
+ "covers readonlyWhen too — the arm a trusted (isSystem) caller can still hit".
2558
+
2559
+ Both the `hook-api-update-readonly-when-field` hint and the matching
2560
+ `content/docs/automation/hook-bodies.mdx` bullet carried the same defect from
2561
+ the other direction — they **ruled out the remedy that works**:
2562
+
2563
+ > readonlyWhen strips even a beforeUpdate-derived value, so an own-hook stamp is NOT a workaround here
2564
+
2565
+ That is the behaviour #9107 removed. The conditional strip now judges the
2566
+ *caller's* entry snapshot, so a value a `beforeUpdate` hook **derives** is not
2567
+ caller-supplied and lands even on a locked record —
2568
+ `engine-readonly-when-derived-writes.test.ts` opens with "THE REPORT: a
2569
+ hook-derived value on a TRUE readonlyWhen field now LANDS", and pins the bulk
2570
+ path on the same terms. Between them the two halves left the author's only
2571
+ working option struck out and a useless one recommended.
2572
+
2573
+ All three hints now name the same two measured remedies — confirm the write
2574
+ only targets records whose predicate is FALSE, or derive the field in a
2575
+ `beforeUpdate` hook on the target object — and refuse elevation explicitly,
2576
+ matching the shape `action-api-update-readonly-when-field` already shipped.
2577
+ The hook hint keeps its stronger, separate reason that `sudo()` is a
2578
+ `TypeError` from a sandboxed body, and now also carries the reason that
2579
+ survives if that one is ever fixed: a system context does not waive the
2580
+ conditional lock either.
2581
+
2582
+ Deliberately **not** flattened: the static-`readonly` hints and docs rows that
2583
+ recommend elevation stay exactly as they are, because for *that* strip
2584
+ elevation is the intended channel. The two disagree for a reason, and a pin now
2585
+ holds them apart.
2586
+ - b2dea86: Retire the "no `current_user` at section level" claim from the three prose sites the
2587
+ re-measurement left unswept
2588
+
2589
+ A form-view **section** `visibleWhen` binds `current_user` today. That was measured and
2590
+ landed for the schema text and the field-rule lint message, but three hand-written sites
2591
+ still taught the retired claim, so an author reading the docs or hitting the gate was told
2592
+ the opposite of what the platform does. Text only — no schema, no verdict and no runtime
2593
+ behaviour moves.
2594
+
2595
+ Re-verified at source in `objectui` before trimming anything, because a prose trim applied
2596
+ to a claim someone had since fixed would silently regress their work:
2597
+
2598
+ - `apps/console/src/components/FormPage.tsx` threads the host shell's scope into
2599
+ `isSectionVisible`, which forwards it to `evalFieldPredicate` (objectui#6110).
2600
+ - `packages/plugin-form/src/ObjectForm.tsx` copies an authored section `visibleWhen` onto
2601
+ the `section-divider` pseudo-field the renderer evaluates with that scope bound
2602
+ (objectui#6111); `SplitForm` / `ModalForm` / `DrawerForm` carry the same line.
2603
+
2604
+ The three sites:
2605
+
2606
+ - `content/docs/ui/views.mdx` listed *"section-level predicates (objectui#6111)"* as a
2607
+ surface that still evaluates the predicate unbound — naming as evidence the very PR that
2608
+ bound it. The same sentence also listed `/forms/:name` as unbound; that route renders
2609
+ inside `InternalFormRoute`, which publishes the session principal and binds normally, so
2610
+ the public `/f/:slug` route is now the only unbound surface named.
2611
+ - `content/docs/protocol/objectui/layout-dsl.mdx` carried the claim four times — a code
2612
+ comment, the binding-root table row, the paragraph under it, and the "two limits" prose —
2613
+ where the card recorded three. All four are re-measured together.
2614
+ - `packages/lint/scripts/check-doc-formula-expressions.mjs`'s field-rule epilogue still said
2615
+ a faulting field-level `visibleWhen` is simply fail-OPEN. Under a host that publishes a
2616
+ scope the predicate RESOLVES instead: the control is hidden in that one form while no
2617
+ server-side gate evaluates a field-level `visibleWhen` at all, so every other reader still
2618
+ returns the value — a silent enforcement gap, and the worse of the two outcomes. The
2619
+ fault-open leg is kept rather than replaced, because it is still what happens wherever no
2620
+ host publishes a scope. The verdict is untouched and the message says why it is now *more*
2621
+ justified.
2622
+
2623
+ Both replacement texts carry the two qualifications the retired claim's correction needs, so
2624
+ "sections bind `current_user`" cannot be read as an authorization primitive: the binding is
2625
+ **client-side only** (nothing on the write path evaluates a form-view field or section
2626
+ `visibleWhen` — it evaluates field `readonlyWhen` / `requiredWhen` and per-option
2627
+ `visibleWhen`, and that is the whole list), and **the scope belongs to the host**, so it is
2628
+ empty on the public `/f/:slug` route and the predicate faults open there.
2629
+
2630
+ The epilogue is a plain string nobody else read — deleting the re-measured clause broke no
2631
+ assertion and turned no gate red, which is exactly how the stale claim outlived its sibling.
2632
+ It is now pinned by a `--self-test` case that scopes itself to the real epilogue (so it
2633
+ cannot satisfy itself from its own literal) and asserts both outcomes plus the surviving
2634
+ fault-open leg. Proven capable of failing by ablation: reverting the clause on disk turns
2635
+ the self-test red, and restoring returns it to green.
2636
+ - 8e03393: Derive the runtime publish gate's context-collection set from `RuntimeStackContext` instead of hand-listing it.
2637
+
2638
+ `CONTEXT_STACK_KEYS` carried `as const satisfies readonly (keyof RuntimeStackContext)[]`, which asks that every entry it names is a real context key (validity) and never that every context key has an entry (completeness) — while the docblock on `RuntimeStackContext` claimed it was "derived from this shape and keeps the two from drifting". A collection declared on the interface but missing from the list was never carried into the per-write snapshot: the host passed it in, the gate dropped it, and every rule resolving references into that collection judged an empty universe and emitted findings that look correct. Measured, adding a collection to the interface left the package building at exit 0 with nothing red inside it.
2639
+
2640
+ The set is now derived from a keyed record typed `{ [K in keyof RuntimeStackContext]-?: true }` — the `-?` mechanism already proven at `@objectstack/metadata-protocol`'s `protocol.ts` — so a new context collection without its row is a type error naming that collection at `tsc --noEmit` and at the build. Declaration order is preserved and pinned, since it feeds both the snapshot's key order and the derived top-level-index alternation. No behaviour change: the same five collections are carried, in the same order.
2641
+ - f394614: fix(lint): give `validate-translation-references` its `_tabs` leg, and pin the object branch against the schema (#13835)
2642
+
2643
+ `validate-translation-references` walked the object branch's `fields`,
2644
+ `fields.*.options`, `_views`, `_sections` and `_actions`, but never `_tabs` —
2645
+ the string did not appear in the rule at all. The result was an **asymmetry**
2646
+ rather than a coverage gap: `collectExpectedEntries` already emits
2647
+ `objects.<object>._tabs.<tab>.label` and `os i18n check` demands a translation
2648
+ for it, while nothing told an author that a `_tabs` key they wrote named a
2649
+ filter preset that no longer exists. Rename a preset and the bundle keeps its
2650
+ old key: the tab bar renders in the source locale above a fully localized grid,
2651
+ with every gate green. This is the same shape #11608 identified for `flows`, one
2652
+ group over, and it was missed only because `_tabs` arrived after the object
2653
+ branch was written.
2654
+
2655
+ The object branch now reports `translation-target-unknown` for a `_tabs` key no
2656
+ page declares, with the usual near-miss suggestion and an enumeration of the
2657
+ real preset names.
2658
+
2659
+ The universe is collected from `page.interfaceConfig.userFilters.tabs[].name`,
2660
+ de-duplicated per object, mirroring `walkObjectTabs`
2661
+ (`packages/cli/src/utils/i18n-extract.ts`) rather than re-deriving it. Three
2662
+ shape facts are asked of the code that owns them:
2663
+
2664
+ - **`ViewTabSchema` has two carriers and only one is live.** The page-only
2665
+ preset bar (`UserFiltersSchema.tabs`, ADR-0047) is what objectui's
2666
+ `TabFilters` draws and what `translateInterfaceTabs` resolves;
2667
+ `ListViewSchema.tabs` has no renderer in either repo. Registering the dead
2668
+ carrier would make legal a key nothing resolves, so only the live one counts —
2669
+ and the hint says so when an object declares no preset bar, since otherwise an
2670
+ author reads the finding as a bug in the rule.
2671
+ - **The object binds through `interfaceConfig.source` first, then the page's own
2672
+ `object`** — the order both the resolver and the extractor use.
2673
+ - **Source-authored pages (`kind: 'html' | 'react' | 'jsx'`) are read here**,
2674
+ unlike in the component walk that skips their derived `regions` cache:
2675
+ `interfaceConfig` is authored metadata at the page root, and neither consumer
2676
+ consults `kind`.
2677
+
2678
+ Also lands the hardening the issue proposed: the object branch's coverage is now
2679
+ **pinned against `ObjectTranslationDataSchema`'s key set**, so the next group
2680
+ added to the shape cannot land without a leg here. The ledger classifies every
2681
+ declared key as either reference-checked or leaf copy, holds its key set equal to
2682
+ the schema's, and requires each `reference-checked` claim to be backed by a
2683
+ bundle that actually produces a finding — so claiming coverage costs a working
2684
+ leg rather than a line in a table.
2685
+
2686
+ Advisory-only, as the whole rule is: findings are warnings, nothing new is
2687
+ refused at publish, and no bundle that resolved before reports now.
2688
+ - f213793: `validate-translation-references` now checks the `flows` group — an authored key naming a
2689
+ flow, screen node or screen field that does not exist warns instead of resolving to nothing
2690
+
2691
+ The rule walked `objects`, `globalActions`, `apps` and `dashboards`; an unrecognised
2692
+ top-level namespace is skipped and never reported, and `flows` was one of them. So a
2693
+ bundle keyed to `flows.<name>.screens.<node_id>.fields.<field_name>` parsed, shipped, and
2694
+ silently resolved to nothing — the wizard rendering its source-locale string while every
2695
+ other label on the screen was translated, which is the exact failure this rule exists for,
2696
+ one namespace over.
2697
+
2698
+ All three levels are exact-match identifiers with an enumerable universe, so the leg
2699
+ mirrors the `dashboards` → `widgets` leg one level further: flow → `Flow.name`, screen →
2700
+ `FlowNode.id` on `type: 'screen'` nodes, field → `ScreenFieldConfig.name`. Findings are
2701
+ `warning`, like every other finding in this rule (ADR-0072 D1 — an orphan key is inert,
2702
+ not broken), and each names the declared universe it resolved against.
2703
+
2704
+ Two shape facts the collector respects, both measured against the schemas rather than
2705
+ assumed — either one read the obvious way would have made the leg a false-positive
2706
+ generator:
2707
+
2708
+ - **Screen nodes nest.** A screen inside an ADR-0031 region (`loop.config.body`,
2709
+ `parallel.config.branches[].nodes`, `try_catch.config.try`/`.catch`) is a real screen the
2710
+ runner pauses on, so the universe is collected through `walkFlowNodes` rather than the
2711
+ flat `flow.nodes`.
2712
+ - **`ScreenConfigSchema` has two mutually exclusive shapes.** An object-form screen
2713
+ (`config.objectName`) renders that object's own create/edit form and declares no
2714
+ `config.fields`; its input labels resolve through `objects.<objectName>.fields.*`, so a
2715
+ field key there is reported with that redirect rather than a bare "not declared".
2716
+
2717
+ A key naming a node that exists but is not a `screen` is diagnosed as the wrong node type,
2718
+ not as a missing node.
2719
+ - d2b5ba8: `view/layout-without-binding` now covers every view type that carries a binding block, and a new `view/tree-without-parent-field` rule catches the silently flat tree
2720
+
2721
+ `checkViewCompleteness`'s `VIEW_BINDING_BLOCKS` table named `kanban` / `calendar` / `gantt` only, while
2722
+ `ListViewSchema.type` has six members with a type-specific binding block. The other three fell through
2723
+ the same trapdoor the rule exists to close: objectui's ListView adapter falls back to literal field
2724
+ names (`timeline` → `startDateField || 'created_at'`, `titleField || 'name'`; `map` →
2725
+ `locationField || 'location'`; `tree` → `labelField || titleField || 'name'`), so a view authored
2726
+ without its block rendered empty — `timeline` drops every row whose start date fails to parse —
2727
+ while `os validate --json` reported `warnings: []` and `valid: true`. Measured both ways: deleting a
2728
+ `timeline` block was silent, deleting the sibling `gantt` block warned as designed.
2729
+
2730
+ - The table now names all six. Each new entry carries a `fix` hint naming the keys that make the block
2731
+ a binding (`timeline`'s two schema-required keys; either coordinate form for `map`; `parentField` +
2732
+ `labelField` for `tree`). Severity stays `warning` (ADR-0078 §1 — the view degrades, it does not die).
2733
+ - `map` is read for its coordinate binding, not merely for block presence: `ListMapConfigSchema` requires
2734
+ no key, so a `map` block declaring neither `locationField` nor the `latitudeField`/`longitudeField`
2735
+ pair is the same unbound view with braces and is warned about at `map.locationField`.
2736
+ - **New rule `view/tree-without-parent-field`** (warning, path `tree.parentField`): a `type: 'tree'` view
2737
+ with no declared `parentField` on an object that carries neither a `tree` field nor a
2738
+ `lookup`/`master_detail` back to itself renders FLAT — every record at depth 0, a correct-looking table
2739
+ whose expand slot never opens. Every `TreeConfigSchema` key is optional, so `tree: {}` satisfies the
2740
+ block check and still renders flat; this rule mirrors objectui's `detectParentField` exactly, so a
2741
+ view the renderer resolves by auto-detection is never warned about.
2742
+ - `checkViewCompleteness(view, boundObject?)` takes the bound object definition as an optional second
2743
+ argument (additive; one-argument callers are unchanged and the tree rule stays silent for them).
2744
+ `@objectstack/lint`'s `validate-functional-completeness` resolves the object by name from
2745
+ `stack.objects` — the list view's own `data.object` first, then the container's binding — and hands
2746
+ it over; no rule logic moved into lint.
2747
+ - `gallery` is measured (`titleField || 'name'`) and deliberately not added: its schema has no binding key
2748
+ to demand. `page` stays deliberately absent (a `page` view refuses at parse via `checkListViewPageMount`).
2749
+
2750
+ Under `os validate --strict` the new warnings are failures, as every warning in this family is.
2751
+ - 4b2cbf7: fix(lint): the three write-set rule messages now state the refusal authors actually get, not a retired driver split (#13858)
2752
+
2753
+ Message text only. Rule ids, severities, match sets and hints are untouched, and
2754
+ no finding changes shape — but a lint's own header states why the prose is
2755
+ governed: *"a lint that misdescribes the failure it is warning about teaches the
2756
+ wrong debugging instinct"*. These three sentences did.
2757
+
2758
+ `validate-hook-body-writes` (the `ctx.api` branch), `validate-action-body-writes`
2759
+ and `validate-flow-node-writes` all told the author that an undeclared write has
2760
+ a **driver-dependent** outcome:
2761
+
2762
+ > on a SQL driver the whole call then fails with a driver-level error far from here; on a schemaless driver (memory, MongoDB) the stray key is persisted
2763
+
2764
+ For the paths those three rules judge, that has not been true since the
2765
+ declared-field door landed (#8682 insert, #8738 update). All three describe a
2766
+ write whose payload is **caller-supplied**, not a mutation of an in-flight
2767
+ `ctx.input`: `ctx.api` is a `ScopedContext` over the running engine, and a flow
2768
+ node hands its `fields` map to the data engine directly. The door refuses a
2769
+ caller-named undeclared key from the object's field map **before any statement is
2770
+ built**, so no driver is reached and there is no split to observe.
2771
+
2772
+ Measured before the prose was rewritten — all three paths, both driver families,
2773
+ through a real QuickJS sandbox, a real `ObjectQL` engine, the real
2774
+ `AutomationEngine` with the real builtin CRUD node executors, real
2775
+ `@objectstack/driver-sql` (better-sqlite3) and real `@objectstack/driver-memory`:
2776
+
2777
+ | path | driver-sql | driver-memory |
2778
+ |---|---|---|
2779
+ | hook body `ctx.api.object(x).update({…})` | `INVALID_FIELD` / 400 | `INVALID_FIELD` / 400 |
2780
+ | action body `ctx.api.object(x).update({…})` | `INVALID_FIELD` / 400 | `INVALID_FIELD` / 400 |
2781
+ | flow `create_record` / `update_record` `fields` | `INVALID_FIELD` / 400 | `INVALID_FIELD` / 400 |
2782
+
2783
+ Every run answered `Unknown field 'stagee' on object 'deal'`; nothing was stored
2784
+ on either family, and the schemaless family kept **no** shadow column — the half
2785
+ the old message promised and the runtime no longer delivers.
2786
+
2787
+ The three messages now name that refusal in the vocabulary the `ctx.input`
2788
+ sibling landed with (`REFUSED at run time — INVALID_FIELD / 400, identically on
2789
+ every driver`), say why the door and not a driver answers, and keep each path's
2790
+ own blast radius: the hook refusal fails the operation that triggered the hook,
2791
+ the action refusal fails the action, and the flow node's refusal is whole — the
2792
+ correctly named fields in the same payload never land either, `create_record`
2793
+ never creates the row, and the step fails the run. That last clause is why the
2794
+ flow rule still gates at `error`; the severity is unchanged.
2795
+
2796
+ `unprovisionedAnchorWriteConsequence()` in the same files is **untouched**: an
2797
+ ADR-0015 external object's injected anchor *is* declared in the registered
2798
+ schema, so it passes the door by construction and the remote database really is
2799
+ what refuses it. That message was already correct.
2800
+ - Updated dependencies [809d417]
2801
+ - Updated dependencies [387e231]
2802
+ - Updated dependencies [f794e4e]
2803
+ - Updated dependencies [cae2169]
2804
+ - Updated dependencies [b812a54]
2805
+ - Updated dependencies [2d4fa75]
2806
+ - Updated dependencies [0e4e51b]
2807
+ - Updated dependencies [e84bbf6]
2808
+ - Updated dependencies [effae80]
2809
+ - Updated dependencies [d62f990]
2810
+ - Updated dependencies [c45d8e6]
2811
+ - Updated dependencies [2e3e8c7]
2812
+ - Updated dependencies [e621291]
2813
+ - Updated dependencies [40a93b5]
2814
+ - Updated dependencies [d5b330d]
2815
+ - Updated dependencies [dda969c]
2816
+ - Updated dependencies [1f45690]
2817
+ - Updated dependencies [277948f]
2818
+ - Updated dependencies [8bdd955]
2819
+ - Updated dependencies [f3bbbef]
2820
+ - Updated dependencies [4f24e9d]
2821
+ - Updated dependencies [474242f]
2822
+ - Updated dependencies [63cd487]
2823
+ - Updated dependencies [bd4aa4e]
2824
+ - Updated dependencies [803eaab]
2825
+ - Updated dependencies [f8e8f03]
2826
+ - Updated dependencies [eae824e]
2827
+ - Updated dependencies [f6fa22c]
2828
+ - Updated dependencies [8a483b3]
2829
+ - Updated dependencies [97bcd99]
2830
+ - Updated dependencies [df59de0]
2831
+ - Updated dependencies [96e25a8]
2832
+ - Updated dependencies [713f83f]
2833
+ - Updated dependencies [77d4b3c]
2834
+ - Updated dependencies [f75a38a]
2835
+ - Updated dependencies [7a25e7d]
2836
+ - Updated dependencies [1fa05a6]
2837
+ - Updated dependencies [c85a265]
2838
+ - Updated dependencies [dcb10a5]
2839
+ - Updated dependencies [773a999]
2840
+ - Updated dependencies [35dffea]
2841
+ - Updated dependencies [776a098]
2842
+ - Updated dependencies [5060877]
2843
+ - Updated dependencies [4f6325d]
2844
+ - Updated dependencies [52954c0]
2845
+ - Updated dependencies [2aa8456]
2846
+ - Updated dependencies [93809a3]
2847
+ - Updated dependencies [7c0d0c3]
2848
+ - Updated dependencies [daae7aa]
2849
+ - Updated dependencies [8dc22d6]
2850
+ - Updated dependencies [279431e]
2851
+ - Updated dependencies [948dd6b]
2852
+ - Updated dependencies [3b4c56c]
2853
+ - Updated dependencies [ae8edd2]
2854
+ - Updated dependencies [e25403c]
2855
+ - Updated dependencies [64baa68]
2856
+ - Updated dependencies [9fa70d7]
2857
+ - Updated dependencies [09db64a]
2858
+ - Updated dependencies [92916e7]
2859
+ - Updated dependencies [a84f3ea]
2860
+ - Updated dependencies [f2eaae8]
2861
+ - Updated dependencies [c09451b]
2862
+ - Updated dependencies [ba64877]
2863
+ - Updated dependencies [7345308]
2864
+ - Updated dependencies [79b6a22]
2865
+ - Updated dependencies [30d96ab]
2866
+ - Updated dependencies [f658793]
2867
+ - Updated dependencies [c95ad19]
2868
+ - Updated dependencies [e58ea8b]
2869
+ - Updated dependencies [4a17645]
2870
+ - Updated dependencies [3795c5f]
2871
+ - Updated dependencies [8ab926b]
2872
+ - Updated dependencies [7317cf2]
2873
+ - Updated dependencies [e25e839]
2874
+ - Updated dependencies [5997207]
2875
+ - Updated dependencies [8b13cc8]
2876
+ - Updated dependencies [4a4a35d]
2877
+ - Updated dependencies [4a4a35d]
2878
+ - Updated dependencies [86e765a]
2879
+ - Updated dependencies [1d7e76a]
2880
+ - Updated dependencies [53dc739]
2881
+ - Updated dependencies [fd289be]
2882
+ - Updated dependencies [03bf7b1]
2883
+ - Updated dependencies [f90e820]
2884
+ - Updated dependencies [18d816a]
2885
+ - Updated dependencies [e8bd715]
2886
+ - Updated dependencies [b91c351]
2887
+ - Updated dependencies [a28a3c0]
2888
+ - Updated dependencies [daeaaf9]
2889
+ - Updated dependencies [c459da6]
2890
+ - Updated dependencies [e914733]
2891
+ - Updated dependencies [1d8ad0f]
2892
+ - Updated dependencies [9738c35]
2893
+ - Updated dependencies [f887e52]
2894
+ - Updated dependencies [881f8d8]
2895
+ - Updated dependencies [3bfa1e6]
2896
+ - Updated dependencies [901355c]
2897
+ - Updated dependencies [34ce8e7]
2898
+ - Updated dependencies [33681ea]
2899
+ - Updated dependencies [4635f3e]
2900
+ - Updated dependencies [ee3595c]
2901
+ - Updated dependencies [b2eab95]
2902
+ - Updated dependencies [93940d4]
2903
+ - Updated dependencies [3a04b01]
2904
+ - Updated dependencies [45b9051]
2905
+ - Updated dependencies [b9e9227]
2906
+ - Updated dependencies [d395692]
2907
+ - Updated dependencies [5894d30]
2908
+ - Updated dependencies [a3765f6]
2909
+ - Updated dependencies [e22158f]
2910
+ - Updated dependencies [7404925]
2911
+ - Updated dependencies [0c2334f]
2912
+ - Updated dependencies [778c59f]
2913
+ - Updated dependencies [d2619fd]
2914
+ - Updated dependencies [6acb11a]
2915
+ - Updated dependencies [33c5fd3]
2916
+ - Updated dependencies [20b0fdb]
2917
+ - Updated dependencies [905019b]
2918
+ - Updated dependencies [a286411]
2919
+ - Updated dependencies [98c0d33]
2920
+ - Updated dependencies [368a82e]
2921
+ - Updated dependencies [a3d5724]
2922
+ - Updated dependencies [93ea19b]
2923
+ - Updated dependencies [9ee2dcf]
2924
+ - Updated dependencies [8cb96ec]
2925
+ - Updated dependencies [8f10a79]
2926
+ - Updated dependencies [6269a55]
2927
+ - Updated dependencies [0fb8760]
2928
+ - Updated dependencies [e5ce2ed]
2929
+ - Updated dependencies [be21955]
2930
+ - Updated dependencies [bc56e18]
2931
+ - Updated dependencies [be21955]
2932
+ - Updated dependencies [a9ee989]
2933
+ - Updated dependencies [4d0d944]
2934
+ - Updated dependencies [15d58db]
2935
+ - Updated dependencies [d63b014]
2936
+ - Updated dependencies [9abe4e4]
2937
+ - Updated dependencies [2cc7122]
2938
+ - Updated dependencies [50d6c92]
2939
+ - Updated dependencies [9e0ba21]
2940
+ - Updated dependencies [311433f]
2941
+ - Updated dependencies [3e5ad08]
2942
+ - Updated dependencies [9abe4e4]
2943
+ - Updated dependencies [b7131f3]
2944
+ - Updated dependencies [e5812fa]
2945
+ - Updated dependencies [7085f90]
2946
+ - Updated dependencies [dee4dd4]
2947
+ - Updated dependencies [ce7e497]
2948
+ - Updated dependencies [51ecb2f]
2949
+ - Updated dependencies [9086761]
2950
+ - Updated dependencies [42a117b]
2951
+ - Updated dependencies [1401ae7]
2952
+ - Updated dependencies [4297fe7]
2953
+ - Updated dependencies [e398863]
2954
+ - Updated dependencies [d16df74]
2955
+ - Updated dependencies [f11fc61]
2956
+ - Updated dependencies [e808890]
2957
+ - Updated dependencies [8f79379]
2958
+ - Updated dependencies [e6ca40e]
2959
+ - Updated dependencies [0c77ea4]
2960
+ - Updated dependencies [52954c0]
2961
+ - Updated dependencies [89eb997]
2962
+ - Updated dependencies [aa5994e]
2963
+ - Updated dependencies [be93457]
2964
+ - Updated dependencies [a65db76]
2965
+ - Updated dependencies [15eb2c9]
2966
+ - Updated dependencies [5691b07]
2967
+ - Updated dependencies [2a6122b]
2968
+ - Updated dependencies [225e769]
2969
+ - Updated dependencies [8af88dd]
2970
+ - Updated dependencies [fb5fbb8]
2971
+ - Updated dependencies [d7b3963]
2972
+ - Updated dependencies [b72db01]
2973
+ - Updated dependencies [dce5cd4]
2974
+ - Updated dependencies [177ebdc]
2975
+ - Updated dependencies [8d237b4]
2976
+ - Updated dependencies [2d2e6f0]
2977
+ - Updated dependencies [2d8dd8d]
2978
+ - Updated dependencies [22d573e]
2979
+ - Updated dependencies [b5a2398]
2980
+ - Updated dependencies [348860c]
2981
+ - Updated dependencies [5383fa6]
2982
+ - Updated dependencies [5b3ff63]
2983
+ - Updated dependencies [1a6a19c]
2984
+ - Updated dependencies [527e050]
2985
+ - Updated dependencies [dd33bf9]
2986
+ - Updated dependencies [4cb2a90]
2987
+ - Updated dependencies [74a7804]
2988
+ - Updated dependencies [53d3689]
2989
+ - Updated dependencies [b3a63d3]
2990
+ - Updated dependencies [033a34c]
2991
+ - Updated dependencies [4d25d22]
2992
+ - Updated dependencies [1ffee51]
2993
+ - Updated dependencies [5ae4303]
2994
+ - Updated dependencies [ece4dad]
2995
+ - Updated dependencies [e9b377e]
2996
+ - Updated dependencies [146f448]
2997
+ - Updated dependencies [735f5c7]
2998
+ - Updated dependencies [a7e18de]
2999
+ - Updated dependencies [366f895]
3000
+ - Updated dependencies [dc75ba8]
3001
+ - Updated dependencies [2182bd1]
3002
+ - Updated dependencies [2a5c1cd]
3003
+ - Updated dependencies [34f60b7]
3004
+ - Updated dependencies [0e68ed2]
3005
+ - Updated dependencies [8beb3de]
3006
+ - Updated dependencies [4a9f461]
3007
+ - Updated dependencies [cce0aa9]
3008
+ - Updated dependencies [e764507]
3009
+ - Updated dependencies [cff17af]
3010
+ - Updated dependencies [39404f3]
3011
+ - Updated dependencies [ca1965f]
3012
+ - Updated dependencies [8619f95]
3013
+ - Updated dependencies [b706af9]
3014
+ - Updated dependencies [fc9ba76]
3015
+ - Updated dependencies [0f94cc7]
3016
+ - Updated dependencies [a11c1a5]
3017
+ - Updated dependencies [71f9cd1]
3018
+ - Updated dependencies [ee17d86]
3019
+ - Updated dependencies [cdbd920]
3020
+ - Updated dependencies [18c432e]
3021
+ - Updated dependencies [3c418c4]
3022
+ - Updated dependencies [fa8715a]
3023
+ - Updated dependencies [a933ed7]
3024
+ - Updated dependencies [b3ca463]
3025
+ - Updated dependencies [a933ed7]
3026
+ - Updated dependencies [0d4a6a8]
3027
+ - Updated dependencies [518d5e5]
3028
+ - Updated dependencies [6643ba1]
3029
+ - Updated dependencies [eeba2ef]
3030
+ - Updated dependencies [ec4c4d2]
3031
+ - Updated dependencies [424f73c]
3032
+ - Updated dependencies [cccbe51]
3033
+ - Updated dependencies [a8d6b1d]
3034
+ - Updated dependencies [e4a7695]
3035
+ - Updated dependencies [87075b1]
3036
+ - Updated dependencies [fc58a99]
3037
+ - Updated dependencies [14cfc00]
3038
+ - Updated dependencies [1c6f7b4]
3039
+ - Updated dependencies [e854a53]
3040
+ - Updated dependencies [dfebfc8]
3041
+ - Updated dependencies [d028b37]
3042
+ - Updated dependencies [122ef38]
3043
+ - Updated dependencies [4a37870]
3044
+ - Updated dependencies [428f9b2]
3045
+ - Updated dependencies [aa7ff56]
3046
+ - Updated dependencies [c41b42e]
3047
+ - Updated dependencies [c4db311]
3048
+ - Updated dependencies [750fff5]
3049
+ - Updated dependencies [c19035e]
3050
+ - Updated dependencies [ececf7a]
3051
+ - Updated dependencies [d173125]
3052
+ - Updated dependencies [8eeca27]
3053
+ - Updated dependencies [8425c17]
3054
+ - Updated dependencies [a5ef1d8]
3055
+ - Updated dependencies [772d5de]
3056
+ - Updated dependencies [ce80ec2]
3057
+ - Updated dependencies [b372318]
3058
+ - Updated dependencies [97a2263]
3059
+ - Updated dependencies [29d0676]
3060
+ - Updated dependencies [0169d49]
3061
+ - Updated dependencies [6bd3231]
3062
+ - Updated dependencies [d2b5ba8]
3063
+ - Updated dependencies [b799ac5]
3064
+ - Updated dependencies [8f74307]
3065
+ - Updated dependencies [d23dc08]
3066
+ - Updated dependencies [038f333]
3067
+ - Updated dependencies [644ad50]
3068
+ - Updated dependencies [0da7cd2]
3069
+ - Updated dependencies [28a5c3e]
3070
+ - Updated dependencies [4bc18e5]
3071
+ - @objectstack/spec@17.3.0
3072
+ - @objectstack/formula@17.3.0
3073
+ - @objectstack/sdui-parser@17.3.0
3074
+
3
3075
  ## 17.2.0
4
3076
 
5
3077
  ### Minor Changes