@objectstack/lint 16.1.0 → 17.0.0-rc.1

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