@objectstack/lint 17.2.0 → 17.3.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,2299 @@
1
1
  # @objectstack/lint
2
2
 
3
+ ## 17.3.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 345fc33: Resolve an ADR-0021 dataset's own references — base object, `include[]`,
8
+ `dimensions[].field` / `measures[].field`, and filter KEYS — at
9
+ `validate`/`build` (#14105)
10
+
11
+ A dataset could name a **base object that does not exist**, join a
12
+ **relationship that does not exist**, and bind every dimension and measure to
13
+ **fields that do not exist**, and `objectstack validate` exited **0** with
14
+ `✓ Validation passed`. `objectstack build` also exited 0 and wrote the dangling
15
+ dataset into `dist/objectstack.json`.
16
+
17
+ The sting was that the author-time rule pass **already walked those exact
18
+ nodes**. Measured on published 17.2.0, each mutation applied on its own and
19
+ confirmed on disk before running:
20
+
21
+ | mutation | before | after |
22
+ |:----------------------------------------------------|:-------|:------|
23
+ | dimension `field` → a base field that does not exist | passed | `dataset-field-unknown` |
24
+ | dimension `field` → a joined field that does not exist | passed | `dataset-field-unknown` |
25
+ | measure `field` → a field that does not exist | passed | `dataset-field-unknown` |
26
+ | measure filter KEY → a field that does not exist | passed | `dataset-filter-field-unknown` |
27
+ | `include[]` → a relationship that does not exist | passed | `dataset-include-unknown` |
28
+ | `object` → an object that does not exist | passed | `object-reference-unknown` |
29
+
30
+ The two controls in that measurement — a duplicate measure name
31
+ (`DatasetSchema.superRefine`) and a bad date macro in a **measure filter**
32
+ (`filter-token-unknown`) — both failed the build, so datasets were
33
+ demonstrably in the validation path the whole time. `filter-token-unknown`
34
+ already stood at `datasets[1].measures[1].filter.last_update_at.$lt` and
35
+ reasoned about the **value**; nothing standing in that same position resolved
36
+ the **key**, or the sibling `field` one level up.
37
+
38
+ This matters more for a dataset than for most metadata because a dataset is the
39
+ semantic layer: dashboards and reports bind its dimensions and measures by name
40
+ (ADR-0021), and the consumer end of that binding is already guarded
41
+ (`widget-dataset-unknown` / `widget-dimension-unknown` / `widget-measure-unknown`,
42
+ #7529/#8902). So the surviving hole was the quiet one — every binding resolves,
43
+ the board renders, and the charts are empty or subtly wrong because the dataset
44
+ underneath addresses columns that do not exist.
45
+
46
+ **Five verdicts, all `error`.** Four are new rule ids on a new suite member,
47
+ `validateDatasetReferences`:
48
+
49
+ - `dataset-include-unknown` — an `include[]` entry that resolves to nothing, or
50
+ to a field that is not a relationship, so no join can be derived from it.
51
+ - `dataset-field-unknown` — a dimension or measure `field` path that resolves to
52
+ no column, on the base object or on any joined object along the path.
53
+ - `dataset-field-not-included` — the second real check: a dotted path that
54
+ RESOLVES, but whose relationship prefix was never declared in `include`.
55
+ ADR-0021 D-C joins only declared paths, so the column is out of the query's
56
+ reach however real it is.
57
+ - `dataset-filter-field-unknown` — a filter key on `Dataset.filter` or
58
+ `measures[].filter`, in any of the three authored filter shapes.
59
+
60
+ The fifth, the base object itself, lands on `validateObjectReferences` as a new
61
+ `datasets[].object` reference site rather than as a sixth id here. That rule's
62
+ charter IS object-name references that are plain `z.string()`, and putting it
63
+ there buys the curated cross-package severity ladder: the platform's own
64
+ `system.datasets.ts` declares five datasets over `sys_*` objects, three of which
65
+ live in packages a stack compiling plugin-auth alone cannot see. All five
66
+ resolve through `PLATFORM_PROVIDED_OBJECT_NAMES`; a local "not in this stack ⇒
67
+ error" check would have reported every one of them. When the base object does
68
+ not resolve, `validateDatasetReferences` skips the dataset entirely, so one typo
69
+ yields one finding rather than one per dimension, measure and filter key.
70
+
71
+ **Skips, so a finding is never a guess** (ADR-0072 D1): an object this stack
72
+ does not define, an object with no readable field map (ADR-0015 `external` and
73
+ introspected schemas), a registry-injected system column, and any hop *through*
74
+ one — an injected `owner_id` is a lookup at the registry whose target is
75
+ invisible here, so `owner_id.name` is unanswerable rather than a miss. The
76
+ shipped `showcase_task_metrics` dimension `{ field: 'created_at' }` is that skip's
77
+ live case, and every shipped dataset in the repo is silent under the new rule.
78
+
79
+ **Two reusable seams ship with it**, newly exported, because the same two
80
+ questions are asked at a dashboard widget's filter keys and `sortBy` and at a
81
+ list view's field positions, and three independent copies of a hop-walker drift:
82
+
83
+ - `object-graph.ts` — `indexObjectGraph` / `resolveFieldPath` / `isUnjudgeable`,
84
+ answering "what does this `relationship[.relationship].field` path resolve to?"
85
+ as a discriminated **verdict** union rather than a boolean, so a caller can
86
+ tell "this hop is not a relationship" from "this leaf does not exist" and write
87
+ the right prescription. Plus `nearestName` / `suggestName` / `listNames`.
88
+ - `walkFilterFieldKeys` (`filter-walk.ts`) — the FIELD-KEY half of a filter
89
+ subtree, beside the subtree-finding half that module already owns. It handles
90
+ all three authored shapes (Mongo condition object, `{ field, operator, value }`
91
+ rules, `[field, op, value]` triples), because a reader that handles only one
92
+ shape is the exact bug #3574 was filed against, and it composes a nested
93
+ condition object into one relationship path so `{ account: { region: … } }`
94
+ reports `account.region` rather than a bare `region` resolved against the
95
+ wrong object.
96
+
97
+ Both hold mechanism only — no rule ids, no severities, no findings.
98
+ - 09ae32e: Judge `visibleWhen` / `readonlyWhen` / `requiredWhen` examples in the docs corpus
99
+ as CEL — where the enclosing structure says which layer they are about
100
+
101
+ `{/* os:check */}` blocks are type-checked by `tsc --noEmit`, and every CEL
102
+ string is the same type as every other CEL string, so
103
+ `visibleWhen: "record.status != 'closed' && user.hasRole('admin')"` type-checked
104
+ perfectly. `hasRole` is a CEL function that exists nowhere — it is in no stdlib
105
+ registry and on no contract — so the predicate faults at runtime, and a
106
+ field-level `visibleWhen` fault is fail-**open**: `resolveFieldRuleState`
107
+ evaluates visibility with `fallback: true`, so the element the author wrote the
108
+ predicate to hide is shown to everyone who copies the page. That is not a
109
+ hypothetical shape — a shipped doc taught it (#11034 fixed the instance).
110
+
111
+ `check:doc-formula-expressions` gains this as a third scan surface rather than a
112
+ second gate, because two gates with opinions about one contract is the thing
113
+ Prime Directive #12 exists to prevent. The verdict is imported whole: syntax, the
114
+ unknown-function catch and the bare-reference rule come from
115
+ `@objectstack/formula`'s `validateExpression`, and the closed-root rule comes
116
+ from `fieldRuleRootIssue` — the same two the metadata walk applies to the same
117
+ slot, in the same order, in the same words.
118
+
119
+ **The layer is decided first, and a layer that cannot be decided is skipped and
120
+ printed.** `visibleWhen` is one key spelling several unrelated contracts, and the
121
+ binding root really does differ: an object field binds `record` + `previous`
122
+ (+ `parent`), a per-option predicate binds `record` plus the host predicate scope
123
+ including `current_user`, a page component binds the user roots and `app`, and a
124
+ flow-screen field **flattens its own field names to top level**. A gate keyed on
125
+ the key alone would have gone red on
126
+ `content/docs/automation/flows.mdx`'s correct `visibleWhen:
127
+ 'createOpportunity == true'` and on `content/docs/ui/pages.mdx`'s correct
128
+ `'sales_manager' in current_user.positions` — and a gate whose reds are wrong is
129
+ worse than no gate, because it teaches people to add ignores.
130
+
131
+ So admission is structural and schema-backed, never keyed on the key: a
132
+ `Field.*({ … })` factory call, or a raw field definition carrying `type:` inside
133
+ an object-literal `fields:` **map**. The map-versus-array test is the load-bearing
134
+ half and it is read off the schemas — `ObjectSchema.fields` is
135
+ `z.record(name, FieldSchema)` while `FormFieldSchema` and `ScreenFieldConfigSchema`
136
+ are both `z.array(…)`, so a `fields:` map is the object-field layer and nothing
137
+ else, and a `fields:` array is exactly the case that cannot be told apart.
138
+
139
+ **The skip list is printed and counted on every run, including green ones.** A
140
+ gate that skips in silence is the same false-green one level up, so the summary
141
+ names every skipped site and why. Measured on the corpus as it stands: 23
142
+ text-level `*When:` occurrences, of which 13 are admitted and judged, 7 are
143
+ listed as skipped, and 3 are the ADR quoting `field.zod.ts`'s schema
144
+ (`visibleWhen: ExpressionInputSchema.optional()`) rather than authoring a
145
+ predicate. Three of those seven were invisible to an AST-only walk — a bare
146
+ `visibleWhen: "…"` line at statement position is a labelled statement, not a
147
+ property — so a text-level tripwire reconciles the two counts and any site the
148
+ parser never surfaced is listed rather than dropped.
149
+
150
+ `@objectstack/lint` newly exports `fieldRuleRootIssue` and
151
+ `FIELD_RULE_BOUND_ROOTS`. The field-rule root decision was a closure inside
152
+ `validateStackExpressions` — correct while it had one caller, and exactly how a
153
+ second caller comes to own a dialect of a rule instead of the rule. Behaviour is
154
+ unchanged: the metadata walk now calls the extracted function and its 2271 tests
155
+ pass untouched.
156
+ - 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.
157
+ - 661275d: security lint: report a `controlled_by_parent` object whose master is decided by FIELD DECLARATION ORDER (#14747)
158
+
159
+ `SecurityPlugin.resolveCbpRelation` resolves the master a `controlled_by_parent`
160
+ object derives record-level access from through three tiers — a required
161
+ `master_detail`, then any `master_detail`, then a required `lookup` — and picks
162
+ inside a tier with `Array.prototype.find`. So when two or more candidates sit in
163
+ the tier that wins, the master is whichever one the field map happens to list
164
+ first. Measured on a real kernel: an object declaring two required lookups
165
+ resolved its security master to the first-declared one, and swapping the two
166
+ field declarations — nothing else — repointed every row's record-level access
167
+ to the other object. Nothing reported it: not `os validate`, not `os lint`, not
168
+ a boot warning.
169
+
170
+ New error id **`security-controlled-by-parent-ambiguous-relation`**, the mirror
171
+ image of `security-controlled-by-parent-no-relation` (#7503): that one reports
172
+ ZERO candidates, this one reports two or more. The message names every
173
+ candidate — field, type and master — in declaration order, says which tier was
174
+ tested, and says which candidate wins today and therefore which object access
175
+ derives from right now.
176
+
177
+ Only the **winning** tier is judged, and that is not a shortcut: the runtime's
178
+ `??` chain stops at the first tier that resolves, so a tie in a lower tier is
179
+ masked by a higher tier's single winner and is not a decision the platform ever
180
+ makes. An object with one required `master_detail` and two required lookups is
181
+ silent, and stays silent.
182
+
183
+ `error` rather than advisory, for the inverse of the usual reason. The other
184
+ error rules in this linter mirror a hard runtime refusal; this one has none to
185
+ mirror precisely BECAUSE the runtime does not refuse — it silently picks — so
186
+ author time is the only place the ambiguity can ever surface. What it does meet
187
+ is the admissibility bar the #7503 rule states: a self-contained property of the
188
+ object document, no per-permission-set nuance to adjudicate, and no legitimate
189
+ reading, since two tied candidates is not an author saying which master they
190
+ meant.
191
+
192
+ This **narrows the accept set of a gating rule** — error findings fail
193
+ `os validate` / `os compile`. Measured over the shipped corpus: the three
194
+ `controlled_by_parent` objects in the example apps (`showcase_invoice_line`,
195
+ `showcase_expense_line`, `crm_opportunity_line_item`) plus the 27
196
+ `ObjectSchema.create` sites the `check:doc-security-posture` gate reads across
197
+ 226 marked prose blocks — **0 findings before and 0 after**. Each of the three
198
+ declares exactly one required `master_detail`, so tier 1 wins with a single
199
+ candidate. `showcase_invoice_line` is the interesting one: it also carries a
200
+ required `lookup`, and the rule is silent because that tie-free lower tier is
201
+ never reached.
202
+
203
+ No runtime behaviour changes. `resolveCbpRelation` in this package now reads its
204
+ tiers from one shared table so the two rules cannot disagree about which tier
205
+ wins, and its answer is unchanged by construction: `find` over a tier is the
206
+ first element `filter` over that tier keeps. The mirror's one deliberate
207
+ divergence from the runtime is kept — `reference` is the only spelling accepted
208
+ here (#5017), so a field carrying the rejected `reference_to` alias is not a
209
+ candidate and cannot create a tie.
210
+ - 9b30cc1: `lintLivenessProperties` no longer crashes on the `live-elsewhere` verdict — and never tells an author to remove a key a sibling repo enforces
211
+
212
+ `describe()` in `lint-liveness-properties.ts` knew three verdicts
213
+ (`experimental`, `planned`, `dead`) and threw, loudly and by design, on any
214
+ other. #13483 then shipped the ledger's fifth status — `live-elsewhere`: dead
215
+ HERE by measurement, genuinely enforced in a sibling repo — and migrated
216
+ `manifest.runtime` onto it (its enforcer is the cloud marketplace publish
217
+ gate). Nothing taught `describe()` about it, so the day any `live-elsewhere`
218
+ row opts into `authorWarn: true`, `os lint` would raise that
219
+ shipped-ledger-integrity error instead of the advisory warning the author
220
+ should get. No shipped row carries `authorWarn` today, so this was a fuse
221
+ rather than a fire.
222
+
223
+ `describe()` now has a fourth branch. `live-elsewhere` gets its own rule id —
224
+ `liveness-live-elsewhere-property`, exported as `LIVENESS_LIVE_ELSEWHERE_PROPERTY`
225
+ beside `LIVENESS_DEAD_PROPERTY` / `LIVENESS_EXPERIMENTAL_PROPERTY` /
226
+ `LIVENESS_PLANNED_PROPERTY` and advisory-only like them — plus its own message
227
+ (`is enforced in a sibling repo, not here`) and its own default hint, which keeps
228
+ the property and points at the ledger row's `evidence` for the enforcer. It
229
+ deliberately does **not** reuse the `dead` branch: that is the #11384 lesson,
230
+ which is that verdicts imply OPPOSITE author actions, and "Remove it" is the
231
+ single most damaging sentence available about a key whose enforcement is real
232
+ and remote — deleting it tears out a live gate's input. The sentinel throw
233
+ stays for genuinely unknown statuses, with its enumeration of the known ones
234
+ updated.
235
+
236
+ The suite gains a coverage pin derived from the shipped ledgers rather than from
237
+ a hand-written list: every distinct `status` those ledgers actually carry must be
238
+ answered by `describe()` with a rule id of its own, or (for `live`, which reaches
239
+ `describe()` only through a ledger-authoring mistake) must still fail loud. A
240
+ sixth status now fails that pin by name instead of waiting for an author to trip
241
+ the sentinel.
242
+ - 8d3f093: fix(lint): refuse a list view's dotted field reference at author time where the runtime door refuses it (#14282)
243
+
244
+ An accept-set narrowing on `validateListViewFieldRefs`, the #14107 rule — shipped
245
+ as `minor`, matching the level that landing and the two family landings before it
246
+ (#14105, #14148) were given.
247
+
248
+ #14107 judges only the HEAD segment of a list-view field reference, so a dotted
249
+ path whose head resolves to a real relationship field (`columns: [{ field:
250
+ 'owner.name' }]`) passed `os validate` and `os build` clean while every query
251
+ door a list view reaches refuses it by name. That half was recorded in the rule's
252
+ docblock and pinned in tests rather than closed, because its failure mode is the
253
+ opposite of the silent-blank class #14107 gates: a loud `400 INVALID_FIELD` on
254
+ the first fetch. This is the ruled resolution of that half, as a second finding
255
+ class with its own id, `list-view-field-dotted`, so one class can be suppressed
256
+ or filtered without silencing the other (the convention
257
+ `validate-sortable-fields` and `validate-searchable-fields` already follow).
258
+
259
+ The class is scoped by the DOOR, not by the position table, because some
260
+ list-view positions are read client-side out of the fetched row and walk a dotted
261
+ path perfectly well:
262
+
263
+ - **Projection** — `columns[]`, in both authored spellings. Clients build the
264
+ `$select` projection from them, and both doors refuse a dotted entry
265
+ unconditionally (`assertProjectionHasNoDottedPaths` on the engine boundary,
266
+ `assertProjectionFieldsExist` at the REST ingress).
267
+ - **Filter** — the view's `filter`, its `tabs[].filter`, its
268
+ `userFilters.tabs[].filter`, and the two positions declaring which names an end
269
+ user may filter on (`filterableFields`, `userFilters.fields`). Here the rule
270
+ asks the same `classifyDottedFilterHead` the runtime doors ask, so the #8371
271
+ carve-outs the doors serve — structured/JSON heads, array-valued heads, heads
272
+ whose type is unreadable — are NOT refused at author time.
273
+
274
+ Deliberately excluded, each measured rather than assumed:
275
+ `gantt.quickFilters[].field` and `gantt.tooltipFields[]`, which the renderer
276
+ resolves IN MEMORY over already-fetched rows through walkers that split on `.`
277
+ (the spec describes the former as "Record field / dot-path", and the measurement
278
+ agreed); and every renderer binding that reaches no query door, which stays
279
+ unjudged rather than acquiring a verdict nobody measured.
280
+
281
+ Existing behaviour is untouched: a dotted path whose head resolves to nothing
282
+ still reports `list-view-field-unknown`, `sort[]` keeps its owner, and the
283
+ shipped example corpus was measured at zero findings both before and after.
284
+ - aca23ab: feat(lint): resolve a list view's field references at validate/build (#14107)
285
+
286
+ Accept-set narrowing, `minor` under the family precedent (#14105, #14148).
287
+
288
+ A list view names fields in more than twenty places and **none of them was
289
+ resolved against the bound object** — not by `os validate`, and not by `os
290
+ build`, which is the publish gate. Measured on `@objectstack/cli` 17.2.0 from a
291
+ real app, each mutation applied on its own and confirmed on disk: a
292
+ `columns[].field`, a `filter[].field`, a `grouping.fields[].field`, a
293
+ `kanban.groupByField` and a `gantt.startDateField` naming a field that does not
294
+ exist all left `os validate` at `valid: true, warnings: []` and `os build` at
295
+ exit 0, `✓ Build complete`.
296
+
297
+ Each one fails silently at render, in the way ADR-0078 and the
298
+ `view/layout-without-binding` rule already treat as worth gating: a bad column
299
+ renders blanks, a bad filter key is sent to the engine and matches nothing (an
300
+ empty list indistinguishable from a true zero), a bad gantt start date leaves a
301
+ blank chart, a bad kanban group-by collapses every card into the uncolumned
302
+ bucket. The platform already shipped the *harder* half of this check —
303
+ `view/layout-without-binding` warns when a binding block is **absent**; a block
304
+ that is present but points at a field that does not exist reaches the identical
305
+ end state and got nothing.
306
+
307
+ The new rule `list-view-field-unknown` (`validateListViewFieldRefs`, a member of
308
+ the reference-integrity suite, so it runs on `validate` / `lint` / `compile` and
309
+ on `view` per-write publish snapshots) resolves every field-naming position on a
310
+ list view against the object graph:
311
+
312
+ - `columns[]` (bare-string and `{ field }` forms, plus `summary.field` and
313
+ `prefix.field`), `filter[]` keys, `tabs[].filter[]` keys, `grouping.fields[]`,
314
+ `rowColor.field`, `userFilters.fields[]`, `userFilters.tabs[].filter[]` keys,
315
+ `filterableFields[]`, `hiddenFields[]`, `fieldOrder[]`;
316
+ - every field binding inside the `kanban`, `calendar`, `gantt`, `timeline`,
317
+ `gallery`, `map` and `tree` blocks.
318
+
319
+ `sort[]` and `searchableFields[]` are deliberately untouched — they already have
320
+ owners (`sort-field-unknown` #9257, `searchable-field-unknown` #6674/#4830),
321
+ each with a runtime-admissibility verdict on top of existence.
322
+
323
+ Two severity tiers, the `validateFlowTemplatePaths` precedent: `error` where the
324
+ miss changes the data the view returns or collapses the layout it configures
325
+ (every position in the card's measured table), `warning` where the renderer
326
+ drops one decoration and renders the rest (optional colour/title/tooltip/cover
327
+ bindings, a stale `hiddenFields` or `fieldOrder` entry).
328
+
329
+ Resolution goes through the shared `object-graph.ts` seam (#14105/#14148) — no
330
+ second field-resolution implementation — and judges the **head segment** of a
331
+ dotted reference rather than walking relationship hops: a list view compiles no
332
+ joins, and all three query axes it reaches refuse a dotted path by name
333
+ (`assertProjectionFieldsExist` #7532 / `assertProjectionHasNoDottedPaths` #7589,
334
+ the #8371 dotted filter door, `assertSortFieldsExist` #6994). This is strictly
335
+ wider than "skip dotted paths": `ownr.name` is now reported, where a skip would
336
+ have passed it.
337
+
338
+ **Migration.** A list view refused by the new rule names a field the bound
339
+ object does not have: correct the spelling (the finding carries a "did you mean"
340
+ and the object's field list) or drop the entry. The three standard skips apply —
341
+ an object this stack does not define, an object with no readable field map
342
+ (ADR-0015 `external`), and registry-injected system columns — plus a fourth on
343
+ this surface: a list view whose `data.provider` is not `object`.
344
+ - a39b02a: Resolve an app navigation entry's `viewName` against its object's list views at `validate` and `build`
345
+
346
+ `AppNavigationItemSchema.viewName` is documented as *"Default list view to open"*, so an
347
+ unresolvable name never failed — it **fell back**. A nav entry keeping its authored label and
348
+ icon would open a different view, and nothing said so: `os validate --json` reported
349
+ `valid: true` and `os build` was green. The decay mode was worse than the typo mode — renaming
350
+ a list view silently degraded every nav entry pointing at it, with every gate green and the
351
+ diff reading correctly in review.
352
+
353
+ `lintViewRefs` now walks `app.navigation` (and the `areas[]` container) recursively and reports
354
+ `view-ref-nav-view-missing` as an **error** when a `viewName` resolves to no list view on the
355
+ object it names. This extends #2554's existing rule to the second, more travelled door into the
356
+ same `listViews` namespace rather than adding a new rule class.
357
+
358
+ Resolution mirrors the runtime matcher (objectui's `resolveViewId`) in all three directions —
359
+ exact id, short name retried as `<object>.<name>`, and qualified name with the prefix stripped —
360
+ so a name that works at runtime is never reported. The accept set narrows only where the stack
361
+ itself declares the object's list views: an entry is skipped when the `viewName` is interpolated,
362
+ when `recordId` is set (the schema documents `viewName` as ignored there), when the item carries
363
+ `requiresObject`, or when this stack contributed no list view for that object.
364
+ - 225e769: Author-time rejection for unknown `PageComponentSchema.type` strings inside the spec's own namespaces — the type-vocabulary half of the "Component Placeholder" gap.
365
+
366
+ `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.
367
+
368
+ - `@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.
369
+ - `@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.
370
+
371
+ 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.
372
+ - e38da2b: feat(lint): resolve an interface page's whitelisted visualizations at validate/build (#14073)
373
+
374
+ Accept-set narrowing, `minor` under the family precedent (#14107, #14105, #14148).
375
+
376
+ An interface `list` page whitelists renderers with
377
+ `interfaceConfig.appearance.allowedVisualizations`, and `InterfacePageConfigSchema`
378
+ is a closed shape with **no per-visualization binding key at all** — no
379
+ `calendar:`, no `kanban:`, no `map:`. So #13817's parse-time refinement, which
380
+ demands a `calendar:` block on a list VIEW that whitelists `calendar`, was
381
+ correctly not extended to this door: a requirement the page surface cannot
382
+ satisfy would be unauthorable.
383
+
384
+ That left the page door with no check of any kind. Measured on objectui
385
+ `f0f774b0` (after objectui#7029 removed the invented `due_date` default), the
386
+ renderer derives each binding from the source object's fields
387
+ (`InterfaceListPage.tsx`, `view.<viz> ?? deriveFromObject(objectDef)`), and when
388
+ nothing derives there are exactly two outcomes, neither of which reaches the
389
+ author:
390
+
391
+ - the entry LEADS the whitelist — it becomes the page's forced view type, is
392
+ force-pushed into the switcher's resolvable set, and every visitor lands on
393
+ the renderer's "Calendar configuration required" refusal screen;
394
+ - the entry is anywhere else — it is filtered out of the switcher **silently**,
395
+ while the switcher chrome still appears (it is shown on whitelist length).
396
+
397
+ The new rule `page/visualization-without-binding`
398
+ (`validatePageVisualizationBindings`, a member of the reference-integrity suite,
399
+ so it runs on `validate` / `lint` / `compile`) asks the renderer's own question
400
+ at authoring time. For every `list` page, each whitelisted visualization must be
401
+ either derivable from the source object's declared fields — using the SAME
402
+ predicates the renderer applies, the field TYPE first and then the NAME regex
403
+ fallback (kanban: select-like type or status-like name; calendar and timeline: a
404
+ date-typed non-hidden non-system field, else a date-like name; gallery:
405
+ image-typed, no name leg; gantt: two distinct date fields; map: location-typed or
406
+ a geo-like name) — or bound by the block of the list view the page references
407
+ through `sourceView` (a `calendar:` block also binds a `timeline`, which is what
408
+ `resolveTimelineDateBinding` accepts). `grid` always passes.
409
+
410
+ Severity tracks what the visitor sees: **`error`** when the unbound entry is
411
+ `allowedVisualizations[0]`, **`warning`** otherwise. Every message names
412
+ `sourceView` as the remedy, because on this door it is the one schema-legal
413
+ channel for a per-visualization binding — exactly how the shipped showcase map
414
+ page binds its `locationField`.
415
+
416
+ Deliberately NOT stricter than the renderer: mirroring both predicates rather
417
+ than the type half alone means a page whose only date is a text field called
418
+ `due_date` still passes, because it still renders. The mirrored table is exported
419
+ (`OBJECTUI_DERIVATION_PREDICATES`) and pinned verbatim by a fixture test, and the
420
+ seven shipped `showcase_task` interface pages are pinned as a live regression
421
+ corpus — the two halves catch drift on either side of a mirror no build edge
422
+ connects. `chart` and `tree` get no verdict: the renderer derives no binding for
423
+ them on this seam, so the rule says nothing rather than guessing.
424
+
425
+ **Migration.** A page reported by the new rule whitelists a visualization that
426
+ renders nothing: point the page at a list view that declares the block
427
+ (`interfaceConfig.sourceView`), give the source object a field the derivation can
428
+ find, or drop the entry from `appearance.allowedVisualizations`. Four skips keep
429
+ it quiet where it cannot know: a page that is not `type: 'list'`, an object this
430
+ stack does not define, an object with no readable field map (ADR-0015
431
+ `external`), and a `sourceView` naming a view this stack does not declare (the
432
+ runtime hydrates stored view bodies over the network).
433
+
434
+ No `packages/spec` change — the page surface stays closed, which is what ADR-0047
435
+ §7 open question 3 asks for.
436
+ - 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 accepts either spelling as satisfying `<ListView>`'s required binding and resolves field-name props (`columns`, `searchableFields`, filter positions, …) against the object bound by whichever spelling is present, canonical winning when both are. `<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 is a later card after the deprecation window.
437
+ - 46b53a2: Add `validateReadonlyActionWrites` — an author-time warning on an action body writing a `readonlyWhen` field through `ctx.api`.
438
+
439
+ 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:
440
+
441
+ | channel | static `readonly` | `readonlyWhen`, predicate TRUE |
442
+ | --- | --- | --- |
443
+ | action body `ctx.api` | lands | **stripped** |
444
+ | hook body `ctx.api`, non-system trigger | stripped | stripped |
445
+ | `ctx.api.sudo()` | lands | **stripped** |
446
+
447
+ So exactly one shape is a silent no-op on this surface, and that is what the new rule reports:
448
+
449
+ - `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).
450
+
451
+ 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.
452
+
453
+ 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).
454
+
455
+ `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).
456
+ - 36d2878: Add `validateReadonlyHookWrites` — an author-time gate on a hook body writing a `readonly` field through `ctx.api`.
457
+
458
+ 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`.
459
+
460
+ Two new rule ids, wired through `REFERENCE_INTEGRITY_RULES` so they run on `os validate`, `os lint` and `os compile`:
461
+
462
+ - `hook-api-update-readonly-field` — **error**. A literal `ctx.api.object('…').update()` / `.updateById()` writing a field the named object declares `readonly: true`.
463
+ - `hook-api-update-readonly-when-field` — **warning**. The same write against a `readonlyWhen` field, which strips per record state.
464
+
465
+ 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).
466
+ - 39404f3: feat(spec,lint): a layout section can reference a declared field group instead of copying its members (#13855)
467
+
468
+ Additive accept widening. Maintainer ruling 2026-08-31 (option B on #13855):
469
+ 「直接处理b」.
470
+
471
+ ADR-0085 makes `fieldGroups` + `Field.group` the canonical grouping, assembled in
472
+ one place — `deriveFieldGroupLayout` (ADR-0085 §5). The two layout escape hatches
473
+ were whole-takeover shapes with no way back to it: a custom record page's
474
+ `record:details` `properties.sections` and a view-level `form.sections` each
475
+ enumerated their members by hand, so an author who reached for either had to
476
+ hand-copy the same membership fact a second and third time. Nothing linked the
477
+ copies to the declaration, so every field added to the object afterwards made
478
+ them quietly staler — measured on a real app as three disagreeing groupings of
479
+ one object, with the detail page missing two fields the form showed.
480
+
481
+ **A section may now name the group instead.** On both surfaces:
482
+
483
+ ```ts
484
+ sections: [
485
+ { group: 'contact_info' }, // members + presentation derived
486
+ { label: 'Notes', fields: ['note'] }, // enumerated, unchanged
487
+ ]
488
+ ```
489
+
490
+ Members (every visible field whose `Field.group` points at the key, in
491
+ field-declaration order) and the group's own presentation (label, icon,
492
+ description, `collapse`, `visibleWhen`, and the drop when a group has no visible
493
+ members) all come from `deriveFieldGroupLayout`. Nothing is re-implemented in
494
+ section land.
495
+
496
+ **The mixing rule**, declared once for both surfaces and pinned:
497
+
498
+ - `group` and `fields` are mutually exclusive; a section declaring neither is
499
+ refused (before this change it was unrepresentable, because `fields` was
500
+ required).
501
+ - A group-referencing section carries no key the group already declares —
502
+ `name`, `label`, `icon`/`description`, the collapse pair, `visibleWhen` (and
503
+ its deprecated `visibleOn` spelling) are refused beside `group`, each with the
504
+ pointer to the `fieldGroups` entry that owns it. Not a precedence rule: the
505
+ absence of one. The surface keys the group says nothing about — `columns`,
506
+ `pane`, `hideEmpty`, `showBorder`, `headerColor` — ride alongside as usual.
507
+ - Across sections, both kinds coexist in declared array order; a
508
+ group-referencing section occupies one slot and expands in place.
509
+ - ⛔ Not on a wizard step: a group carries `visibleWhen` and `collapse`, and a
510
+ wizard step has no slot for either (the #13704 refusals, reached through the
511
+ object's declaration instead of the step's own keys).
512
+
513
+ **Existence is checked by reference diagnostics, not at parse.** The key names
514
+ something on a different schema, so the spec door takes any well-formed
515
+ snake_case key — the `UserFilterFieldSchema.field` precedent. `@objectstack/lint`
516
+ reports a dangling one as `page-section-group-unknown` (`record:details`) or
517
+ `form-section-group-unknown` (form views, both the canonical `sections` and the
518
+ legacy `groups` bucket), advisory like every other dangling-reference finding in
519
+ that family, with the object's declared groups listed in the hint.
520
+
521
+ The key grammar is now single-sourced as `FIELD_GROUP_KEY_PATTERN` beside the
522
+ derivation, so the declaring surface (`ObjectFieldGroupSchema.key`) and the two
523
+ referencing surfaces cannot drift into accepting different keys.
524
+
525
+ **Type-surface note for consumers.** `fields` becomes optional on both section
526
+ shapes (that is what makes `group` the other way to declare the same fact), so
527
+ `z.infer` now types it `… | undefined`. A consumer that reads `section.fields`
528
+ unconditionally must handle the reference form; every in-repo reader already
529
+ guards it. No authored metadata changes shape, and nothing that parsed before
530
+ stops parsing — `fields: []` included.
531
+
532
+ The renderer half (objectui) is tracked separately; until it lands, a
533
+ group-referencing section is declared and diagnosed but not yet rendered.
534
+ - 06ee8bf: Warn when a bare identifier in a flow node/edge condition is shadowed by a declared flow variable
535
+
536
+ A flow `condition` is evaluated in a flattened scope, so a bare `status` normally
537
+ resolves to the trigger record's field and is the correct, canon-taught spelling.
538
+ `objectstack validate` deliberately never judged a bare identifier there, and it
539
+ still does not — with one exception it now names.
540
+
541
+ When the same name is BOTH a declared flow variable and a field on the bound
542
+ object, the two collide silently: a run seeds its declared variables first and
543
+ flattens the record's fields only where nothing is bound yet, so the variable
544
+ wins, the field is unreachable under its own name, and nothing anywhere reports
545
+ it. The author reads `status` and gets the variable. On this surface that is the
546
+ least visible failure there is — a flow condition that never fires produces no
547
+ record, no error and no log line.
548
+
549
+ `validateStackExpressions` now emits a `warning` (never an error) on exactly that
550
+ case, naming the mechanism and both repairs: `record.status` for the field, or
551
+ rename the variable. A bare name that is only a field, or only a variable, stays
552
+ silent as before.
553
+
554
+ The variable set is collected across every ADR-0031 region of the flow, since a
555
+ run holds one variable map: flow-level declarations, loop/map iterator and index
556
+ variables, the try/catch error variable, node output variables, assignment
557
+ targets in all three shapes the executor accepts (including a legacy assignment
558
+ node with no `assignments` wrapper, whose top-level config keys are the variable
559
+ names), and node ids, which are bare CEL roots at runtime.
560
+ - d23dc08: feat(spec,lint,metadata-protocol): a `page` member on the `view` type enum — mount an already-published page on an object view (#13216)
561
+
562
+ A custom page created and published at runtime through the metadata API had no
563
+ in-protocol way to reach an end user (#13100's evidence map). App navigation is
564
+ closed to runtime content (`app.allowOrgOverride: false`), and the `view` `type`
565
+ enum — on one of the five types the platform deliberately leaves open
566
+ (`allowOrgOverride: true`, `allowRuntimeCreate: true`) — was closed over
567
+ declarative row renderers, so a published page could not be mounted as an
568
+ object's list view or tab.
569
+
570
+ Maintainer ruling 2026-08-29 (live director session, verbatim 「同意」), 方向 1:
571
+
572
+ > `view` 的 `type` 枚举新增 `page` 成员——对象的列表视图/标签页可挂载一个已发布页面。走平台**有意开着**的门(`view` 本就 `allowOrgOverride=true` + 运行时可创建),零新增授权面;设计要点:`page` 型 view 需声明 `pageName` 绑定,校验目标页面存在,渲染委托既有页面渲染器
573
+
574
+ **Zero new authorization surface, as the ruling's basis requires.** Nothing in
575
+ this change touches a metadata type's `allowOrgOverride` / `allowRuntimeCreate`
576
+ flags, adds a write door, or adds a read door. A `page` view is a `view` written
577
+ through the door `view` already opens, and it holds a NAME — the page itself is
578
+ still fetched through the page read path it already had, and still renders
579
+ through the existing page renderer, so the page's own audience gate
580
+ (`page.assignedProfiles`) rides along unchanged. Delegation is what preserves
581
+ that: a second renderer is what would have introduced a second gate.
582
+
583
+ **The binding, refused in both directions at parse.** `ListViewSchema` gains
584
+ `pageName`, declared with `SnakeCaseIdentifierSchema` — the same grammar
585
+ `PageSchema.name` carries, so the accepted set is exactly the set of strings that
586
+ could name a page. `checkListViewPageMount` then refuses:
587
+
588
+ - `type: 'page'` with no `pageName` — unlike every other view type there is no
589
+ degraded rendering to fall back to, so the view would be blank;
590
+ - `pageName` on any other view type — the accepted-and-ignored shape;
591
+ - a non-empty `columns` beside a page mount — `columns` is the one required key
592
+ on a list view, and the only truthful value for a page mount is `[]`.
593
+
594
+ The check is attached at all three list-view doors (`ListViewSchema`,
595
+ `ObjectListViewSchema`, and the flattened runtime overlay behind
596
+ `PUT /api/v1/meta/view`), with a pinned test that fails if any attachment is
597
+ dropped.
598
+
599
+ **Existence of the target page** is answered where the collection is visible:
600
+ `defineStack`'s `validateCrossReferences` refuses at build time (same
601
+ `pageNames.size > 0` policy the two other page references in that function
602
+ already use), and the new `@objectstack/lint` rule `view-page-unresolved`
603
+ (`validateViewPageRefs`) resolves it on `os validate` / `os lint` / `os compile`
604
+ **and** at the runtime publish gate. Advisory, not gating, for its nav twin's
605
+ reason: with no curated cross-package page registry, "unresolved here" cannot be
606
+ told apart from "provided by a package this stack cannot see".
607
+
608
+ Reaching the runtime publish gate needed the per-write snapshot to carry the
609
+ `pages` collection (`RuntimeStackContext.pages`, threaded through
610
+ `evaluateRuntimeAuthoringGate` and read off the live registry in
611
+ `saveMetaItem`'s gate call). That is the one-key widening `RuntimeStackContext`
612
+ documents, made when a rule that reads the collection crossed the wall — never
613
+ in advance — and the false-positive channel it closes is measured both ways in
614
+ `runtime-gate.view-page-refs.test.ts`. `pages` joined `NAME_KEYED_STACK_KEYS` in
615
+ the same edit, because a collection that is both context-filled and
616
+ write-targeted must have its finding paths name-keyed (#10064).
617
+
618
+ **Downstream note (not an accept-set narrowing).** No previously valid metadata
619
+ becomes invalid: `pageName` is a new key and `page` a new enum member, so every
620
+ refusal above can only fire on a document that could not be written before.
621
+ What does change for a downstream schema author is composition: `ListViewSchema`
622
+ now carries a refinement, and zod 4 refuses `.omit()` / key-overwriting
623
+ `.extend()` on a refined object. The unrefined shape stays module-private
624
+ (publishing it would mint a duplicate protocol def and a second full set of
625
+ ratcheted authorable-surface keys), so a consumer that derived from
626
+ `ListViewSchema` by omission should compose with `.safeExtend()` or narrow after
627
+ parsing. `FormViewSchema` has had this property since its own refinement landed,
628
+ so this is the established shape for view schemas rather than a new one.
629
+
630
+ **Deliberately out of scope**, per the same ruling: 方向 2 (registering app
631
+ navigation at publish time) is deferred to its own design card — it would
632
+ require reversing the `app.allowOrgOverride: false` authorization decision — and
633
+ with it the known limitation the ruling accepts on the record, that a page
634
+ belonging to no object still has no browse-to entry. `page` is also NOT added to
635
+ `VisualizationTypeSchema`: the switcher offers alternative ways to draw the same
636
+ rows, and a page draws none.
637
+ - 038f333: feat(lint,formula): refuse a visibility predicate that calls a function the CEL environment does not register (#13594)
638
+
639
+ An accept-set narrowing at `objectstack validate` and at the runtime publish
640
+ door, ruled by the maintainer on 2026-08-31 (director batch #21) on a censused
641
+ premise.
642
+
643
+ **The hole.** `validate-visibility-predicates` — the gate that judges
644
+ `visibleWhen` on view form sections/fields and page components — was
645
+ deliberately parse-only. So a predicate that parses perfectly and calls a
646
+ function that does not exist passed CLEAN, measured side by side with two
647
+ controls that fired:
648
+
649
+ ```text
650
+ source lint gate (before) validateExpression
651
+ totallyBogusFn(1,2) CLEAN ok=false
652
+ record.x.nosuchmethod('a') CLEAN ok=false
653
+ country === "USA" syntax ok=false <- control
654
+ status == 'active' bare-identifier ok=true <- control
655
+ ```
656
+
657
+ The runtime fault it hides is the worst-shaped one the platform has: on a view
658
+ or page surface it falls OPEN (the element renders unconditionally, identical
659
+ to carrying no predicate at all), and on an action surface — evaluated with
660
+ `throwOnError: true` — it falls CLOSED, so the action disappears for every
661
+ user *including one who holds the grant*, behind a single deduped
662
+ `console.warn` (objectui#4421). A plausible-looking function name that does not
663
+ exist is exactly what a generator invents.
664
+
665
+ **What changed.**
666
+
667
+ - `@objectstack/formula` publishes `firstUnknownFunctionCall(source)` — the
668
+ function-EXISTENCE verdict, isolated from everything else cel-js's `check()`
669
+ has an opinion about. The oracle is the evaluation environment's own
670
+ registration set, read through the same `buildEnv` seam `celEngine.compile`
671
+ and `celEngine.evaluate` build with — never the advertised
672
+ `CEL_STDLIB_FUNCTIONS` catalog, which lists 35 of the 72 registered names and
673
+ would have refused 37 functions that resolve and evaluate today (`type`,
674
+ `map`, `filter`, `split`, `getFullYear`, `json`, …).
675
+ - `@objectstack/lint` gains `visibility-predicate-unknown-function`
676
+ (**error**), covering both call forms — global (`totallyBogusFn(1,2)`) and
677
+ receiver/member (`record.x.nosuchmethod('a')`). The message quotes the
678
+ engine's own `found no matching overload for '…'` verbatim so publish time
679
+ and run time read as one system, and offers **no** "did you mean" suggestion:
680
+ nearest-name matching over the function namespace was measured to answer
681
+ `min` for `can`.
682
+
683
+ **Scoped supersession, not a widening.** The module's parse-only ruling stands
684
+ for everything except function existence. A registered name called with wrong
685
+ arguments (`upper(1, 2)`), a registered name called in the wrong position
686
+ (bare `split('a,b')`), the CEL-type blind spot (`type == 'grid'`) and every
687
+ operator-overload fault (`1 + 'a'`) are all still unreported — `type(record.x)
688
+ == string` and every other legal `dyn` predicate is untouched. Refusing an
689
+ unregistered call cannot be a false positive: the validation and runtime
690
+ environments are the same builder (53 probes, 0 divergence), so the call this
691
+ refuses is a call that would have faulted.
692
+
693
+ **Migration.** A refused predicate names a function that does not exist and
694
+ never evaluated — replace it with an advertised callable, or precompute the
695
+ value into a formula field on the object and test that field. The census found
696
+ **0** host-registered extra CEL functions across the reachable corpus
697
+ (objectstack `packages/`/`examples/`/`apps/`, objectui, one shipped host app),
698
+ with a firing positive control, and this repo's `examples/**` and `apps/**`
699
+ sweep produces **0** new refusals. `cloud`, `objectos` and published
700
+ third-party apps were NOT MEASURED — if a host in one of those registers extra
701
+ CEL functions, its predicates are refused; that gap was declared before the
702
+ ruling and accepted with it.
703
+ - fa1eca3: Resolve a dashboard widget's OWN `filter` keys and `options.sortBy` at validate/build
704
+
705
+ A dashboard widget could filter by a column that does not exist, and order by a name
706
+ it never selected, and `objectstack validate` exited 0 with "Validation passed";
707
+ `build` — the publish gate — wrote the dashboard into `dist/objectstack.json`. The
708
+ widget then rendered **empty**.
709
+
710
+ The surrounding surface was already covered, which is what made the two misses so
711
+ narrow: `widget-dataset-unknown`, `widget-dimension-unknown`, `widget-measure-unknown`,
712
+ `filter-token-unknown` and `dashboard-filter-field-unknown` all failed both gates on the
713
+ same dashboard. On the very same node, the filter TOKEN was checked and the filter
714
+ COLUMN was not — `filter-token-unknown` fires path-precise at
715
+ `…widgets[4].filter.due_date.$lte`, so the traversal already walked the filter tree and
716
+ already knew the widget's dataset. Only the key resolution was missing. And
717
+ `options.sortBy` was declared-≠-enforced in the plainest way available: the spec states
718
+ the contract in its own prose — *"must be one this widget actually selects"* — and
719
+ nothing enforced it.
720
+
721
+ Why this class of miss is expensive rather than untidy, in the reporter's words: the
722
+ dashboard it was measured on leads with a "not moving" tile — open work untouched more
723
+ than 14 days — and *"an empty tile is indistinguishable from a healthy team: a missing
724
+ number reads as zero, and zero is the answer the manager is hoping for."* The failure is
725
+ silent in the direction the reader wants to believe.
726
+
727
+ Three gating rule ids, all at the site that already emits `widget-dataset-unknown` /
728
+ `dashboard-filter-field-unknown`, and all failing **`validate` and `build`** (pinned
729
+ end-to-end, not inferred from the registry entry):
730
+
731
+ - `widget-filter-field-unknown` — a key of the widget's own `filter` resolves to no
732
+ column on the bound dataset's object graph. Reported path-precise at
733
+ `dashboards[i].widgets[j].filter.<key>`, matching `filter-token-unknown`'s precision in
734
+ that same subtree.
735
+ - `widget-filter-field-not-included` — the key resolves, but its relationship prefix is
736
+ not declared in the dataset's `include`, so ADR-0021 compiles no join and the column is
737
+ out of the query's reach.
738
+ - `widget-sortby-unselected` — `options.sortBy` names neither a `dimensions[]` nor a
739
+ `values[]` entry of the widget. A name the dataset declares but the widget did not
740
+ select gets its own message, because the fix is a selection rather than a spelling.
741
+
742
+ **A dotted path through a declared `include` is RESOLVED, not skipped.** A widget's
743
+ `filter` is ANDed into the dataset query as `runtimeFilter`, and that compiled query
744
+ carries only the joins `include` declared — so the same two clauses the dataset rule
745
+ applies one level down (existence, then joinability) apply here. The runtime is not a
746
+ backstop for the second: the dataset compiler's `assertDeclared` runs over `dimensions`
747
+ and `measures` only, never over `runtimeFilter`.
748
+
749
+ Built on the seams that shipped with the dataset-level sibling rather than a second
750
+ implementation: `walkFilterFieldKeys` (all three authored filter shapes) and
751
+ `indexObjectGraph` / `resolveFieldPath`. Two helpers that were local to that rule —
752
+ `joinablePrefixes` and `describeFieldPathVerdict` — moved into the shared seam and are
753
+ now exported, because both are answers this position asks identically and copying either
754
+ would have been the second implementation the seam exists to prevent.
755
+
756
+ Minor rather than patch: this narrows the accept set. Metadata that built yesterday and
757
+ names a column or an order that does not exist now fails the build — which is the point.
758
+ The three skips every field-existence rule in this package takes are unchanged, so an
759
+ object the stack does not define, an ADR-0015 `external` object with no readable field
760
+ map, and a registry-injected system column are never reported.
761
+
762
+ ### Patch Changes
763
+
764
+ - e5ed943: fix(lint): guard `collectBare`'s recursion so a self-referential page terminates instead of killing the stack (#13235)
765
+
766
+ `page-envelope-audit`'s `collectBare` is a lockstep raw/parsed value walker,
767
+ separate from the shared `walkPageComponents` traversal, and it carried no cycle
768
+ guard. A page whose component tree contains itself (`A -> B -> A` through
769
+ `properties.children`) is input the schema **admits** — `properties` is
770
+ `z.record(z.unknown())` and `properties.children` is `z.array(z.unknown())`, so
771
+ `PageSchema.safeParse` succeeds — and door 1 then recursed until
772
+ `RangeError: Maximum call stack size exceeded`. Door 1 runs over the whole page
773
+ before any other door, so this is the first thing that died on such a page.
774
+
775
+ The guard is an **ancestor set on the authored side**: objects are added on
776
+ entry to the descent and removed on exit, so a node is skipped only when it is
777
+ its own ancestor. Two consequences are pinned by tests:
778
+
779
+ - **Report-neutral on acyclic input, by construction.** No node is ever its own
780
+ ancestor on an acyclic page, so the guard never fires and findings are
781
+ unchanged. A visited-set would instead have skipped merely *shared* subtrees —
782
+ the same component literal referenced from two slots is legal authoring — and
783
+ silently dropped their findings.
784
+ - **Nothing distinct is lost on a cyclic page.** Every node of a finite graph is
785
+ reachable by a simple path, so each authored position is still visited; only
786
+ the infinite tail of re-reports at ever-longer paths is dropped.
787
+
788
+ Cycles through arrays are covered as well as cycles through records.
789
+
790
+ Scope: this is door 1 only. `walkPageComponents` carries its own separate
791
+ unguarded recursion, so `auditPageExpressionEnvelopes` end-to-end still dies at
792
+ the walk on the same input until that lands (#13217). No published type changes
793
+ and no accept/reject behaviour changes on any page that parses today.
794
+ - 834da6f: lint: `dashboard-filter-field-unknown` resolves dotted dashboard-filter fields on the object graph, and answers system columns per object
795
+
796
+ A dashboard-level filter (`dateRange`, or a `globalFilters[]` entry) is ANDed into
797
+ **every** widget's analytics query, so its effective field — after any
798
+ `filterBindings` re-target — has to resolve on each bound widget's dataset object.
799
+ The rule that enforces that shipped with two holes, and this closes both by
800
+ migrating the check onto the shared `resolveFieldPath` / `joinablePrefixes` seam
801
+ the widget's own `filter` keys already use one position over.
802
+
803
+ - **Dotted paths are no longer skipped.** The branch carried
804
+ `if (field.includes('.')) continue;`, accurate when nothing in the package could
805
+ walk relationship hops and false since the object-graph seam landed. A filter
806
+ re-targeted to `account.signed_at` was unjudged whether or not `account` existed,
807
+ whether or not `signed_at` existed on it, and whether or not `account` was
808
+ declared in the dataset's `include`. It is now walked hop by hop, and a miss
809
+ names **which** hop failed.
810
+ - **System columns are resolved per object, not through the flat union.** The old
811
+ test was `objectFields.has(field) || SYSTEM_FIELDS.has(field)`, which answers
812
+ "could this be a system column *anywhere*". On an `ownership: 'none'` object the
813
+ platform injects no `owner_id`, and on `systemFields: { audit: false }` no
814
+ `created_at` — both were answered as resolvable and are now reported.
815
+
816
+ New error id **`dashboard-filter-field-not-included`**: the effective field
817
+ resolves, but its relationship prefix is not declared in the bound dataset's
818
+ `include`, so ADR-0021 compiles no join and the column is out of the broadcast
819
+ query's reach. It mirrors `widget-filter-field-not-included` one level down, and is
820
+ its own id because the fix is a different edit (declare the join, versus point the
821
+ filter at something real).
822
+
823
+ This **narrows the accept set of a shipped gating rule**. Both new answers are
824
+ error-tier, so like the rule's other errors they fail `os validate` / `os build`
825
+ and the runtime publish gate for `dashboard` writes. Measured over the shipped
826
+ dashboard corpus — the three example apps plus the platform's own
827
+ `system_overview` — the change is 0 findings before and 0 after; the
828
+ `dashboard-filter-field-unprovisioned` warning is unchanged and now travels with
829
+ the verdict, so it answers a dotted path landing on an ADR-0015 `external` object
830
+ too.
831
+ - 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)
832
+
833
+ `skills/objectstack-ai` tells authors that `data_chat` and `metadata_assistant`
834
+ "are **not** vocabulary — always write `ask` / `build`". The platform then
835
+ taught the opposite from every live example it ships. Nothing was broken at
836
+ runtime; what was wrong is what an author copies.
837
+
838
+ **Studio's pin.** `studio.app.ts` was the repo's ONLY `app.defaultAgent` usage,
839
+ and it spelled the alias:
840
+
841
+ ```
842
+ - defaultAgent: 'metadata_assistant',
843
+ + defaultAgent: 'build',
844
+ ```
845
+
846
+ The triage card left this undecidable — if the cloud plugin registered the
847
+ agent under the legacy id, re-pinning would be a behaviour change in a
848
+ consumer this repo cannot see. Measured instead of assumed, at `cloud`
849
+ `main@3856fbf7`: `service-ai-studio/src/agents/metadata-assistant-agent.ts:12,40`
850
+ ships the record as `name: BUILD_AGENT_NAME` = `'build'`, and `plugin.ts:58`
851
+ registers `metadata_assistant` as a **one-way, resolution-only** legacy alias.
852
+ The canonical id *is* `build`; the old pin reached it by detour.
853
+
854
+ Nor is the re-pin cosmetic. Alias resolution depends on an in-memory
855
+ `registerAgentAlias` call having run at plugin init, and cloud carries two
856
+ defensive docblocks about that registration silently no-op'ing for real under
857
+ bundle load ordering (`service-ai-studio/src/plugin.ts:44-57`,
858
+ `service-ai/src/agent-runtime.ts:30-41` — "a missed alias must never hide a
859
+ real platform agent like `build`"). The canonical id never touches the alias
860
+ table, so this drops a load-order dependency from the platform's own flagship
861
+ authoring surface. On the UI side nothing moves: `objectui`'s
862
+ `AGENT_ALIAS_GROUPS` is bidirectional and canonical-first, and
863
+ `SURFACE_DEFAULT['studio-build']` was already `'build'`.
864
+
865
+ **The MCP prompt example.** `mcp-server-runtime.ts`'s `agent_prompt` argument
866
+ described itself as `'Name of the agent to load (e.g. "data_chat",
867
+ "metadata_assistant")'` — two retired aliases, neither canonical id present.
868
+ That string is served to every MCP client asking what to pass, so the one
869
+ surface that suggests a spelling to an LLM suggested the two the catalogue
870
+ forbids. Now `(e.g. "ask", "build")`.
871
+
872
+ **The lint's value roster.** `validate-ai-agent-authoring`'s `defaultAgent`
873
+ **value** limb reused the four-name `PLATFORM_AGENT_NAMES` set, so it
874
+ deliberately passed `metadata_assistant` — the gate that exists to make
875
+ authoring mistakes loud waved through the exact spelling the catalogue bans,
876
+ which is the silent-tolerance shape ADR-0078 exists to close, committed by the
877
+ gate itself. The two limbs now read different tables, because they ask
878
+ different questions:
879
+
880
+ - **declaration limb** — unchanged, still all four names. Declaring
881
+ `metadata_assistant` shadows the `build` record through the alias exactly as
882
+ declaring `build` does.
883
+ - **value limb** — canonical `ask` / `build` only. A legacy alias gets its own
884
+ rule id `default-agent-legacy-alias` (exported) and its own wording, because
885
+ an alias **resolves** (the app gets the agent it meant — a spelling defect)
886
+ while an unknown name does **not** (the pin is inert). Describing the alias
887
+ as "no effect" would send an author hunting a bug that is not there.
888
+
889
+ Both of the #6041 ruling's operative decisions are kept intact: still
890
+ `warning` tier, still no Zod enum narrowing. `defaultAgent: 'metadata_assistant'`
891
+ keeps parsing, building, and resolving — the only change is that authoring it
892
+ now says so.
893
+
894
+ Not breaking: nothing an author can write was removed, and both aliases stay
895
+ resolvable for old bookmarks and persisted `agent_id`s, which is the only job
896
+ ADR-0063 §2 ever gave them.
897
+ - 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.
898
+
899
+ 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.
900
+
901
+ 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.
902
+
903
+ 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.
904
+
905
+ 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']`.
906
+
907
+ <!-- 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. -->
908
+ - 4301f78: refactor(lint): derive the runtime gate's name-keyed collection set instead of hand-listing it (#13390)
909
+
910
+ The set of collections the runtime publish gate carries in a per-write snapshot was
911
+ written down in five places, and `NAME_KEYED_STACK_KEYS` was the one with no guard of
912
+ any kind — `CONTEXT_STACK_KEYS` carries a `satisfies` clause, which is validity rather
913
+ than completeness, and the compiler held nothing else.
914
+
915
+ That list carries a real invariant: a collection the CONTEXT fills **and** that some
916
+ write type maps into must be name-keyed, or a finding's `path` is a positional index
917
+ into an in-memory snapshot the caller has never seen and cannot enumerate — the defect
918
+ #10064 fixed for `objects` / `permissions` / `books`. Omitting a member did not fail to
919
+ build, fail a test, or fail a gate; it produced correct-LOOKING findings with paths the
920
+ receiver cannot resolve. Adding the `pages` collection had to touch all five spellings
921
+ and only one of them announced itself.
922
+
923
+ `NAME_KEYED_STACK_KEYS` and the `TOP_LEVEL_INDEX` pattern built from it are now derived
924
+ from the two inputs that already state the answer: `CONTEXT_STACK_KEYS` intersected with
925
+ the values of `TYPE_TO_STACK_KEY`. The intersection was measured against the list it
926
+ replaces before anything changed — same four members (`objects`, `permissions`, `books`,
927
+ `pages`) in the same order, and `datasets` excluded on its own because no write type maps
928
+ into it, so no member needed a hand-written exception and none is kept.
929
+
930
+ Constructive preservation, not a tightening or a loosening: the derived pattern's `source`
931
+ is byte-identical to the literal it replaces, and the gate returns the same findings for
932
+ the same inputs. No published entry point changed — `@objectstack/lint` and
933
+ `@objectstack/lint/runtime` export exactly the names they did before.
934
+ - e7191ce: fix(build): give each `exports` condition its own `types` target in the 28 dual-build packages (#13112)
935
+
936
+ **Published-surface change, zero runtime change.** No emitted byte moves; what
937
+ moves is which declaration file a resolver READS. Maintainer ruling 2026-08-29
938
+ (decision batch #3, verbatim 「同意」) chose declaring the files over deleting
939
+ them.
940
+
941
+ ## What was wrong
942
+
943
+ These 28 packages are `"type": "module"` and dual-built, and each spelled one
944
+ `types` condition as a **sibling** of `import`/`require`:
945
+
946
+ ```json
947
+ "exports": { ".": {
948
+ "types": "./dist/index.d.ts", "import": "./dist/index.js", "require": "./dist/index.cjs"
949
+ } }
950
+ ```
951
+
952
+ A sibling `types` answers for **both** conditions, so a CommonJS consumer was
953
+ handed `dist/index.d.ts` — an ES-module declaration, because the package is
954
+ `"type": "module"` — for an entry point it reaches with `require`. Measured with
955
+ `tsc --traceResolution` on a `"type": "commonjs"` fixture at `moduleResolution:
956
+ node16`:
957
+
958
+ ```
959
+ error TS1479: The current file is a CommonJS module whose imports will produce
960
+ 'require' calls; however, the referenced file is an ECMAScript module and cannot
961
+ be imported with 'require'.
962
+ ```
963
+
964
+ The JavaScript at `dist/index.cjs` loads perfectly (`check:dual-build-cjs-loads`
965
+ has asserted that for months). It is the **types** that told the consumer the
966
+ supported `require` entry point could not be required. The `dist/index.d.cts`
967
+ twin tsup emits beside it — 36 files, 5,517,701 B on this build — was named by
968
+ no condition at all and shipped in every tarball unreachable.
969
+
970
+ ## What changed
971
+
972
+ Each condition now names its own declaration, the shape TypeScript documents:
973
+
974
+ ```json
975
+ "exports": { ".": {
976
+ "import": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
977
+ "require": { "types": "./dist/index.d.cts", "default": "./dist/index.cjs" }
978
+ } }
979
+ ```
980
+
981
+ 33 entry points across 27 packages, subpaths included. The root `types` field is
982
+ untouched, so `node10` resolvers are unaffected; the `import` condition resolves
983
+ exactly what it resolved before, measured as an unchanged control in the same
984
+ run.
985
+
986
+ ## `@objectstack/core` is deliberately NOT changed
987
+
988
+ Splitting a declaration in two makes TypeScript compare it nominally, and
989
+ `ObjectKernel` carries a `private plugins` member that reaches every plugin
990
+ through `PluginContext.getKernel()`. With core split, whole-repo `pnpm build`
991
+ fails in `@objectstack/verify` with 5 × TS2345 ("Types have separate
992
+ declarations of a private property 'plugins'"); with core held back and the
993
+ other 27 split, 71/71 tasks pass. So core keeps the sibling-`types` shape and
994
+ its two `.d.cts` files (220,854 B) stay unreachable, declared as such in
995
+ `check:dual-build-cjs-loads`. Splitting it needs a decision about core's public
996
+ types, not about an exports map.
997
+
998
+ ## For consumers
999
+
1000
+ - **ESM consumers: nothing changes.** Same declaration file, byte for byte.
1001
+ - **CJS consumers under `node16`/`nodenext`: TS1479 goes away** and the
1002
+ declarations they get are the ones built for CommonJS.
1003
+ - **`node10` / `moduleResolution: node` consumers: nothing changes** — they never
1004
+ read `exports`.
1005
+ - Nothing is removed: every path that resolved before still resolves.
1006
+
1007
+ Packages that are CJS-first (`require` → `./dist/index.js`, no `"type": "module"`)
1008
+ were already correct and are untouched — their `dist/index.d.ts` really is the
1009
+ CommonJS declaration. Their ESM mirror (an unreachable `.d.mts` under the
1010
+ `import` condition) is a separate, larger population and is filed separately per
1011
+ the ruling, not fixed here.
1012
+
1013
+ `check:dual-build-cjs-loads` grew a fourth invariant (TYPED) that reds on the old
1014
+ shape, so the drift cannot return silently.
1015
+ - 1fb6281: Walk object-nested `list` / `listViews.*` through the view completeness rules.
1016
+
1017
+ `validateFunctionalCompleteness` walked only the top-level `views[]` containers, so
1018
+ a `timeline` / `gantt` / `map` / `tree` view authored on the object itself — the
1019
+ ADR-0017 "Object has-many View" spelling that `objects[].list` and
1020
+ `objects[].listViews.*` carry — never reached `checkViewCompleteness`. Both doors
1021
+ register the same expanded view items and reach the same renderer, so `os validate`
1022
+ and `os build` were silent on exactly the half of the stack the sibling rules
1023
+ (`lint-view-refs`, `validate-list-view-field-refs`) already walk.
1024
+
1025
+ Both authorable object spellings (array-form and name-keyed map) are covered, and a
1026
+ list view's own `data.object` retarget (ADR-0047) resolves the bound object the same
1027
+ way it does on the top-level door — so `view/layout-without-binding` and
1028
+ `view/tree-without-parent-field` now reach the nested door by construction rather
1029
+ than by a second wiring step. Findings report as
1030
+ `object "<name>" › listViews.<key>` / `objects[<i>].listViews.<key>.<block>`.
1031
+ - 7345308: fix(lint): drop the `element:form` entry from `COMPONENT_FIELD_SPECS` (#9249)
1032
+
1033
+ The whole `element:form` element retired at element grain (ADR-0049 — no
1034
+ renderer ever shipped for it; the #9220 shape one element over), so every
1035
+ `ElementFormProps` key is a `retiredKey()` tombstone and no spec-conformant
1036
+ page carries `fields` on it. The field-binding rule's job (resolve a field
1037
+ NAME against the object) is not the question a retired key raises: an authored
1038
+ key is already reported by name with the element-retirement prescription —
1039
+ which names the live replacement, the object-bound `object-form` block —
1040
+ through the #5068 props gate, and the binding entry would only add a second
1041
+ finding about a key that no longer exists — the #5775/#6629 residue class the
1042
+ package's own `component-field-specs-liveness` gate refuses.
1043
+ - 8ed9c54: flows: warn on a `loop` body with a fallible node and no containment, and on a `try_catch` with no `catch` (#14394)
1044
+
1045
+ Two authoring-time rules in the flow anti-pattern family, both `warning`:
1046
+
1047
+ - **`flow-loop-body-uncontained`** — a `loop` whose `body` region runs a node
1048
+ that can end the run (a record read/write, `http`, `notify`,
1049
+ `connector_action`, `script`, `subflow`, `map`, `approval`) with no
1050
+ `try_catch` between the loop and that node. The `loop` executor iterates with
1051
+ a bare `await` and has no `try`/`catch` at all, so the first failing item ends
1052
+ the whole run: later items are never processed, and the work already done is
1053
+ not even reported. The finding names the loop, the node, and the prescribed
1054
+ spelling.
1055
+ - **`flow-try-catch-without-catch`** — the near-miss, and the first target
1056
+ rather than an extra: `catch` is optional in the schema, and omitting it makes
1057
+ the container fail through, so an author who wrapped the node and stopped
1058
+ there gets **zero** containment and previously got no diagnostic either.
1059
+ Measured, the no-`catch` run and the unwrapped control produce identical
1060
+ output; a `retry` policy only delays that.
1061
+
1062
+ Both stay warnings under the family's severity bar: a loop deliberately allowed
1063
+ to stop at the first failure, and a retry-then-fail `try_catch`, are legitimate
1064
+ readings the rule cannot disprove.
1065
+
1066
+ `content/docs/automation/flows.mdx` documents `loop { try_catch { … } }` as the
1067
+ per-iteration containment spelling, with the measured minimal handler — one bare
1068
+ `assignment` node, `edges` and `errorVariable` omitted — and the three `catch`
1069
+ spellings the schema refuses (`catch` omitted gives no containment; `catch: {}`
1070
+ and `catch: { nodes: [] }` are rejected, the region's `nodes` being `.min(1)`).
1071
+
1072
+ No spec, engine or runtime change: the containment capability already exists and
1073
+ was measured working (5 of 5 iterations, items 4-5 processed, run completes).
1074
+ - f887e52: Re-measure four stale `current_user` binding-text sites, including the form SECTION slot
1075
+
1076
+ The claim that `current_user` is unbound on a form-view **section** predicate was true when
1077
+ it was written and is not any more: the console form renderer threads the host shell's
1078
+ predicate scope into `isSectionVisible` (objectui#6110), and the object-view chain now
1079
+ carries an authored `section.visibleWhen` through to an evaluator via the `section-divider`
1080
+ pseudo-field (objectui#6111). Text only — no schema, no verdict and no runtime behaviour
1081
+ moves.
1082
+
1083
+ - `FormSectionSchema.visibleWhen` (`ui/view.zod.ts`) — the JSDoc and `describe()` now say the
1084
+ root resolves, carrying the two qualifications the field-slot text already carried: the
1085
+ binding is **client-side only** (no write-path evaluator reads a form-view section or field
1086
+ `visibleWhen` — the rule validator's list is field `readonlyWhen` / `requiredWhen` and
1087
+ per-option `visibleWhen`), and the scope is **empty on the public `/f/:slug` route**, which
1088
+ is mounted outside any provider on purpose. The `features.*` refusal sentence is unchanged:
1089
+ that root is unbound on both standalone form routes.
1090
+ - `SelectOptionSchema.visibleWhen` (`data/field.zod.ts`) and
1091
+ `SELECT_OPTION_EDITABILITY_GUIDANCE` (`shared/editability-boundary.ts`) — the retired
1092
+ exclusivity claim ("the one `*When` surface where `current_user` resolves") is trimmed. The
1093
+ durable grounding stays and is now what the prescription rests on: per-option is the one
1094
+ visibility predicate the **server** enforces, so the rule validator refuses a write of a
1095
+ value whose predicate is false.
1096
+ - `@objectstack/lint`'s field-rule message — the `visibleWhen` consequence clause is
1097
+ re-measured. Under a scope-publishing host the predicate no longer faults: it resolves, the
1098
+ control is hidden client-side, and the server still returns the value to every other reader
1099
+ — a silent enforcement gap. The fault-open leg survives wherever no host publishes a scope.
1100
+ The verdict is unchanged and the message says why it is now *more* justified: trading a loud
1101
+ lint error for a gap nobody can see is worse than the error.
1102
+ - 8b04c75: fix(cli,lint): stop lowering hook handlers that call `ctx.api.sudo()` into bodies that cannot run it (#14010)
1103
+
1104
+ `ScopedContext.sudo()` is real in-process and is **not** marshalled into the
1105
+ QuickJS sandbox: the VM's `ctx.api` carries `object()` and the transaction
1106
+ surface, and nothing else. Every consumer of that fact had it backwards.
1107
+
1108
+ The failure this closes is the expensive shape, not a cosmetic one. An author
1109
+ writes an inline `handler`, tests it the way the docs teach — calling
1110
+ `hook.handler(ctx)` natively, against the in-process `ScopedContext`, where
1111
+ `sudo()` exists — and the suite is green. `objectstack build` then lowers that
1112
+ same source into an L2 `body`, and in production the call is
1113
+ `TypeError: ctx.api.sudo is not a function`. Under a hook's default
1114
+ `onError: 'abort'` the TypeError aborts the **triggering write**, so the
1115
+ symptom surfaces as an unrelated save being refused. Green tests, dead feature.
1116
+
1117
+ - **`@objectstack/cli`** — `.sudo(` joins `FORBIDDEN_PATTERNS` in
1118
+ `extractHookBody`, so the build declines to emit such a handler as
1119
+ `body.source`. This is a repair, not just a refusal: `lowerCallables` already
1120
+ registers the callable and ships it through the `.mjs` bundle when extraction
1121
+ throws, so the handler keeps running **in-process, where `sudo()` is real**.
1122
+ The build prints the reason; `--strict-body`, which demands a body for every
1123
+ callable, turns it into a hard failure — correctly, since a body needing
1124
+ elevation genuinely cannot be one. Same family as the `crypto.hash`
1125
+ retirement (#4391): a member advertised ahead of its implementation, where
1126
+ build-time inference was the amplifier rather than the safety net.
1127
+ - **`@objectstack/lint`** — `hook-api-update-readonly-field` (severity
1128
+ `error`, gating) and its `readonlyWhen` sibling both *prescribed*
1129
+ `ctx.api.sudo()` as the remedy. That rule reads L2 body sources and nothing
1130
+ else, so the prescribed shape was a TypeError for **100%** of its population:
1131
+ a gating rule pointing at a dead feature. Both hints now name the own-hook
1132
+ stamp and say plainly that `sudo()` is not reachable from a body. The rule's
1133
+ findings, severities and exclusions are unchanged — only the advice.
1134
+
1135
+ Docs: the `readonly` table in `automation/hook-bodies.mdx` claimed the
1136
+ `sudo()` row **Lands**; it now records what actually happens.
1137
+
1138
+ Not addressed here, and the reason this is only half the card: a hook still has
1139
+ **no declared elevation knob** — there is no hook-side `runAs` the way
1140
+ `FlowSchema` has one — so "this column is computed by automation and never
1141
+ hand-written" remains inexpressible whenever the maintaining write is
1142
+ cross-object. That is a contract-surface decision (see #14010), left to the
1143
+ review chain rather than guessed at here.
1144
+ - 33681ea: feat(hooks): `runAs` on a hook — `'system' | 'user' | 'inherit'`, default `'inherit'`
1145
+
1146
+ A hook's `ctx.api` runs with the context of the write that fired it, so a column
1147
+ an app wants **computed and never hand-written** could not be expressed: author
1148
+ `editable: false` for the persona and the direct `PATCH` is refused — and so is
1149
+ the hook that maintains the column, by the same field-level check. The guard and
1150
+ the legitimate writer were the same door. The only elevation a hook had was the
1151
+ in-process `ctx.api.sudo()`, which is not marshalled into the sandbox (a
1152
+ `TypeError` once a build lowers the handler into a body) and which rides the L3
1153
+ bundle path that is being retired.
1154
+
1155
+ `HookSchema` now accepts `runAs`:
1156
+
1157
+ | value | the hook's `ctx.api` data operations run as |
1158
+ | --- | --- |
1159
+ | `'inherit'` (default) | the context of the triggering write — exactly the behaviour every hook has today |
1160
+ | `'system'` | elevated: a full-access, RLS-bypassing system principal |
1161
+ | `'user'` | the triggering user; a hook whose trigger resolved no user has its data operations **refused** (`HOOK_UNSCOPED_DATA_ACCESS`) rather than run unscoped |
1162
+
1163
+ `'system'` and `'user'` mean here exactly what they mean on `flow.runAs` — same
1164
+ word, same semantics. `'inherit'` is the hook-only third value, because only a
1165
+ hook has a context to inherit; a flow establishes its identity from nothing,
1166
+ which is why its default is `'user'` and this one's is `'inherit'`. Nothing on
1167
+ `FlowSchema` changes.
1168
+
1169
+ **Purely additive: no migration, no behaviour change for any existing hook.**
1170
+ The default reproduces today's behaviour by handing the engine-built `ctx.api`
1171
+ through unchanged, and an absent key parses to it.
1172
+
1173
+ Scope, deliberately narrow: `ctx.api` data operations only. `condition`
1174
+ evaluation, the `readonly` strip applied to the hook's own `ctx.input` payload,
1175
+ `ctx.session` and `async` semantics all keep reading the triggering operation's
1176
+ context, and declaring `runAs: 'system'` does not elevate the write that fired
1177
+ the hook.
1178
+
1179
+ Elevation is authorization, not anonymity: a `runAs: 'system'` write still
1180
+ carries the triggering user, so `created_by` / `updated_by` and the audit row
1181
+ still name the operator.
1182
+
1183
+ Honoured on both execution surfaces — the in-process `handler` and the
1184
+ sandboxed `body`.
1185
+
1186
+ Authoring notes:
1187
+
1188
+ - `sudo`, `elevate`, `elevated` and `isSystem` are refused with a prescription
1189
+ naming `runAs`, and `run_as` is answered as a rename.
1190
+ - `@objectstack/lint`'s gating `hook-api-update-readonly-field` rule now skips a
1191
+ hook that declares `runAs: 'system'` — the static `readonly` strip skips a
1192
+ system context, so the write it exists to catch does not happen — and its
1193
+ hints name the knob. The `readonlyWhen` warning is unchanged: a system context
1194
+ does not waive a conditional lock.
1195
+ - 68c5dba: fix(cli,lint): stop `os lint` demanding translation keys the liveness ledger warns authors for writing (#11624)
1196
+
1197
+ `os lint` computes i18n coverage and runs the authoring-rule registry in a
1198
+ single pass over the same stack, and for the `flows` translation group the two
1199
+ halves pointed opposite ways:
1200
+
1201
+ | the author does | which rule fires | what it says |
1202
+ |---|---|---|
1203
+ | omits `flows.*` from the bundle | `i18n/missing-flow` | the key is missing a translation for locale X |
1204
+ | adds it (`os i18n extract` scaffolds it) | `liveness-planned-property` | the `flows` group is `planned` — nothing reads it |
1205
+
1206
+ Measured on one stack, one run: omitting produced **4** `i18n/missing-flow`
1207
+ findings and 0 liveness findings; authoring produced 0 demands and **2**
1208
+ `liveness-planned-property` findings ("sets `flows` but this translation
1209
+ property is planned"). There is no per-rule suppression in `os lint`, only
1210
+ `--skip-i18n`, which silences the entire `i18n/missing-*` family — so the
1211
+ author's only escape cost them every other coverage signal. Under
1212
+ `--i18n-strict` the demand side is an **error**, so a project could be forced
1213
+ to author keys it is then warned for.
1214
+
1215
+ ⛔ The warning is not the bug and is unchanged: no shipped screen-flow runner
1216
+ reads the group, so a translated wizard string is stored and never shown — the
1217
+ failure mode `validationMessages` was removed in 17.0.0 for. The premature half
1218
+ is the demand.
1219
+
1220
+ **The fix.** `collectExpectedEntries` — the single definition of what is
1221
+ translatable at all, shared by the coverage gate and the `os i18n extract`
1222
+ skeleton — now leaves out any translation group the liveness ledger warns
1223
+ authors for authoring. It reads that set from `@objectstack/lint`'s new
1224
+ `authorWarnedProperties(type)`, which returns the very warn-map
1225
+ `lintLivenessProperties` iterates, so the demand side and the warn side cannot
1226
+ drift into disagreeing about the same keys again.
1227
+
1228
+ Two properties fall out of reading the ledger rather than switching on `flows`
1229
+ by name: the bucket **turns itself back on** the day an objectui screen-flow
1230
+ runner lands and the row flips to `live` (no flag, no follow-up edit), and any
1231
+ future group that acquires an `authorWarn` is covered on the day it is marked
1232
+ rather than re-opening this collision one group at a time. Today `flows` is the
1233
+ only such group — pinned as an equality so a second one goes red instead of
1234
+ shipping.
1235
+
1236
+ No other bucket changes: `objects`, `apps`, `pages`, `dashboards`,
1237
+ `globalActions` and `metadataForms` are all `live` and are reported exactly as
1238
+ before. `@objectstack/spec` is untouched — the `flows` row keeps `planned` +
1239
+ `authorWarn: true`.
1240
+ - b2eab95: feat(spec,objectql): give three authored display surfaces a bundle key — bulk-action defs, custom validation messages, dataset labels (#14253)
1241
+
1242
+ Purely additive: three new translation groups, one new dispatch-table entry, one
1243
+ new resolution step on the write path. No existing key changes shape, no
1244
+ resolution order changes, and every surface still falls back to the authored
1245
+ literal when the bundle carries nothing.
1246
+
1247
+ Each of the three carried **authored, user-facing display text that no key in
1248
+ `TranslationDataSchema` could reach** — not a drifted key, no key. Each rendered
1249
+ in the source locale inside an otherwise fully translated screen, which is the
1250
+ bad failure mode: it reads as a styling quirk rather than as a missing
1251
+ translation. Measured on a real `zh-CN` deployment.
1252
+
1253
+ **1. A list view's `bulkActionDefs[]`** —
1254
+ `objects.<object>._views.<view>.bulkActions.<def_name>.{label,confirmText,confirmLabel,params.<p>.{label,help,placeholder}}`,
1255
+ resolved in `translateView` against `config.bulkActionDefs` (the one address a
1256
+ served def has: both `ViewItemSchema` and `expandViewContainer` nest the whole
1257
+ ListView under `config`). A def is part of the *view* document, not an action
1258
+ document, so it never reached `translateAction`; the selection bar read
1259
+ `已选择 1 项 · Complete · Skip · 清除`. The def's `label` deliberately stays a
1260
+ plain `z.string()` on the authoring side — the bar renders it as a React child,
1261
+ so an inline locale map would be a blank cell rather than a parse error — and
1262
+ overlaying at the metadata boundary keeps the wire value a plain string. The
1263
+ documented workaround (`bulkActions: ['<name>']`, promoting a declared action)
1264
+ is not equivalent: it is N elevated per-record dispatches instead of one
1265
+ data-plane `updateMany`.
1266
+
1267
+ **2. A custom validation rule's `message`** —
1268
+ `objects.<object>._validations.<rule_name>.message`, spelled by the new
1269
+ `objectValidationMessageKey` and read on the write path by the rule evaluator.
1270
+ ⚠️ **This adds a key shape, not a channel**: the lookup runs on the *existing*
1271
+ `i18nService` hook that has localized built-in field-catalog messages and field
1272
+ labels since #3957. Before it, a deployment got platform-generated refusals in
1273
+ the caller's language and author-written refusals in the source language inside
1274
+ one `400 VALIDATION_FAILED` envelope. All five authored-message emitters route
1275
+ through one seat; a nested `conditional` branch is addressed by the branch's own
1276
+ name; a platform-generated rejection (an unevaluable predicate) is deliberately
1277
+ left alone. `messages['validation.field.*']` is unchanged and still overrides the
1278
+ built-in catalog only.
1279
+
1280
+ **3. Dataset labels** — `datasets.<name>.{label,description,dimensions.<d>.label,measures.<m>.label}`
1281
+ plus `translateDataset` in `METADATA_DOCUMENT_TRANSLATORS`. A dataset reads like
1282
+ a back-office definition, but a measure label is drawn on the dashboard, under
1283
+ every metric tile and on every chart axis. Registering the translator is the
1284
+ whole wiring — `TRANSLATABLE_METADATA_TYPES` is derived from that table and
1285
+ `@objectstack/rest` reads the derived set (#3786) — so `GET /api/v1/meta/datasets?locale=…`
1286
+ localizes with nothing else to remember.
1287
+
1288
+ Key faces are measured against the authoring schemas rather than mirrored from
1289
+ the report, so nothing here parses clean and translates nothing: a bulk param's
1290
+ hint is `help` (not the action-param `helpText`), per-param `options` are refused
1291
+ because `options[].value` is unconstrained and a value-keyed map cannot address
1292
+ `true` and `"true"` apart, a def has no `successMessage`, and a dataset dimension
1293
+ or measure has no `description` — the authoring schema says so itself. Every
1294
+ exclusion carries `guidance` naming the right home.
1295
+
1296
+ Two tombstones stop asserting that no route exists: the retired
1297
+ `validationMessages` and `errors` guidance now point at
1298
+ `objects.<object>._validations.<rule>.message`. Retiring `validationMessages`
1299
+ (17.0.0, #4667, ADR-0049) is **not** reversed — that group was keyed by rule name
1300
+ at the bundle's top level, so it could not tell two objects' rules apart, and,
1301
+ the reason it was retired, nothing read it. Its ADR-0087 conversion still strips
1302
+ it from stored bundles. The replacement is object-scoped and ships its reader in
1303
+ the same change.
1304
+
1305
+ Authors upgrading need do nothing; a bundle that writes none of the three new
1306
+ groups behaves exactly as before.
1307
+
1308
+ <!-- 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. -->
1309
+ - 5228e52: fix(lint): `lintDataModel` reads only the canonical `reference` target (#13250)
1310
+
1311
+ `refOf` in `packages/lint/src/data-model-rules.ts` resolved
1312
+ `def?.reference || def?.reference_to`, so a relationship field spelled with the
1313
+ rejected alias resolved a target. #11567 settled that `reference` is the only
1314
+ relationship spelling `@objectstack/spec` declares — `FieldSchema` answers
1315
+ `reference_to` with `unrecognized_keys` and *"Did you mean `reference_to` →
1316
+ `reference`?"* — and put it as "one key, one answer, on both doors".
1317
+
1318
+ `@objectstack/lint` runs over an in-memory, schema-parsed stack, so the alias
1319
+ cannot legitimately appear here at all: the tolerance was inert. Where it did
1320
+ fire, it made the rule whose entire job is to catch a relationship with no
1321
+ target — `relationship/missing-reference` — report a valid target for a field
1322
+ that has none, i.e. the one component that exists to tell an author their
1323
+ metadata is wrong was the component accepting the wrong spelling.
1324
+
1325
+ This mirrors the deliberate canonical-only narrowing already recorded in-file
1326
+ for `refOf` in `packages/lint/src/validate-security-posture.ts`, including its
1327
+ `typeof r === 'string'` guard — which also makes the declared
1328
+ `string | undefined` return type true, where the old `||` chain returned
1329
+ whatever truthy value it found (a non-string `reference` was reported as a
1330
+ resolved target).
1331
+
1332
+ What changes for a consumer, only for metadata the spec already refuses:
1333
+ `relationship/missing-reference` (error) now fires on a relationship field
1334
+ whose only target spelling is `reference_to`, and the rules that need a
1335
+ resolved target (`relationship/master-detail-required`, `rollup/missing-summary`
1336
+ and the rest of the relationship family) no longer treat such a field as
1337
+ pointing anywhere. Canonical `reference` is untouched.
1338
+
1339
+ Scope note: the two remaining tolerant readers named in #13250 —
1340
+ `packages/verify/src/derive.ts` and
1341
+ `packages/plugins/plugin-security/src/security-plugin.ts` — are deliberately
1342
+ NOT narrowed here. Both were measured to sit on populations the alias can
1343
+ actually reach (raw `registerObject`, which skips Zod by design, and an app
1344
+ config that never passes through a `define*` parse), so narrowing them is a
1345
+ triage call rather than a defect fix.
1346
+ - 9057811: fix(lint): `relationship/delete-behavior` suggestion no longer names `set_null` as declarable on a `master_detail`
1347
+
1348
+ `lintDataModel`'s `relationship/delete-behavior` suggestion told an author an
1349
+ undeclared `master_detail.deleteBehavior` could be `cascade`, `restrict`, or
1350
+ `set_null`. Since #9689 (PR #11406, maintainer ruling 2026-08-19), an authored
1351
+ `deleteBehavior: 'set_null'` on a `master_detail` field is a named parse-time
1352
+ rejection — a detail row cannot outlive its master, so the engine resolves
1353
+ every value except `restrict` to `cascade` on this type. Following the
1354
+ suggestion's own `set_null` mention literally walked an author into that
1355
+ rejection at publish time.
1356
+
1357
+ The message now enumerates only the two values `FieldSchema` actually accepts
1358
+ on a `master_detail` (`cascade`/`restrict` — matching the vocabulary already
1359
+ offered by the metadata-admin field form, `object.form.ts`'s `master_detail`
1360
+ `deleteBehavior` options), and keeps the same outcome-naming courtesy as the
1361
+ parse-time rejection message: it still names `set_null` to say plainly that it
1362
+ is not honored on this type, and points to `lookup` for the case where
1363
+ children must survive the parent. The `fix` payload (`deleteBehavior:
1364
+ 'cascade'`) was already correct and is unchanged.
1365
+ - 1af8286: fix(lint): a field-level `*When` reading `app` gets the scope diagnostic, not the false `record.app` prescription (#13935)
1366
+
1367
+ `fieldRuleRootIssue` judged field-rule roots against `@objectstack/formula`'s
1368
+ `SCOPE_ROOTS`, which answers "is this root declared **platform-wide**". The
1369
+ question this rule needs answered is "is this root bound at **some** evaluation
1370
+ site". The two agreed for all 27 baseline roots and disagreed for exactly one:
1371
+ `app`, which objectui's `ExpressionProvider` binds on the form-view surface an
1372
+ author migrates a field rule *down* from.
1373
+
1374
+ Falling outside the membership test sent `app` to the generic bare-reference
1375
+ check, whose prescription is ``Write `record.app` `` — and following that
1376
+ advice earns ``unknown field `app` on `invoice` `` from the field-existence
1377
+ pass. A first diagnostic that asserts something false about where the root
1378
+ binds, plus a wasted correction cycle. `current_user`, `user`, `ctx`, `os`,
1379
+ `features` and `data` all got the correct message; `app` alone did not.
1380
+
1381
+ Authoring a field-level `visibleWhen` / `readonlyWhen` / `requiredWhen` on
1382
+ `app` now earns the same scope diagnostic every other unbound root gets —
1383
+ "a field-level conditional rule binds only `record` (plus `previous`, and
1384
+ `parent` on a master-detail line item)" — with a prescription tier of its own
1385
+ that says what is actually true of an ambient root: it is *not* declared
1386
+ platform-wide, it is mounted only by the renderer, and `record.app` is
1387
+ explicitly refused rather than merely omitted, because that is the advice the
1388
+ author just followed out of the old diagnostic.
1389
+
1390
+ **No accept set moves.** `SCOPE_ROOTS` is `@objectstack/formula`'s published
1391
+ strict-lint baseline — adding `app` there would stop *every* surface that
1392
+ judges bare identifiers from faulting it, to fix one surface's wording. The
1393
+ widened vocabulary is assembled in `@objectstack/lint` instead, where the
1394
+ per-surface question is asked, and both diagnostics involved were already
1395
+ `severity: 'error'`, so this changes which message an author reads and nothing
1396
+ about what lints clean.
1397
+
1398
+ `FIELD_RULE_AMBIENT_ROOTS` and `FIELD_RULE_JUDGED_ROOTS` are exported beside
1399
+ the existing `FIELD_RULE_BOUND_ROOTS`.
1400
+ - 365e334: **Fix:** `lintLivenessProperties` walks `stack.translations` as the locale-keyed bundle it is, so the `translation` liveness ledger finally reaches the author (#11288).
1401
+
1402
+ `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.
1403
+
1404
+ 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'`).
1405
+
1406
+ 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.
1407
+
1408
+ Advisory-only as before: the finding is a warning, and `os lint` exits on errors, never on warnings.
1409
+
1410
+ 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.
1411
+ - 5c28b88: lint: `flow-update-readonly-when-field` now inspects `runAs:'system'` flows
1412
+
1413
+ The `runAs:'system'` exemption in `validate-readonly-flow-writes` was a single
1414
+ flow-level early return, so it removed an elevated flow from **both** branches of
1415
+ the rule. Only the static branch warrants it: the engine skips
1416
+ `stripReadonlyFields` under `if (!opCtx.context?.isSystem)`, but
1417
+ `stripReadonlyWhenFields` runs on the update path with no `isSystem` guard at all
1418
+ (`packages/objectql/src/engine.ts`, the #9107 note: "`isSystem` is still NOT an
1419
+ exemption here, unlike the static strip below"), pinned as "LOCK 2 — isSystem does
1420
+ NOT exempt a caller-supplied value".
1421
+
1422
+ The exemption now gates the static branch only. A `runAs:'system'` flow whose
1423
+ `update_record` node writes a `readonlyWhen` field reports the branch's existing
1424
+ `warning` — the same silent-no-op the rule exists to surface, on the flow class the
1425
+ rule's own hint tells the author elevation cannot save. A system flow writing a
1426
+ static `readonly:true` field stays silent, as before; rule ids and severities are
1427
+ unchanged, and the new finding is advisory and never blocks a build.
1428
+ - 996cb1d: fix(lint): run the flattened-scope shadowing warning on the descriptor-declared predicate slots too (#14288)
1429
+
1430
+ The #14089 shadowing warning — a bare name that is BOTH a declared flow
1431
+ variable AND a field on the bound object, where the variable silently wins at
1432
+ runtime — reached exactly two expression positions: the node `condition` and
1433
+ the edge `condition`. The `#4027` descriptor-declared predicate slots were
1434
+ validated for dialect by the same traversal but were never passed through the
1435
+ shadowing pass, so the identical mistake stayed silent on them.
1436
+
1437
+ The warning is about the **scope** an expression is evaluated in, not the key
1438
+ it was authored under, and the engine measurement says both `predicate` slots
1439
+ on the ledger share the run's one flattened variable map:
1440
+
1441
+ - `decision.conditions[].expression` — the decision executor evaluates against
1442
+ the very `variables` parameter the engine hands every node executor, which is
1443
+ the same `Map` object `seedRunVariables` built and a node `condition` is
1444
+ judged against. Nothing on the path clones or narrows it.
1445
+ - `screen.fields[].visibleWhen` — `refuseInvalidScreenInput` evaluates against
1446
+ `run.variables` (the persisted snapshot of that same seeded map) with the
1447
+ submitted bag overlaid. A superset, so the shadow still reaches it: the
1448
+ overlay carries the screen's own collected values, never the bound record's
1449
+ field, so it can never hand back a field the variable displaced.
1450
+
1451
+ `loop.collection` and `map.collection` are `flow-template`, not `predicate`,
1452
+ and the slot loop already skips them.
1453
+
1454
+ Warning-only and within the 2026-09-01 option-C ruling's letter: one more call
1455
+ site reusing the `declaredVariables` set already collected once per flow, no
1456
+ new rule id, no severity above `warning`, no accept set moved, and no bare
1457
+ identifier judged for being bare. Nothing that linted clean before can newly
1458
+ fail a build.
1459
+ - 6eb8e3c: fix(lint): `sharing-rule-runtime-variable-condition`'s fix-hint no longer sends authors to RLS to widen a `private` object (#14234)
1460
+
1461
+ The hint printed for a sharing-rule `condition` that reads `current_user.*`
1462
+ ended in an **unqualified** remedy: "express per-user access with the mechanism
1463
+ that runs per request instead — an RLS policy on a permission set
1464
+ (`rowLevelSecurity[].using`, where `current_user.*` IS resolved)". That advice
1465
+ is sound on an open OWD and **structurally impossible on `private`**, which is
1466
+ the sharing model an author hitting this rule is most likely to be on.
1467
+
1468
+ Measured, not argued. The security layers are AND-composed —
1469
+ `plugin-security`'s `getReadFilter` returns
1470
+ `andComposeLayers(andComposeLayers(filter, cbpFilter), sharingFilter)`, and its
1471
+ own prose promises "the same filter the engine middleware AND-s into" every
1472
+ find — while `plugin-sharing`'s `buildReadFilter` constrains `private` only
1473
+ (`effectiveSharingModel(schema) !== 'private'` returns `null`). So:
1474
+
1475
+ - `public_read` / `public_read_write` → the sharing layer imposes nothing on
1476
+ reads, and an RLS policy **narrows** an open baseline. The original advice is
1477
+ correct here and is kept, now with the models it holds for attached.
1478
+ - `private` → the sharing layer has already withheld the row, and **an AND term
1479
+ can only remove rows, never add one back**. An RLS "widener" there lints
1480
+ clean, passes every gate, and grants nothing — a silent failure at the
1481
+ security boundary, which is the worst possible feedback shape. The card
1482
+ records an application author who followed this path and shipped the grant
1483
+ unauthored/fail-closed instead.
1484
+
1485
+ The hint now splits on `sharingModel` and, for the `private` case, states what
1486
+ is true: the two doors that widen a private object for a non-admin principal
1487
+ both key on **who owns the row** or on an **explicit share row**, never on a
1488
+ property of the record — the ADR-0057 D1 depth scopes (`readScope`/`writeScope`,
1489
+ which widen the owner-match to `owner_id IN (…)`) and a `sys_record_share` row,
1490
+ which on this path only a criteria sharing rule writes. Where neither fits, it
1491
+ names the gap **in words** — a known platform limitation, not something a
1492
+ different spelling of this rule can close — rather than inventing a mechanism,
1493
+ and it warns that `position` recipients resolve **tenant-wide**: the
1494
+ neighbouring temptation is an over-broad grant that also lints clean, not the
1495
+ narrower one the author meant. The tracker anchor for that gap sits in an
1496
+ adjacent source comment, not in the message, because a runtime string reaches
1497
+ authors and generated surfaces that cannot resolve `#NNNN`
1498
+ (`check:doc-authoring`, maintainer ruling 2026-08-12) — a pin asserts the
1499
+ message carries no tracker id.
1500
+
1501
+ **The explanation is preserved byte-for-byte.** The opening sentence — the
1502
+ MATERIALISED/`criteria_json` explanation of *why* the condition is refused — is
1503
+ the most useful part of the diagnostic and is unchanged; a pin asserts it
1504
+ verbatim. The file's own docblock carried the same unqualified "the fix is RLS"
1505
+ claim one layer up and is corrected in the same edit.
1506
+
1507
+ **No behaviour change**: the rule's accept/reject verdict, its ids, severities,
1508
+ paths and `message` text are untouched — this is `hint` prose only. AND
1509
+ composition is correct by design and is not touched either.
1510
+
1511
+ A new pin holds the split, because prose is invisible to every other gate —
1512
+ nothing else in CI reads a word of this hint. Reverse-verified by restoring the
1513
+ shipped wording: **7 legs go red** (the unqualified-RLS leg, the AND-composition
1514
+ leg, the widening-doors leg, the `position` warning, the #14103 gap, and both
1515
+ vocabulary legs), while the verbatim-explanation leg correctly stays green. The
1516
+ vocabulary legs check every sharing model and depth scope the hint names against
1517
+ the **spec-owned** `OWDModel` / `ObjectAccessScopeSchema` enums, so a rename in
1518
+ the spec reds this file instead of leaving the hint quoting values the platform
1519
+ no longer has — without it the hint and its expectations would move together and
1520
+ nothing would go red.
1521
+ - 3a5b8c9: fix(lint): consolidate five more private "Did you mean?" copies onto the shared `suggestName` (#14577, follow-up to #14268/#14575)
1522
+
1523
+ `validate-action-name-refs.ts`, `validate-chart-bindings.ts` and
1524
+ `validate-searchable-fields.ts` each carried a private `suggest`/`distance`
1525
+ pair, byte-for-byte re-deriving the edit-distance-only budget that
1526
+ `object-graph.ts` already exports as `suggestName` (the shared helper
1527
+ #14268/#14575 consolidated three other rules onto). All three now import
1528
+ `suggestName` from `./object-graph` and their private copies are deleted.
1529
+
1530
+ `validate-ai-tool-references.ts` and `validate-translation-references.ts`
1531
+ each carry a one-line pre-pass ahead of the private pair — the `action_<name>`
1532
+ tool-family prefix, and a snake_case namespace-segment match — that is
1533
+ rule-local knowledge, not the shared helper's business. Both keep that
1534
+ pre-pass and now delegate the fallback to `suggestName` instead of a private
1535
+ Levenshtein copy.
1536
+
1537
+ The shared helper's containment pre-pass (a candidate that contains the
1538
+ target, or vice versa, scores ahead of any edit-distance match) is now every
1539
+ one of these five rules' behaviour too, so a hint may now appear where one was
1540
+ previously absent — it never removes a hint the private copy gave. Per site:
1541
+
1542
+ - `validate-action-name-refs.ts` — `archive` → `archive_completed_deals`
1543
+ (17 edits, over budget) now gets a hint; unaffected cases unchanged.
1544
+ - `validate-chart-bindings.ts` — the issue's own headline example,
1545
+ `amount` → `sum_amount` (4 edits, over the budget of 2) now gets a hint on
1546
+ a raw-field-instead-of-measure binding.
1547
+ - `validate-searchable-fields.ts` — `amount` → `sum_amount` (4 edits) now
1548
+ gets a hint on a stale `searchableFields` entry.
1549
+ - `validate-ai-tool-references.ts` — the `action_<name>` prefix pre-pass is
1550
+ unchanged and still wins first; a miss with no prefix match now also
1551
+ reaches `suggestName`'s containment scan (e.g. `knowledge_base` →
1552
+ `search_knowledge_base`), where the old private copy gave nothing.
1553
+ - `validate-translation-references.ts` — the namespace-segment pre-pass is
1554
+ unchanged and still wins first; a miss with no segment match now also
1555
+ reaches `suggestName`'s containment scan (e.g. `amount` →
1556
+ `amountsummary`), where the old private copy gave nothing.
1557
+
1558
+ `object-graph.ts`'s helper is untouched (already ruled by #14268/#14575);
1559
+ `validate-react-page-props.ts` and `validate-rule-schema-formats.ts` stay out
1560
+ — both are a different contract on purpose (see #14577's triage).
1561
+ - d754829: fix(lint): consolidate the four hand-copied "Did you mean?" helpers into `object-graph.ts`'s shared `nearestName`/`suggestName` (#14268)
1562
+
1563
+ `validate-object-references.ts`, `validate-sortable-fields.ts` and
1564
+ `validate-widget-bindings.ts` each carried a private `suggest`/`distance` (or
1565
+ `didYouMean`/`levenshtein`) pair, byte-for-byte re-deriving the same
1566
+ edit-distance budget `object-graph.ts` already exported on the package barrel
1567
+ as `nearestName`/`suggestName` — the same drift #4330 fixed one constant over
1568
+ for `SYSTEM_FIELDS`. All three now import `suggestName` (and `nearestName`
1569
+ where a rule needs the bare name) from `./object-graph` and their private
1570
+ copies are deleted.
1571
+
1572
+ The one decision the consolidation forced: `validate-widget-bindings.ts`
1573
+ scored a containment match (e.g. `amount` → `sum_amount`, the ADR-0021
1574
+ base-column → prefixed-measure-name drift) ahead of edit distance; the other
1575
+ two rules had no such pre-pass and suggested nothing for the same class of
1576
+ typo. That containment pre-pass is now `nearestName`'s behaviour for every
1577
+ caller — it only ever *adds* a suggestion where the edit-distance budget
1578
+ previously returned none, so a "Did you mean?" hint may now appear where one
1579
+ was previously absent. The full `@objectstack/lint` suite (93 files / 2812
1580
+ tests) was run against the pre-change and post-change trees and produced
1581
+ identical results, so no existing suggestion assertion was affected in
1582
+ practice.
1583
+ - 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)
1584
+
1585
+ `packages/lint/tsconfig.json` excluded `**/*.test.ts` and `**/*.spec.ts`, and
1586
+ the package's `typecheck` script was a bare `tsc --noEmit` against that very
1587
+ config — so no gate anywhere read a lint test file with a type checker.
1588
+ `src/validate-expressions.test.ts` alone is ~2,700 lines built almost entirely
1589
+ out of compile-time and meta pins (the #5017 receiver scan, the
1590
+ `TRACKED_UNDECLARED_READS` shrink-only list, the residual-root table), and none
1591
+ of it was type-checked by anything: vitest transpiles through esbuild (types
1592
+ stripped, never resolved), so a wrong key or a signature drift in a pin's own
1593
+ scaffolding was caught by nobody.
1594
+
1595
+ Onboarded by *wiring* to the mechanism #14062 (PR #14420) landed on
1596
+ (`scripts/check-test-typecheck.mts`), per the triage ruling on this card: a
1597
+ sibling `tsconfig.test.json` matching vitest's real module semantics
1598
+ (`module: esnext`, `moduleResolution: bundler`, `lib: ["ES2022"]`; strictness
1599
+ and `rootDir` untouched, inherited), named by `typecheck`. Measured (workspace
1600
+ closure built first): 6 residual errors over 2 files, all TS6059 (imports from
1601
+ `examples/app-showcase`, outside this package's `rootDir` — pre-existing,
1602
+ config-tier, not a lint defect), recorded EXACT and shrink-only in the new
1603
+ `test-typecheck-debt.json`.
1604
+
1605
+ This is a CONVERSION of the coverage gate's existing `@objectstack/lint`
1606
+ TEST_DEBT entry (`errors: 16`), not a new debt-opening decision: the same
1607
+ authority that recorded the 16 now holds the residue one level finer, per file
1608
+ and per signature, and the coverage-gate entry is deleted as the graduation
1609
+ that pairing forces. No test file is edited — opening the ratchet is not the
1610
+ same job as paying it down.
1611
+ - 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.
1612
+
1613
+ 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.
1614
+ - 20a452e: `lintLivenessProperties` now honours its own docblock contract ("Advisory only
1615
+ — returns findings, never throws") when a collection item is `null` or
1616
+ otherwise not an object. The object walk, the field walk nested under it, and
1617
+ the flat `TYPE_COLLECTIONS` loop that covers every other governed type (flow,
1618
+ action, agent, tool, …) each read `item.name`/`item.object` straight off every
1619
+ element with no record guard, throwing `TypeError: Cannot read properties of
1620
+ null (reading 'name')` on a malformed item instead of skipping it — reachable
1621
+ via the exported `stack: AnyRec` signature on an unparsed or hand-built stack.
1622
+ The translation bundle walk already guarded its two levels (#11383); this
1623
+ closes the same hole on the three walks that did not (#11385).
1624
+ - 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.
1625
+
1626
+ 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.
1627
+
1628
+ 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.
1629
+ - 289cf91: fix(lint): guard `walkPageComponents` against component cycles (#13217)
1630
+
1631
+ `walkPageComponents` — the one shared page-component traversal under every
1632
+ page-shaped lint rule and the CLI's i18n object-sections pass — descended the
1633
+ untyped composition slots inside `properties` with no cycle guard. Every one of
1634
+ those slots is `z.array(z.unknown())` authored data, so a component whose
1635
+ `properties.children` contains itself is **legal input**, and feeding one in
1636
+ recursed until the stack died with `RangeError: Maximum call stack size
1637
+ exceeded`. Because the walk is shared rather than copied, that crash was not
1638
+ scoped to one rule: it took every rule standing on the walk down in the same
1639
+ process.
1640
+
1641
+ The descent now carries an **ancestor set** — the node is added before
1642
+ descending and removed on the way out — so a node that is its own ancestor
1643
+ stops the descent. Measured on the shapes that matter: a direct self-reference,
1644
+ an indirect cycle (`A -> B -> A`) and a longer chain (`A -> B -> C -> A`) all
1645
+ terminate, through every descended slot (`properties.children`,
1646
+ `properties.items[].children`, `properties.body`, `properties.footer`).
1647
+
1648
+ Two deliberate non-changes, both pinned:
1649
+
1650
+ - **An ancestor set, not a visited set.** A component object placed twice as a
1651
+ *sibling*, or reached down two different branches, is legitimate re-use at two
1652
+ distinct config paths, and every rule built on this walk must see both
1653
+ placements. A visited set would yield the first and silently drop the rest —
1654
+ trading a loud crash for missing lint coverage. This matches the predicate the
1655
+ sibling resolver `translatePage` already settled on.
1656
+ - **No depth cap.** A cap and a cycle guard are different instruments. On a
1657
+ resolver a cap leaves copy untranslated; on a lint walk it would drop real
1658
+ components from the walk output and every rule would go quiet about them — a
1659
+ silent truncation that reads exactly like a clean page. With the cycle guard
1660
+ the descent is bounded by the document's own finite nesting, so a cap could
1661
+ only ever fire on acyclic input, which is the input it must not truncate.
1662
+
1663
+ The guard is silent: a cycle stops the descent and yields nothing extra, and no
1664
+ finding or warning is produced. Deciding that a self-referential page is itself
1665
+ an authoring error would be new reject behaviour on authored input, which is a
1666
+ contract call and not this walk's to make. Measured on a cyclic-but-otherwise
1667
+ valid page, all six rules that route through the walk report exactly what they
1668
+ report for the equivalent acyclic document (zero findings either way).
1669
+
1670
+ No authored page in this repo carries such a cycle — swept across 57
1671
+ page-shaped objects with a positive control, zero hits — so this fixes a
1672
+ reachable crash, not an active incident.
1673
+ - ba8420b: `lintLivenessProperties` no longer tells authors a `planned` property is `dead`
1674
+
1675
+ `describe()` in `lint-liveness-properties.ts` only knew two verdicts
1676
+ (`experimental`, everything else → `dead`), while the liveness ledger ships a
1677
+ third: `status: 'planned'` (declared, and a consumer is being built against
1678
+ it — contract-first, the opposite of `dead`). Every `planned` row fell through
1679
+ into the `dead` branch, so the finding's own **message** told the author to
1680
+ remove metadata the platform had asked them to write, while the same finding's
1681
+ **hint** (when the row carried one) said the opposite one sentence later. Three
1682
+ shipped rows hit this: `field.relatedListFilter`, `object.externalSharingModel`,
1683
+ `translation.flows`.
1684
+
1685
+ `describe()` now has a third branch: `status === 'planned'` gets its own rule
1686
+ id (`liveness-planned-property`, mirroring `liveness-dead-property` /
1687
+ `liveness-experimental-property`'s advisory-only posture — nothing downstream
1688
+ keys off these ids today) and its own message/default hint ("keep it — a
1689
+ consumer is being built against this property", never "Remove it").
1690
+
1691
+ The ledger's `status` field is a documented vocabulary, not a Zod-enforced
1692
+ enum — nothing rejects a ledger entry with an unrecognised status. `describe()`
1693
+ previously graded any such entry `dead` silently; it now throws, naming the
1694
+ offending status, so a ledger-authoring mistake (a typo, or a new status added
1695
+ without teaching this file about it) fails loudly at test time instead of
1696
+ mislabelling a finding.
1697
+ - b003cf2: Refuse an undeclared field a `before*` hook writes, identically on every driver
1698
+
1699
+ **BREAKING** accept-set narrowing at the post-hook write door, shipped as `minor`
1700
+ under the repo's launch-window convention for breaking changes.
1701
+
1702
+ **Bump level, argued**: `@objectstack/objectql` is `minor`, not `patch`. A
1703
+ `before*` hook or an L2 (`language:'js'`) body writing a key the object never
1704
+ declares **used to succeed** on the `memory` family — the value reached the
1705
+ store and persisted as a shadow column — and now **throws**, `INVALID_FIELD` /
1706
+ **400**, on every driver. That is a narrowing of the accept set on the record
1707
+ payload, a surface every hook body touches; it is not an instrument or a message
1708
+ fix, and a hook that relied on either driver-dependent outcome stops working at
1709
+ run time. The same-package sibling `.changeset/hook-input-symbol-key-refusal.md`
1710
+ argues exactly this shape — "used to succeed, and now throw. That is a narrowing
1711
+ of the accept set" — to `minor`, and the launch-window convention is what keeps
1712
+ it off `major` (pre-1.0 lockstep semantics: a breaking change does not burn a
1713
+ major version while the stack versions in lockstep — see
1714
+ `scripts/check-changeset-no-major.mjs`). `patch` would under-declare a change
1715
+ that turns a passing hook into a throwing one.
1716
+
1717
+ `'@objectstack/lint': patch` is deliberate and stays. That half of the diff is
1718
+ message and comment prose only: `validateHookBodyWrites` reports the same
1719
+ findings on the same bodies at the same severity, with wording that now names
1720
+ the runtime refusal instead of the driver split this change retires.
1721
+
1722
+ The declared-field door (#8682 on insert, #8738 on update) runs before the
1723
+ `before*` hooks — deliberately, so a payload about to be refused never consumes
1724
+ an autonumber (#8737). That left the payload the hooks themselves produce
1725
+ unjudged: a key a `beforeInsert` / `beforeUpdate` hook or an L2 (`language:'js'`)
1726
+ body wrote went straight to the driver, and the drivers disagreed. `memory`
1727
+ accepted it and stored a shadow column; `driver-sql` threw a raw `SQLITE_ERROR`
1728
+ with no `status` and the bound statement and its values quoted back in the
1729
+ message; `sqlite-wasm` threw a bare `Error` with neither. One app and one hook
1730
+ meant different things on two deployments, and nothing in the app could tell
1731
+ which one it was running on.
1732
+
1733
+ The same check now runs a second time over the post-hook payload, before any
1734
+ statement is built, so a hook-written undeclared key is refused with the caller
1735
+ path's envelope — `INVALID_FIELD` / **400**, `Unknown field 'x' on object 'y'` —
1736
+ on every driver, because none of them is reached. The existing pre-hook door is
1737
+ unchanged and stays exactly where it is.
1738
+
1739
+ This is a security fix as well as a consistency one: `fieldPermissions` is keyed
1740
+ by declared field name and reports only fields explicitly marked non-editable, so
1741
+ a key the object never declares can carry no entry and could never be gated by
1742
+ field-level security. On `memory`-family stores such a value was persisted where
1743
+ no view, formula, index or permission could name it.
1744
+
1745
+ The platform's own stamps are unaffected. `created_at` / `updated_at` — the two
1746
+ the built-in audit hook writes unconditionally, because SQL drivers create them
1747
+ as built-in columns on every table — are already tolerated by this check
1748
+ alongside `id`; every other stamp (`created_by`, `updated_by`, `tenant_id`) is
1749
+ guarded by an explicit declaration test in the hook that writes it.
1750
+
1751
+ <!-- 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. -->
1752
+ - b5a2398: Correct three stale `current_user` binding claims about a form FIELD `visibleWhen`
1753
+
1754
+ 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".
1755
+
1756
+ - `@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()`.
1757
+ - `@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.
1758
+
1759
+ 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`.
1760
+ - b992b1d: fix(lint): stop the `readonlyWhen` hints ruling out the remedy that works and offering one that does not (#13832)
1761
+
1762
+ Message text only. Rule ids, severities and match sets are untouched, and no
1763
+ finding changes shape — but the hint **is** the whole product of an advisory
1764
+ rule (neither finding blocks a build), so the sentence is all the author acts
1765
+ on, and both of these sentences were measured false against the engine.
1766
+
1767
+ `flow-update-readonly-flow-writes`' `flow-update-readonly-when-field` hint said:
1768
+
1769
+ > If automation must maintain this field regardless of record state, run the flow runAs:'system'.
1770
+
1771
+ It does not. The conditional strip has **no `isSystem` guard at all** —
1772
+ `stripReadonlyWhenFields` runs unconditionally on the update path, unlike the
1773
+ static `readonly` strip beside it that really is skipped for system callers.
1774
+ So the advice bought the author a `runAs:'system'` flow, a re-run, the same
1775
+ missing column, and an elevated run identity in the tree with no compensating
1776
+ behaviour: **a privilege widening for no effect**. Pinned as "LOCK 2 — isSystem
1777
+ does NOT exempt a caller-supplied value" in
1778
+ `engine-readonly-when-derived-writes.test.ts`, and from the strict-mode side as
1779
+ "covers readonlyWhen too — the arm a trusted (isSystem) caller can still hit".
1780
+
1781
+ Both the `hook-api-update-readonly-when-field` hint and the matching
1782
+ `content/docs/automation/hook-bodies.mdx` bullet carried the same defect from
1783
+ the other direction — they **ruled out the remedy that works**:
1784
+
1785
+ > readonlyWhen strips even a beforeUpdate-derived value, so an own-hook stamp is NOT a workaround here
1786
+
1787
+ That is the behaviour #9107 removed. The conditional strip now judges the
1788
+ *caller's* entry snapshot, so a value a `beforeUpdate` hook **derives** is not
1789
+ caller-supplied and lands even on a locked record —
1790
+ `engine-readonly-when-derived-writes.test.ts` opens with "THE REPORT: a
1791
+ hook-derived value on a TRUE readonlyWhen field now LANDS", and pins the bulk
1792
+ path on the same terms. Between them the two halves left the author's only
1793
+ working option struck out and a useless one recommended.
1794
+
1795
+ All three hints now name the same two measured remedies — confirm the write
1796
+ only targets records whose predicate is FALSE, or derive the field in a
1797
+ `beforeUpdate` hook on the target object — and refuse elevation explicitly,
1798
+ matching the shape `action-api-update-readonly-when-field` already shipped.
1799
+ The hook hint keeps its stronger, separate reason that `sudo()` is a
1800
+ `TypeError` from a sandboxed body, and now also carries the reason that
1801
+ survives if that one is ever fixed: a system context does not waive the
1802
+ conditional lock either.
1803
+
1804
+ Deliberately **not** flattened: the static-`readonly` hints and docs rows that
1805
+ recommend elevation stay exactly as they are, because for *that* strip
1806
+ elevation is the intended channel. The two disagree for a reason, and a pin now
1807
+ holds them apart.
1808
+ - b2dea86: Retire the "no `current_user` at section level" claim from the three prose sites the
1809
+ re-measurement left unswept
1810
+
1811
+ A form-view **section** `visibleWhen` binds `current_user` today. That was measured and
1812
+ landed for the schema text and the field-rule lint message, but three hand-written sites
1813
+ still taught the retired claim, so an author reading the docs or hitting the gate was told
1814
+ the opposite of what the platform does. Text only — no schema, no verdict and no runtime
1815
+ behaviour moves.
1816
+
1817
+ Re-verified at source in `objectui` before trimming anything, because a prose trim applied
1818
+ to a claim someone had since fixed would silently regress their work:
1819
+
1820
+ - `apps/console/src/components/FormPage.tsx` threads the host shell's scope into
1821
+ `isSectionVisible`, which forwards it to `evalFieldPredicate` (objectui#6110).
1822
+ - `packages/plugin-form/src/ObjectForm.tsx` copies an authored section `visibleWhen` onto
1823
+ the `section-divider` pseudo-field the renderer evaluates with that scope bound
1824
+ (objectui#6111); `SplitForm` / `ModalForm` / `DrawerForm` carry the same line.
1825
+
1826
+ The three sites:
1827
+
1828
+ - `content/docs/ui/views.mdx` listed *"section-level predicates (objectui#6111)"* as a
1829
+ surface that still evaluates the predicate unbound — naming as evidence the very PR that
1830
+ bound it. The same sentence also listed `/forms/:name` as unbound; that route renders
1831
+ inside `InternalFormRoute`, which publishes the session principal and binds normally, so
1832
+ the public `/f/:slug` route is now the only unbound surface named.
1833
+ - `content/docs/protocol/objectui/layout-dsl.mdx` carried the claim four times — a code
1834
+ comment, the binding-root table row, the paragraph under it, and the "two limits" prose —
1835
+ where the card recorded three. All four are re-measured together.
1836
+ - `packages/lint/scripts/check-doc-formula-expressions.mjs`'s field-rule epilogue still said
1837
+ a faulting field-level `visibleWhen` is simply fail-OPEN. Under a host that publishes a
1838
+ scope the predicate RESOLVES instead: the control is hidden in that one form while no
1839
+ server-side gate evaluates a field-level `visibleWhen` at all, so every other reader still
1840
+ returns the value — a silent enforcement gap, and the worse of the two outcomes. The
1841
+ fault-open leg is kept rather than replaced, because it is still what happens wherever no
1842
+ host publishes a scope. The verdict is untouched and the message says why it is now *more*
1843
+ justified.
1844
+
1845
+ Both replacement texts carry the two qualifications the retired claim's correction needs, so
1846
+ "sections bind `current_user`" cannot be read as an authorization primitive: the binding is
1847
+ **client-side only** (nothing on the write path evaluates a form-view field or section
1848
+ `visibleWhen` — it evaluates field `readonlyWhen` / `requiredWhen` and per-option
1849
+ `visibleWhen`, and that is the whole list), and **the scope belongs to the host**, so it is
1850
+ empty on the public `/f/:slug` route and the predicate faults open there.
1851
+
1852
+ The epilogue is a plain string nobody else read — deleting the re-measured clause broke no
1853
+ assertion and turned no gate red, which is exactly how the stale claim outlived its sibling.
1854
+ It is now pinned by a `--self-test` case that scopes itself to the real epilogue (so it
1855
+ cannot satisfy itself from its own literal) and asserts both outcomes plus the surviving
1856
+ fault-open leg. Proven capable of failing by ablation: reverting the clause on disk turns
1857
+ the self-test red, and restoring returns it to green.
1858
+ - 8e03393: Derive the runtime publish gate's context-collection set from `RuntimeStackContext` instead of hand-listing it.
1859
+
1860
+ `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.
1861
+
1862
+ 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.
1863
+ - f394614: fix(lint): give `validate-translation-references` its `_tabs` leg, and pin the object branch against the schema (#13835)
1864
+
1865
+ `validate-translation-references` walked the object branch's `fields`,
1866
+ `fields.*.options`, `_views`, `_sections` and `_actions`, but never `_tabs` —
1867
+ the string did not appear in the rule at all. The result was an **asymmetry**
1868
+ rather than a coverage gap: `collectExpectedEntries` already emits
1869
+ `objects.<object>._tabs.<tab>.label` and `os i18n check` demands a translation
1870
+ for it, while nothing told an author that a `_tabs` key they wrote named a
1871
+ filter preset that no longer exists. Rename a preset and the bundle keeps its
1872
+ old key: the tab bar renders in the source locale above a fully localized grid,
1873
+ with every gate green. This is the same shape #11608 identified for `flows`, one
1874
+ group over, and it was missed only because `_tabs` arrived after the object
1875
+ branch was written.
1876
+
1877
+ The object branch now reports `translation-target-unknown` for a `_tabs` key no
1878
+ page declares, with the usual near-miss suggestion and an enumeration of the
1879
+ real preset names.
1880
+
1881
+ The universe is collected from `page.interfaceConfig.userFilters.tabs[].name`,
1882
+ de-duplicated per object, mirroring `walkObjectTabs`
1883
+ (`packages/cli/src/utils/i18n-extract.ts`) rather than re-deriving it. Three
1884
+ shape facts are asked of the code that owns them:
1885
+
1886
+ - **`ViewTabSchema` has two carriers and only one is live.** The page-only
1887
+ preset bar (`UserFiltersSchema.tabs`, ADR-0047) is what objectui's
1888
+ `TabFilters` draws and what `translateInterfaceTabs` resolves;
1889
+ `ListViewSchema.tabs` has no renderer in either repo. Registering the dead
1890
+ carrier would make legal a key nothing resolves, so only the live one counts —
1891
+ and the hint says so when an object declares no preset bar, since otherwise an
1892
+ author reads the finding as a bug in the rule.
1893
+ - **The object binds through `interfaceConfig.source` first, then the page's own
1894
+ `object`** — the order both the resolver and the extractor use.
1895
+ - **Source-authored pages (`kind: 'html' | 'react' | 'jsx'`) are read here**,
1896
+ unlike in the component walk that skips their derived `regions` cache:
1897
+ `interfaceConfig` is authored metadata at the page root, and neither consumer
1898
+ consults `kind`.
1899
+
1900
+ Also lands the hardening the issue proposed: the object branch's coverage is now
1901
+ **pinned against `ObjectTranslationDataSchema`'s key set**, so the next group
1902
+ added to the shape cannot land without a leg here. The ledger classifies every
1903
+ declared key as either reference-checked or leaf copy, holds its key set equal to
1904
+ the schema's, and requires each `reference-checked` claim to be backed by a
1905
+ bundle that actually produces a finding — so claiming coverage costs a working
1906
+ leg rather than a line in a table.
1907
+
1908
+ Advisory-only, as the whole rule is: findings are warnings, nothing new is
1909
+ refused at publish, and no bundle that resolved before reports now.
1910
+ - f213793: `validate-translation-references` now checks the `flows` group — an authored key naming a
1911
+ flow, screen node or screen field that does not exist warns instead of resolving to nothing
1912
+
1913
+ The rule walked `objects`, `globalActions`, `apps` and `dashboards`; an unrecognised
1914
+ top-level namespace is skipped and never reported, and `flows` was one of them. So a
1915
+ bundle keyed to `flows.<name>.screens.<node_id>.fields.<field_name>` parsed, shipped, and
1916
+ silently resolved to nothing — the wizard rendering its source-locale string while every
1917
+ other label on the screen was translated, which is the exact failure this rule exists for,
1918
+ one namespace over.
1919
+
1920
+ All three levels are exact-match identifiers with an enumerable universe, so the leg
1921
+ mirrors the `dashboards` → `widgets` leg one level further: flow → `Flow.name`, screen →
1922
+ `FlowNode.id` on `type: 'screen'` nodes, field → `ScreenFieldConfig.name`. Findings are
1923
+ `warning`, like every other finding in this rule (ADR-0072 D1 — an orphan key is inert,
1924
+ not broken), and each names the declared universe it resolved against.
1925
+
1926
+ Two shape facts the collector respects, both measured against the schemas rather than
1927
+ assumed — either one read the obvious way would have made the leg a false-positive
1928
+ generator:
1929
+
1930
+ - **Screen nodes nest.** A screen inside an ADR-0031 region (`loop.config.body`,
1931
+ `parallel.config.branches[].nodes`, `try_catch.config.try`/`.catch`) is a real screen the
1932
+ runner pauses on, so the universe is collected through `walkFlowNodes` rather than the
1933
+ flat `flow.nodes`.
1934
+ - **`ScreenConfigSchema` has two mutually exclusive shapes.** An object-form screen
1935
+ (`config.objectName`) renders that object's own create/edit form and declares no
1936
+ `config.fields`; its input labels resolve through `objects.<objectName>.fields.*`, so a
1937
+ field key there is reported with that redirect rather than a bare "not declared".
1938
+
1939
+ A key naming a node that exists but is not a `screen` is diagnosed as the wrong node type,
1940
+ not as a missing node.
1941
+ - 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
1942
+
1943
+ `checkViewCompleteness`'s `VIEW_BINDING_BLOCKS` table named `kanban` / `calendar` / `gantt` only, while
1944
+ `ListViewSchema.type` has six members with a type-specific binding block. The other three fell through
1945
+ the same trapdoor the rule exists to close: objectui's ListView adapter falls back to literal field
1946
+ names (`timeline` → `startDateField || 'created_at'`, `titleField || 'name'`; `map` →
1947
+ `locationField || 'location'`; `tree` → `labelField || titleField || 'name'`), so a view authored
1948
+ without its block rendered empty — `timeline` drops every row whose start date fails to parse —
1949
+ while `os validate --json` reported `warnings: []` and `valid: true`. Measured both ways: deleting a
1950
+ `timeline` block was silent, deleting the sibling `gantt` block warned as designed.
1951
+
1952
+ - The table now names all six. Each new entry carries a `fix` hint naming the keys that make the block
1953
+ a binding (`timeline`'s two schema-required keys; either coordinate form for `map`; `parentField` +
1954
+ `labelField` for `tree`). Severity stays `warning` (ADR-0078 §1 — the view degrades, it does not die).
1955
+ - `map` is read for its coordinate binding, not merely for block presence: `ListMapConfigSchema` requires
1956
+ no key, so a `map` block declaring neither `locationField` nor the `latitudeField`/`longitudeField`
1957
+ pair is the same unbound view with braces and is warned about at `map.locationField`.
1958
+ - **New rule `view/tree-without-parent-field`** (warning, path `tree.parentField`): a `type: 'tree'` view
1959
+ with no declared `parentField` on an object that carries neither a `tree` field nor a
1960
+ `lookup`/`master_detail` back to itself renders FLAT — every record at depth 0, a correct-looking table
1961
+ whose expand slot never opens. Every `TreeConfigSchema` key is optional, so `tree: {}` satisfies the
1962
+ block check and still renders flat; this rule mirrors objectui's `detectParentField` exactly, so a
1963
+ view the renderer resolves by auto-detection is never warned about.
1964
+ - `checkViewCompleteness(view, boundObject?)` takes the bound object definition as an optional second
1965
+ argument (additive; one-argument callers are unchanged and the tree rule stays silent for them).
1966
+ `@objectstack/lint`'s `validate-functional-completeness` resolves the object by name from
1967
+ `stack.objects` — the list view's own `data.object` first, then the container's binding — and hands
1968
+ it over; no rule logic moved into lint.
1969
+ - `gallery` is measured (`titleField || 'name'`) and deliberately not added: its schema has no binding key
1970
+ to demand. `page` stays deliberately absent (a `page` view refuses at parse via `checkListViewPageMount`).
1971
+
1972
+ Under `os validate --strict` the new warnings are failures, as every warning in this family is.
1973
+ - 4b2cbf7: fix(lint): the three write-set rule messages now state the refusal authors actually get, not a retired driver split (#13858)
1974
+
1975
+ Message text only. Rule ids, severities, match sets and hints are untouched, and
1976
+ no finding changes shape — but a lint's own header states why the prose is
1977
+ governed: *"a lint that misdescribes the failure it is warning about teaches the
1978
+ wrong debugging instinct"*. These three sentences did.
1979
+
1980
+ `validate-hook-body-writes` (the `ctx.api` branch), `validate-action-body-writes`
1981
+ and `validate-flow-node-writes` all told the author that an undeclared write has
1982
+ a **driver-dependent** outcome:
1983
+
1984
+ > 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
1985
+
1986
+ For the paths those three rules judge, that has not been true since the
1987
+ declared-field door landed (#8682 insert, #8738 update). All three describe a
1988
+ write whose payload is **caller-supplied**, not a mutation of an in-flight
1989
+ `ctx.input`: `ctx.api` is a `ScopedContext` over the running engine, and a flow
1990
+ node hands its `fields` map to the data engine directly. The door refuses a
1991
+ caller-named undeclared key from the object's field map **before any statement is
1992
+ built**, so no driver is reached and there is no split to observe.
1993
+
1994
+ Measured before the prose was rewritten — all three paths, both driver families,
1995
+ through a real QuickJS sandbox, a real `ObjectQL` engine, the real
1996
+ `AutomationEngine` with the real builtin CRUD node executors, real
1997
+ `@objectstack/driver-sql` (better-sqlite3) and real `@objectstack/driver-memory`:
1998
+
1999
+ | path | driver-sql | driver-memory |
2000
+ |---|---|---|
2001
+ | hook body `ctx.api.object(x).update({…})` | `INVALID_FIELD` / 400 | `INVALID_FIELD` / 400 |
2002
+ | action body `ctx.api.object(x).update({…})` | `INVALID_FIELD` / 400 | `INVALID_FIELD` / 400 |
2003
+ | flow `create_record` / `update_record` `fields` | `INVALID_FIELD` / 400 | `INVALID_FIELD` / 400 |
2004
+
2005
+ Every run answered `Unknown field 'stagee' on object 'deal'`; nothing was stored
2006
+ on either family, and the schemaless family kept **no** shadow column — the half
2007
+ the old message promised and the runtime no longer delivers.
2008
+
2009
+ The three messages now name that refusal in the vocabulary the `ctx.input`
2010
+ sibling landed with (`REFUSED at run time — INVALID_FIELD / 400, identically on
2011
+ every driver`), say why the door and not a driver answers, and keep each path's
2012
+ own blast radius: the hook refusal fails the operation that triggered the hook,
2013
+ the action refusal fails the action, and the flow node's refusal is whole — the
2014
+ correctly named fields in the same payload never land either, `create_record`
2015
+ never creates the row, and the step fails the run. That last clause is why the
2016
+ flow rule still gates at `error`; the severity is unchanged.
2017
+
2018
+ `unprovisionedAnchorWriteConsequence()` in the same files is **untouched**: an
2019
+ ADR-0015 external object's injected anchor *is* declared in the registered
2020
+ schema, so it passes the door by construction and the remote database really is
2021
+ what refuses it. That message was already correct.
2022
+ - Updated dependencies [809d417]
2023
+ - Updated dependencies [387e231]
2024
+ - Updated dependencies [f794e4e]
2025
+ - Updated dependencies [cae2169]
2026
+ - Updated dependencies [b812a54]
2027
+ - Updated dependencies [2d4fa75]
2028
+ - Updated dependencies [0e4e51b]
2029
+ - Updated dependencies [e84bbf6]
2030
+ - Updated dependencies [effae80]
2031
+ - Updated dependencies [d62f990]
2032
+ - Updated dependencies [c45d8e6]
2033
+ - Updated dependencies [2e3e8c7]
2034
+ - Updated dependencies [e621291]
2035
+ - Updated dependencies [40a93b5]
2036
+ - Updated dependencies [d5b330d]
2037
+ - Updated dependencies [dda969c]
2038
+ - Updated dependencies [1f45690]
2039
+ - Updated dependencies [277948f]
2040
+ - Updated dependencies [8bdd955]
2041
+ - Updated dependencies [f3bbbef]
2042
+ - Updated dependencies [4f24e9d]
2043
+ - Updated dependencies [474242f]
2044
+ - Updated dependencies [63cd487]
2045
+ - Updated dependencies [bd4aa4e]
2046
+ - Updated dependencies [803eaab]
2047
+ - Updated dependencies [f8e8f03]
2048
+ - Updated dependencies [eae824e]
2049
+ - Updated dependencies [f6fa22c]
2050
+ - Updated dependencies [8a483b3]
2051
+ - Updated dependencies [97bcd99]
2052
+ - Updated dependencies [df59de0]
2053
+ - Updated dependencies [96e25a8]
2054
+ - Updated dependencies [713f83f]
2055
+ - Updated dependencies [77d4b3c]
2056
+ - Updated dependencies [f75a38a]
2057
+ - Updated dependencies [7a25e7d]
2058
+ - Updated dependencies [1fa05a6]
2059
+ - Updated dependencies [c85a265]
2060
+ - Updated dependencies [dcb10a5]
2061
+ - Updated dependencies [773a999]
2062
+ - Updated dependencies [35dffea]
2063
+ - Updated dependencies [776a098]
2064
+ - Updated dependencies [5060877]
2065
+ - Updated dependencies [4f6325d]
2066
+ - Updated dependencies [52954c0]
2067
+ - Updated dependencies [2aa8456]
2068
+ - Updated dependencies [93809a3]
2069
+ - Updated dependencies [7c0d0c3]
2070
+ - Updated dependencies [daae7aa]
2071
+ - Updated dependencies [8dc22d6]
2072
+ - Updated dependencies [279431e]
2073
+ - Updated dependencies [948dd6b]
2074
+ - Updated dependencies [3b4c56c]
2075
+ - Updated dependencies [ae8edd2]
2076
+ - Updated dependencies [e25403c]
2077
+ - Updated dependencies [64baa68]
2078
+ - Updated dependencies [9fa70d7]
2079
+ - Updated dependencies [09db64a]
2080
+ - Updated dependencies [92916e7]
2081
+ - Updated dependencies [a84f3ea]
2082
+ - Updated dependencies [f2eaae8]
2083
+ - Updated dependencies [c09451b]
2084
+ - Updated dependencies [ba64877]
2085
+ - Updated dependencies [7345308]
2086
+ - Updated dependencies [79b6a22]
2087
+ - Updated dependencies [30d96ab]
2088
+ - Updated dependencies [f658793]
2089
+ - Updated dependencies [c95ad19]
2090
+ - Updated dependencies [e58ea8b]
2091
+ - Updated dependencies [4a17645]
2092
+ - Updated dependencies [3795c5f]
2093
+ - Updated dependencies [8ab926b]
2094
+ - Updated dependencies [7317cf2]
2095
+ - Updated dependencies [e25e839]
2096
+ - Updated dependencies [5997207]
2097
+ - Updated dependencies [8b13cc8]
2098
+ - Updated dependencies [4a4a35d]
2099
+ - Updated dependencies [4a4a35d]
2100
+ - Updated dependencies [86e765a]
2101
+ - Updated dependencies [1d7e76a]
2102
+ - Updated dependencies [53dc739]
2103
+ - Updated dependencies [fd289be]
2104
+ - Updated dependencies [03bf7b1]
2105
+ - Updated dependencies [f90e820]
2106
+ - Updated dependencies [18d816a]
2107
+ - Updated dependencies [e8bd715]
2108
+ - Updated dependencies [b91c351]
2109
+ - Updated dependencies [a28a3c0]
2110
+ - Updated dependencies [daeaaf9]
2111
+ - Updated dependencies [c459da6]
2112
+ - Updated dependencies [e914733]
2113
+ - Updated dependencies [1d8ad0f]
2114
+ - Updated dependencies [9738c35]
2115
+ - Updated dependencies [f887e52]
2116
+ - Updated dependencies [881f8d8]
2117
+ - Updated dependencies [3bfa1e6]
2118
+ - Updated dependencies [901355c]
2119
+ - Updated dependencies [34ce8e7]
2120
+ - Updated dependencies [33681ea]
2121
+ - Updated dependencies [4635f3e]
2122
+ - Updated dependencies [ee3595c]
2123
+ - Updated dependencies [b2eab95]
2124
+ - Updated dependencies [93940d4]
2125
+ - Updated dependencies [3a04b01]
2126
+ - Updated dependencies [45b9051]
2127
+ - Updated dependencies [b9e9227]
2128
+ - Updated dependencies [d395692]
2129
+ - Updated dependencies [5894d30]
2130
+ - Updated dependencies [a3765f6]
2131
+ - Updated dependencies [e22158f]
2132
+ - Updated dependencies [7404925]
2133
+ - Updated dependencies [0c2334f]
2134
+ - Updated dependencies [778c59f]
2135
+ - Updated dependencies [d2619fd]
2136
+ - Updated dependencies [6acb11a]
2137
+ - Updated dependencies [33c5fd3]
2138
+ - Updated dependencies [20b0fdb]
2139
+ - Updated dependencies [905019b]
2140
+ - Updated dependencies [a286411]
2141
+ - Updated dependencies [98c0d33]
2142
+ - Updated dependencies [368a82e]
2143
+ - Updated dependencies [a3d5724]
2144
+ - Updated dependencies [93ea19b]
2145
+ - Updated dependencies [9ee2dcf]
2146
+ - Updated dependencies [8cb96ec]
2147
+ - Updated dependencies [8f10a79]
2148
+ - Updated dependencies [6269a55]
2149
+ - Updated dependencies [0fb8760]
2150
+ - Updated dependencies [e5ce2ed]
2151
+ - Updated dependencies [be21955]
2152
+ - Updated dependencies [bc56e18]
2153
+ - Updated dependencies [be21955]
2154
+ - Updated dependencies [a9ee989]
2155
+ - Updated dependencies [4d0d944]
2156
+ - Updated dependencies [15d58db]
2157
+ - Updated dependencies [d63b014]
2158
+ - Updated dependencies [9abe4e4]
2159
+ - Updated dependencies [2cc7122]
2160
+ - Updated dependencies [50d6c92]
2161
+ - Updated dependencies [9e0ba21]
2162
+ - Updated dependencies [311433f]
2163
+ - Updated dependencies [3e5ad08]
2164
+ - Updated dependencies [9abe4e4]
2165
+ - Updated dependencies [b7131f3]
2166
+ - Updated dependencies [e5812fa]
2167
+ - Updated dependencies [7085f90]
2168
+ - Updated dependencies [dee4dd4]
2169
+ - Updated dependencies [ce7e497]
2170
+ - Updated dependencies [51ecb2f]
2171
+ - Updated dependencies [9086761]
2172
+ - Updated dependencies [42a117b]
2173
+ - Updated dependencies [1401ae7]
2174
+ - Updated dependencies [4297fe7]
2175
+ - Updated dependencies [e398863]
2176
+ - Updated dependencies [d16df74]
2177
+ - Updated dependencies [f11fc61]
2178
+ - Updated dependencies [e808890]
2179
+ - Updated dependencies [8f79379]
2180
+ - Updated dependencies [e6ca40e]
2181
+ - Updated dependencies [0c77ea4]
2182
+ - Updated dependencies [52954c0]
2183
+ - Updated dependencies [89eb997]
2184
+ - Updated dependencies [aa5994e]
2185
+ - Updated dependencies [be93457]
2186
+ - Updated dependencies [a65db76]
2187
+ - Updated dependencies [15eb2c9]
2188
+ - Updated dependencies [5691b07]
2189
+ - Updated dependencies [2a6122b]
2190
+ - Updated dependencies [225e769]
2191
+ - Updated dependencies [8af88dd]
2192
+ - Updated dependencies [fb5fbb8]
2193
+ - Updated dependencies [d7b3963]
2194
+ - Updated dependencies [b72db01]
2195
+ - Updated dependencies [dce5cd4]
2196
+ - Updated dependencies [177ebdc]
2197
+ - Updated dependencies [8d237b4]
2198
+ - Updated dependencies [2d2e6f0]
2199
+ - Updated dependencies [2d8dd8d]
2200
+ - Updated dependencies [22d573e]
2201
+ - Updated dependencies [b5a2398]
2202
+ - Updated dependencies [348860c]
2203
+ - Updated dependencies [5383fa6]
2204
+ - Updated dependencies [5b3ff63]
2205
+ - Updated dependencies [1a6a19c]
2206
+ - Updated dependencies [527e050]
2207
+ - Updated dependencies [dd33bf9]
2208
+ - Updated dependencies [4cb2a90]
2209
+ - Updated dependencies [74a7804]
2210
+ - Updated dependencies [53d3689]
2211
+ - Updated dependencies [b3a63d3]
2212
+ - Updated dependencies [033a34c]
2213
+ - Updated dependencies [4d25d22]
2214
+ - Updated dependencies [1ffee51]
2215
+ - Updated dependencies [5ae4303]
2216
+ - Updated dependencies [ece4dad]
2217
+ - Updated dependencies [e9b377e]
2218
+ - Updated dependencies [146f448]
2219
+ - Updated dependencies [735f5c7]
2220
+ - Updated dependencies [a7e18de]
2221
+ - Updated dependencies [366f895]
2222
+ - Updated dependencies [dc75ba8]
2223
+ - Updated dependencies [2182bd1]
2224
+ - Updated dependencies [2a5c1cd]
2225
+ - Updated dependencies [34f60b7]
2226
+ - Updated dependencies [0e68ed2]
2227
+ - Updated dependencies [8beb3de]
2228
+ - Updated dependencies [4a9f461]
2229
+ - Updated dependencies [cce0aa9]
2230
+ - Updated dependencies [e764507]
2231
+ - Updated dependencies [cff17af]
2232
+ - Updated dependencies [39404f3]
2233
+ - Updated dependencies [ca1965f]
2234
+ - Updated dependencies [8619f95]
2235
+ - Updated dependencies [b706af9]
2236
+ - Updated dependencies [fc9ba76]
2237
+ - Updated dependencies [0f94cc7]
2238
+ - Updated dependencies [a11c1a5]
2239
+ - Updated dependencies [71f9cd1]
2240
+ - Updated dependencies [ee17d86]
2241
+ - Updated dependencies [cdbd920]
2242
+ - Updated dependencies [18c432e]
2243
+ - Updated dependencies [3c418c4]
2244
+ - Updated dependencies [fa8715a]
2245
+ - Updated dependencies [a933ed7]
2246
+ - Updated dependencies [b3ca463]
2247
+ - Updated dependencies [a933ed7]
2248
+ - Updated dependencies [0d4a6a8]
2249
+ - Updated dependencies [518d5e5]
2250
+ - Updated dependencies [6643ba1]
2251
+ - Updated dependencies [eeba2ef]
2252
+ - Updated dependencies [ec4c4d2]
2253
+ - Updated dependencies [424f73c]
2254
+ - Updated dependencies [cccbe51]
2255
+ - Updated dependencies [a8d6b1d]
2256
+ - Updated dependencies [e4a7695]
2257
+ - Updated dependencies [87075b1]
2258
+ - Updated dependencies [fc58a99]
2259
+ - Updated dependencies [14cfc00]
2260
+ - Updated dependencies [1c6f7b4]
2261
+ - Updated dependencies [e854a53]
2262
+ - Updated dependencies [dfebfc8]
2263
+ - Updated dependencies [d028b37]
2264
+ - Updated dependencies [122ef38]
2265
+ - Updated dependencies [4a37870]
2266
+ - Updated dependencies [428f9b2]
2267
+ - Updated dependencies [aa7ff56]
2268
+ - Updated dependencies [c41b42e]
2269
+ - Updated dependencies [c4db311]
2270
+ - Updated dependencies [750fff5]
2271
+ - Updated dependencies [c19035e]
2272
+ - Updated dependencies [ececf7a]
2273
+ - Updated dependencies [d173125]
2274
+ - Updated dependencies [8eeca27]
2275
+ - Updated dependencies [8425c17]
2276
+ - Updated dependencies [a5ef1d8]
2277
+ - Updated dependencies [772d5de]
2278
+ - Updated dependencies [ce80ec2]
2279
+ - Updated dependencies [b372318]
2280
+ - Updated dependencies [97a2263]
2281
+ - Updated dependencies [29d0676]
2282
+ - Updated dependencies [0169d49]
2283
+ - Updated dependencies [6bd3231]
2284
+ - Updated dependencies [d2b5ba8]
2285
+ - Updated dependencies [b799ac5]
2286
+ - Updated dependencies [8f74307]
2287
+ - Updated dependencies [d23dc08]
2288
+ - Updated dependencies [038f333]
2289
+ - Updated dependencies [644ad50]
2290
+ - Updated dependencies [0da7cd2]
2291
+ - Updated dependencies [28a5c3e]
2292
+ - Updated dependencies [4bc18e5]
2293
+ - @objectstack/spec@17.3.0
2294
+ - @objectstack/formula@17.3.0
2295
+ - @objectstack/sdui-parser@17.3.0
2296
+
3
2297
  ## 17.2.0
4
2298
 
5
2299
  ### Minor Changes