@objectstack/lint 17.0.0 → 17.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,627 @@
1
1
  # @objectstack/lint
2
2
 
3
+ ## 17.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 13d7864: Dashboard writes are now judged by `validateWidgetBindings` at the runtime publish gate (#7529). A dashboard widget bound to a dataset that resolves to nothing — previously a `200` on both save and publish, failing only as a runtime error on the live board — is refused at **publish** with a located 422 (`INVALID_METADATA`, the offending key path named). Drafts are unaffected: a draft may still hold a forward reference to a dataset not yet authored, and only the draft→active promotion runs the gate.
8
+
9
+ Because rule surfaces are registered per-rule, all six of the rule's error-tier findings now gate a dashboard publish as one reference-integrity class: `widget-dataset-unknown`, `widget-dimension-unknown`, `widget-measure-unknown`, `chart-field-unknown`, `widget-legacy-analytics-unrenderable`, `dashboard-filter-field-unknown`. Warning-tier findings (`table-count-only`, `chart-config-missing`, …) ride the non-blocking `advisories` channel on the save response. Config-authored stacks are unaffected — `os validate` / `os build` / `os lint` already ran this rule; the newly gated population is exactly the `sys_metadata` overlay writes (Studio / REST `/meta` / MCP) that previously bypassed it.
10
+
11
+ The per-write snapshot (`RuntimeStackContext`) now carries the live `datasets` collection so bindings resolve against the real dataset universe — without it every legitimate board would read as dangling. Existing stored rows are untouched (the gate blocks new publishes only), and `OS_ALLOW_UNLINTED_METADATA_WRITES=1` remains the migration-window escape hatch.
12
+ - 8640fb2: `os validate`: a dashboard header `modal` action's target resolves against declared PAGES, only (#9013)
13
+
14
+ `validateDashboardActionRefs` resolved an `actionType: 'modal'` header button's
15
+ `actionUrl` the way objectui's `DashboardView` used to dispatch it: a defined
16
+ action name, a bare object name, or the `<verb>_<object>` prefix form
17
+ (`create_`/`new_`/`add_`/`edit_`/`update_` + a defined object) all passed, and a
18
+ target naming a declared page ERRORED unless it collided with one of those.
19
+
20
+ That mirror is gone. Maintainer ruling objectstack#6739-A (2026-08-09): a
21
+ `type: 'modal'` string target names a PAGE, only — the spec TSDoc, the published
22
+ docs and `defineStack`'s cross-reference walk already said so, and objectui#4764
23
+ / objectui#4782 retired the renderer's object fallback and `DashboardView`'s
24
+ second copy of the prefix convention (enumerated across both repos' corpora:
25
+ zero producers). After that, `os validate` blessed exactly the buttons the
26
+ runtime refuses — the false affordance the rule exists to eliminate — while
27
+ refusing the one shape the runtime serves.
28
+
29
+ **BREAKING** accept-set change on the `os validate` gating tier (landing after
30
+ the v17.0.0 cut; the lockstep launch-window convention ships it as `minor`):
31
+
32
+ - A `modal` header target naming a defined action, a bare object, or a
33
+ `<verb>_<object>` form now **fails** validation. Those buttons already
34
+ dispatch to a named refusal at runtime.
35
+ - A `modal` header target naming a declared page now **passes** — it was
36
+ wrongly refused before.
37
+
38
+ ## FROM → TO
39
+
40
+ ```ts
41
+ // before — passed validation; the runtime now refuses the click
42
+ header: {
43
+ actions: [{ label: 'New Deal', actionType: 'modal', actionUrl: 'create_opportunity' }],
44
+ }
45
+
46
+ // after — name a declared page…
47
+ header: {
48
+ actions: [{ label: 'Intake', actionType: 'modal', actionUrl: 'deal_intake' }], // pages: [{ name: 'deal_intake' }]
49
+ }
50
+ // …or, to open an object's form, use the validated first-class shape
51
+ header: {
52
+ actions: [{ label: 'New Deal', actionType: 'form', actionUrl: 'opportunity.edit' }],
53
+ }
54
+ ```
55
+
56
+ There is deliberately no automatic rewrite: a retired-shape target is a
57
+ name-shaped guess (`create_opportunity` names the page `create_opportunity`, or
58
+ it names nothing — the ruling explicitly declined keeping the prefix), and only
59
+ the author knows whether the button meant a page or an object form.
60
+ `objectstack migrate meta` surfaces the change as a structured TODO (semantic
61
+ entry `dashboard-header-modal-target-page-only`, protocol major 18).
62
+
63
+ <!-- adr-0087: registered dashboard-header-modal-target-page-only -->
64
+ - 8b9eba5: feat(spec): field-level `relatedListFilter` — a declarative default filter for auto-derived related lists (#8704)
65
+
66
+ <!-- adr-0087: not-required (no-migration-prescription) Pure accept-set
67
+ widening: one new optional key joins the existing field-level related-list
68
+ family. Nothing is renamed, retired, narrowed or tombstoned, so there is no
69
+ conversion to register and no retirement registry entry. -->
70
+
71
+ The field-level related-list family (`relatedList` / `relatedListTitle` /
72
+ `relatedListColumns`) gains its fourth member, `relatedListFilter` — closing the
73
+ gap where the only way to filter an auto-derived related list was to abandon the
74
+ auto-derived record page for a hand-written `record:related_list` page
75
+ (maintainer ruling 2026-08-15 on #8704).
76
+
77
+ - **No new filter dialect**: the key carries the canonical Query-DSL
78
+ `FilterCondition` (the same authoring face as a query `where`, dataset scope
79
+ filters, and `summaryOperations.filter`). The FILTER-axis doors therefore
80
+ apply automatically — the schema door refuses bare date-range preset
81
+ comparands in ordering positions at parse (#8793), and the engine doors judge
82
+ the composed query at run time (`formula` keys refused `INVALID_FIELD`,
83
+ #8296).
84
+ - **Contract semantics, pinned**: the declared constraint is AND-composed with
85
+ the parent-relationship condition `{ [referenceField]: parentId }` — an
86
+ authored constraint, never a user-editable suggestion — and the related-list
87
+ tab badge count honors the same composed filter, so counts match visible
88
+ rows. Both clauses are normative in the key's contract text and pinned by
89
+ tests.
90
+ - **`@objectstack/lint`**: the shared authored-filter walk (`FILTER_KEYS`) now
91
+ recognizes `relatedListFilter`, extending the filter-token, empty-combinator
92
+ and preset-comparand rules to the new position.
93
+
94
+ The consumption half (RecordDetailView auto-derivation + tab badge) is
95
+ objectui#4664, `Blocked-by:` this change; until it lands the key is ledgered
96
+ `planned` with an author warning.
97
+ - a777944: feat(spec,lint): refuse a bare date-range preset name in an ordering filter comparand at publish time (#8793 — the ruled C half of #8690)
98
+
99
+ **BREAKING** accept-set narrowing on a published authoring surface, landing
100
+ after the v17.0.0 cut (the lockstep launch-window convention ships it as
101
+ `minor`; the migration prescription is registered under protocol major 18).
102
+
103
+ `last_7_days` / `last_30_days` / `last_90_days` and their ten calendar
104
+ siblings are real, declared preset names — for the dashboard date-filter
105
+ positions, where the console lowers them to `{date-macro}` bounds before any
106
+ query is sent. Authored as a bare filter comparand nothing resolves them:
107
+ measured on #8690, `$gte "last_30_days"` returned HTTP 200 with 0 of 51 rows
108
+ where `$gte "{30_days_ago}"` returned the 38 in-window. The engine now
109
+ refuses the bare name on a declared temporal field at query time
110
+ (`INVALID_FILTER` / 400, PR #8808 — the B half); this change is the
111
+ authoring-time half the same ruling shipped alongside it.
112
+
113
+ **What is refused — ordering positions only, in all three authored filter
114
+ shapes:** a `$gt` / `$gte` / `$lt` / `$lte` comparand or `$between` endpoint
115
+ on every carrier of `FilterConditionSchema` (dashboard widget filter, dataset
116
+ filter, report `runtimeFilter`, page/component filter, rollup filter), a
117
+ `greater_than` / `less_than` / `before` / `after` / `between` view filter
118
+ rule value, and an ordering `[field, op, value]` filter triple (the latter
119
+ two via `@objectstack/lint`'s new gating rule `filter-preset-comparand`,
120
+ which also runs at the runtime publish gate for `dashboard` / `view` /
121
+ `object` / `page` / `flow` writes). The refusal names the offending value,
122
+ the position, and the exact `{date-macro}` window that works.
123
+
124
+ **What stays accepted:** the preset names in the dashboard date-filter
125
+ positions (`dateRange.defaultRange`, a date global filter's `defaultValue`) —
126
+ the only positions any layer ever resolved them; equality and membership
127
+ comparands (`{ period: 'this_quarter' }`, `$in: [...]`) — a select/picklist
128
+ column legitimately stores colliding values, and the engine's field-typed
129
+ door already covers the temporal case; undeclared strings
130
+ (`'not-a-date-at-all'`) — the field-typed engine door owns those; and the
131
+ empty-string cell, which stays its own card by ruling.
132
+
133
+ ## FROM → TO
134
+
135
+ ```ts
136
+ // before — parsed green, returned a silent zero (or 400 at query time since #8808)
137
+ filter: { closed_at: { $gte: 'last_30_days' } }
138
+
139
+ // after — rejected naming the window; write the date-macro spelling
140
+ filter: { closed_at: { $gte: '{30_days_ago}' } }
141
+ // calendar presets prescribe their pair:
142
+ filter: { closed_at: { $between: ['{week_start}', '{week_end}'] } }
143
+ ```
144
+
145
+ `DATE_RANGE_PRESETS` moved to `@objectstack/spec/data`
146
+ (`data/date-range-presets.ts`) with `ui` re-exporting it, so both import
147
+ paths keep working; `DATE_RANGE_PRESET_MACRO_WINDOWS` (the per-preset macro
148
+ window table the refusals quote) and `isDateRangePresetName` are new exports.
149
+
150
+ <!-- adr-0087: registered filter-preset-ordering-comparand-refused -->
151
+ - 73cfddf: fix(lint): the ADR-0091 D3 "delegation row needs a reason" rule is scoped to `sys_user_position` (#9730)
152
+
153
+ `delegated_from` was retired from `sys_user_permission_set` (ADR-0049
154
+ enforce-or-remove, maintainer ruling 2026-08-18), so the security-posture
155
+ lint's D3 dual-audit rule no longer reads the key on that table — linting a
156
+ retired column would imply it still exists, and on that table this rule was
157
+ the column's *only* enforcement, which is exactly the advisory-security shape
158
+ the ruling removed. A seed row that still carries the key is refused loudly
159
+ downstream by the engine's schema preflight (`400 INVALID_FIELD`).
160
+
161
+ The D2 rule (a seed grant whose `valid_until` is already past or unparseable
162
+ is dead on arrival) still covers **both** grant tables — `valid_until` remains
163
+ declared and resolution-enforced on both. Only the two rules' object scopes
164
+ diverge; no rule id, severity or message changed.
165
+ - 1408ae3: feat(lint): the five gating object rules cross the runtime publish gate — `object` writes are now judged by `validateFunctionalCompleteness`, `validateManagedApiMethods`, `lintAutonumberFormats`, `validateRuleCompilability` and `validateRuleSchemaFormats` (#4716)
166
+
167
+ An `active`-state `object` save through `saveMetaItem` (Studio's field editor,
168
+ REST `/meta` item CRUD, an MCP/AI author) is now refused with the existing 422
169
+ `invalid_metadata` envelope when it carries a defect these five rules judge:
170
+ an inert `summary`/`lookup`/`select` shape, a managed-API verb the object's own
171
+ affordances refuse, an autonumber format referencing an unknown field, a
172
+ `format` regex or `json_schema` schema the runtime's own compilers reject, or a
173
+ `json_schema` `format` name ajv would silently drop. All five already gated
174
+ `os validate` / `os build` / `os lint`; the runtime door — the only door a
175
+ tenant overlay row has — ran none of them.
176
+
177
+ Scope is deliberately the five **gating** rules only (the #4716 adjudication):
178
+ the six advisory-tier object rules stay off the runtime surface, so a clean
179
+ save's response is byte-identical and no new advisory volume reaches Studio's
180
+ designer. Draft saves are untouched (D1), stored rows keep being served
181
+ (ADR-0087 asymmetry — the gate's differential blames a write only for what it
182
+ adds), and `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still degrades the refusal to
183
+ a loud log for migration windows.
184
+
185
+ Boot-path note: the two schema-judging rules load ajv lazily, only when the
186
+ judged snapshot actually carries a `json_schema` validation — an ordinary
187
+ field edit still loads no compiler, which `runtime-lazy-deps.test.ts` now pins
188
+ as a three-tier contract (parsers never; ajv never without a schema; ajv
189
+ required, on demand, when one is present).
190
+ - 6f5a449: The runtime publish gate judges a package write against that package's own closure (#9612)
191
+
192
+ The gate handed every rule the tenant's **entire** `objects` collection on every
193
+ publish. That is the wrong validation unit, not merely a large one: a tenant
194
+ that has grown to hundreds of objects is many packages, and judging one
195
+ package's write against all of them asks a question nobody wanted answered.
196
+ Per the maintainer's ruling, the unit is now the package —
197
+ 「客户开发开发,校验是否也应该基于软件包」·「当然这里面要考虑系统对象」.
198
+
199
+ `buildRuntimeWriteSnapshots` accepts an optional `packageScope`
200
+ (`{ packageId, dependencies }`) and reduces `objects` to that closure:
201
+
202
+ - the package being written;
203
+ - the transitive closure of its **declared** `manifest.dependencies` — a
204
+ package's declared dependencies bound what it may reference, so this is a set
205
+ the platform computes exactly rather than estimates;
206
+ - platform / system objects, **unconditionally** — a package legitimately
207
+ references `sys_*` objects it never declares, and a closure that dropped them
208
+ would report unresolved references that are not there;
209
+ - rows carrying no package provenance (tenant-authored overlays), because
210
+ nothing declares what they may reference and so nothing bounds them.
211
+
212
+ `ObjectStackProtocolImplementation` resolves that scope from the package
213
+ registry and passes it through `evaluateRuntimeAuthoringGate`.
214
+
215
+ **A write that names no package, or names one the registry cannot produce,
216
+ narrows nothing** and is judged exactly as before. That direction is the whole
217
+ design: an unresolvable package buys a write *more* validation input, never
218
+ less. There is no branch that skips rules, and none that skips them past a
219
+ size.
220
+
221
+ One behaviour change follows from the unit being right: a **package-scoped**
222
+ write that references an object in a package it never declared a dependency on
223
+ is now judged against a closure that does not contain it, so the reference is
224
+ reported. That is the ruling's intended consequence — such a reference is not
225
+ resolvable by declaration — and it applies only to writes that state a package.
226
+
227
+ Also exported: `narrowObjectsToPackageClosure` and the `RuntimePackageScope`
228
+ type from `@objectstack/lint` and `@objectstack/lint/runtime`, and
229
+ `isSystemObject` from the security-posture rule module so the closure and the
230
+ rules share one reading of what "system" means rather than two.
231
+ - b849e69: fix(lint): ask the provenance question at the fifth blanket-`SYSTEM_FIELDS` read site — `searchableFields` (#8404)
232
+
233
+ `validate-searchable-fields.ts` judged a declared `searchableFields` entry
234
+ against the object-independent `SYSTEM_FIELDS` union, exactly as the four
235
+ filter/page-binding rules did before #8340 wired them to the per-object index.
236
+ Both of its gates were correct about EXISTENCE and structurally blind to
237
+ PROVENANCE: `:345` keeps `searchable-field-unknown` silent for any name in the
238
+ union, and `resolveAllowedSet` goes further — it manufactures a stub meta for
239
+ such an entry so it survives the resolution's existence filter exactly as it
240
+ does at runtime.
241
+
242
+ On an ADR-0015 `external` object the platform registers its injected anchors
243
+ (`owner_id`, `organization_id`, the audit family, …) and provisions no storage
244
+ behind them (#7865 / #8116), so:
245
+
246
+ ```
247
+ searchableFields: ['name', 'owner_id'] // external object
248
+ ```
249
+
250
+ linted clean, the stub kept the entry in the resolved allow-list, and the
251
+ view's `$searchFields` narrowing then scanned a column empty on every record —
252
+ #4830's own failure mode (a narrower search than declared, silently) reached by
253
+ a different route.
254
+
255
+ A new `searchable-field-unprovisioned` rule now warns on such an entry, on the
256
+ object's own canonical set and on a list view's narrowing alike, reusing
257
+ `unprovisionedAnchorCause` / `unprovisionedAnchorHint` so the sentence matches
258
+ the four #8340 rules verbatim rather than becoming a second copy (#4830). WARN,
259
+ never gating, per #4330's cost asymmetry: the remote schema is not visible to
260
+ this pass, so the finding describes a degradation rather than a refusal.
261
+
262
+ **The `:239` stub is KEPT.** It is not incidental — it is what makes the linter's
263
+ resolution agree with the runtime's, which resolves the declared branch against
264
+ the registry field map. Measured by disabling it: the existing "keeps runtime
265
+ parity when the object declares system columns searchable" test goes red
266
+ (`expected [] to have a length of 1 but got +0`), because the declaration
267
+ existence-filters to empty and resolution falls through to the auto-default.
268
+ Dropping it would have been a behaviour change dressed as a warning.
269
+
270
+ The warning is emitted per declared entry in the checker's entry loop, never
271
+ inside `resolveAllowedSet` — that helper reads the OBJECT's declaration and runs
272
+ once per narrowing, so warning there would repeat one object-level fact for
273
+ every view and attribute it to the view's path.
274
+
275
+ `checkSearchableFieldList` takes the index as an OPTIONAL trailing parameter,
276
+ the same shape #8340 gave `checkFieldRefs`: its absence means the caller did not
277
+ build the index and the provenance question goes unasked — the previous
278
+ behaviour, preserved for out-of-repo callers (cloud graph-lint, the AI authoring
279
+ path). Both in-repo callers pass it.
280
+ - 71ac21c: feat(lint): a sharing rule anchored where sharing has nothing to widen is now an authoring-time error (#9698)
281
+
282
+ `validateSharingRuleEnforceability` gains its second arm. It already judged a
283
+ sharing rule's `condition` against the compiler that lowers it; it now judges
284
+ the rule's `object` against the verdict that decides whether the grant can
285
+ exist at all.
286
+
287
+ Two new `error` ids, both decidable from authored metadata before anything
288
+ boots, and both mirroring `SharingService.inertGrantReason` (ADR-0111 D7)
289
+ rather than modelling it:
290
+
291
+ - **`sharing-rule-object-not-shareable`** — the anchor object's effective
292
+ sharing model is `public` (an explicit `sharingModel: 'public_read_write'`,
293
+ or no `sharingModel` on a system object, which ADR-0090 D1 resolves to
294
+ public). Sharing only ever WIDENS an OWD baseline, so on the widest baseline
295
+ there is nothing to widen.
296
+ - **`sharing-rule-object-controlled-by-parent`** — the anchor is a
297
+ master-detail detail, whose visibility is derived from its master
298
+ (ADR-0055). It gets its own id and its own fix-it ("share the master
299
+ record instead"), because `effectiveSharingModel` collapses it onto the same
300
+ `public` verdict while the correct repair is completely different.
301
+
302
+ Both were previously accepted by `SharingRuleSchema`, accepted by `defineRule`,
303
+ seeded into `sys_sharing_rule`, and only then refused — once per boot, as a
304
+ WARN line inside the boot diagnostics block. That WARN is not a sufficient
305
+ diagnostic, and the reason is measured rather than argued: a rule whose criteria
306
+ match no seeded row never reaches `grant`, so it never throws and warns nothing
307
+ while being exactly as dead. The WARN is a function of the DATA; the defect is a
308
+ property of the DECLARATION.
309
+
310
+ **Blast radius, measured through `objectstack build` before deciding the
311
+ severity:** 5 sharing rules are declared in this repo. 3 fire, all of them in
312
+ `examples/app-crm` — `share_high_value_opps_with_managers`,
313
+ `share_active_leads_with_manager` and `share_won_deal_activities`, anchored on
314
+ `crm_opportunity`, `crm_lead` and `crm_activity`, every one of them
315
+ `sharingModel: 'public_read_write'`. They have been failing their boot backfill
316
+ on every boot of that app since they were written, and they are removed here
317
+ under ADR-0049 enforce-or-remove — the same call #9237 made for the two
318
+ equivalent rules in `app-showcase`. The other 2 (app-showcase's, both on
319
+ `private` objects) stay silent, which is the direction that had to be proven
320
+ rather than hoped for.
321
+
322
+ The CRM's smoke test used to assert that these rules existed and were of the
323
+ enforced `criteria` type. Both assertions passed while all three rules enforced
324
+ nothing, so the assertion is replaced by the property their greenness hid: no
325
+ declared rule may be anchored where sharing has nothing to widen.
326
+
327
+ Deliberately NOT judged, because they are not decidable from authored metadata:
328
+ the `owner_id` arm (`owner_id` is injected by the schema registry, so asserting
329
+ it would fail every object that correctly does not declare it by hand), the
330
+ `bypassObjects` arm (plugin configuration, not stack metadata), and the
331
+ federated phantom-anchor arm (a provenance test over that same injected column).
332
+ - 192213f: Three write-surface lint rules now ask provenance, not just membership, before exempting a system column (#8663).
333
+
334
+ `validate-hook-body-writes`, `validate-action-body-writes` and `validate-flow-node-writes` share one `IMPLICIT_FIELDS` set, which is object-INDEPENDENT: it answers "could this name be implicitly writable somewhere", never "did the platform provision a column for it on THIS object". On an ADR-0015 `external` object those diverge — the registry injects `owner_id` / `organization_id` / the audit family onto a federated object exactly as onto a local one, but the remote database owns the schema and no column exists behind them.
335
+
336
+ Each rule now emits a new advisory finding on that path instead of staying silent — `hook-body-write-unprovisioned-anchor`, `action-body-write-unprovisioned-anchor`, `flow-node-write-unprovisioned-anchor` — sharing the `unprovisionedAnchorCause` / `unprovisionedAnchorHint` wording the read-axis rules already use. All three are `warning`: the flow-node rule's existence finding still gates at `error`, and its provenance finding deliberately does not, because the claim is about a remote schema this repo cannot see.
337
+
338
+ An author-DECLARED column of the same name is untouched — on a federated object it maps a remote column the author vouches for. `FlowNodeWriteSeverity` widens from `'error'` to `'error' | 'warning'` accordingly.
339
+ - 42d8990: feat(lint): refuse a list-view `sort` that names a formula field, or no field at all, at authoring time (#9257)
340
+
341
+ <!-- adr-0087: not-required (already-registered engine-find-formula-order-by-refused)
342
+ This rule refuses no shape the runtime accepts — it moves an EXISTING refusal
343
+ earlier. `engine-find-formula-order-by-refused` (semantic, protocol 17) already
344
+ registers the condition and carries the identical FROM → TO prescription
345
+ ("denormalise the value onto the object — a stored field, written when the
346
+ source changes — and sort by that", with `summary` explicitly unaffected); the
347
+ FROM → TO block below restates that entry's remedy for the list-view position
348
+ rather than prescribing a second, different one. The `sort-field-unknown` half
349
+ is covered by `assertSortFieldsExist` (#6994), a REST ingress refusal already
350
+ shipped. Nothing authorable is renamed, retired or tombstoned, and no
351
+ `sys_metadata` row changes shape, so there is no new conversion to register —
352
+ what changes is only WHEN the author is told. -->
353
+
354
+ **BREAKING** accept-set narrowing on a published authoring surface, shipped as
355
+ `minor` under the same lockstep launch-window convention the sibling
356
+ `filter-preset-comparand` refusal used. Measured against the shipped corpus
357
+ before landing at `error`: **56 reachable `sort` declarations across
358
+ `examples/app-showcase`, `examples/app-crm`, `examples/app-todo` and
359
+ `packages/platform-objects`, 0 violations** — so this narrows the accept set
360
+ without failing any metadata that ships today.
361
+
362
+ The SORT axis had a runtime refusal on both doors and no authoring gate. This
363
+ adds the missing half, which is the exact shape #6674 closed for the SEARCH
364
+ axis one axis over.
365
+
366
+ **What was broken.** `ListViewSchema.sort` is
367
+ `z.union([z.string(), Array<{ field, order }>])`, so the field name is a bare
368
+ string and Zod validates only the shape. A list view authored with
369
+ `sort: 'expected_revenue desc'` — a `formula` field — validated, published, and
370
+ reported valid, then answered `400 INVALID_SORT` on **first load and every
371
+ load**: the declared sort is the view's initial fetch, not an optional
372
+ interaction, so the whole view fails with a status the author cannot connect to
373
+ the declaration. Both runtime doors already refuse it — `assertSortFieldsExist`
374
+ (`@objectstack/metadata-protocol`, #6994) at the REST ingress and
375
+ `assertOrderByIsMaterializable` (`@objectstack/objectql`, #7095) on the engine's
376
+ own boundary — and neither can reach the author.
377
+
378
+ **What is refused**, at `error`, on every list-view sort a stack declares
379
+ (`objects[].listViews.*.sort`, `views[].list.sort`, `views[].listViews.*.sort`):
380
+
381
+ - `sort-field-unknown` — the name resolves to no field on the bound object.
382
+ Judged on the head segment, matching the ingress gate's own rule so the two
383
+ doors cannot disagree about which names are unknown.
384
+ - `sort-field-unsortable` — the name is a real field whose type is **virtual**:
385
+ computed on read, no stored column, nothing for any driver to `ORDER BY`. An
386
+ unrefused sort on one returns `asc` and `desc` in byte-identical order.
387
+
388
+ **What stays accepted, and this is the load-bearing half:** `summary` and
389
+ `autonumber` sorts. Virtuality is judged by `isVirtualSearchField` /
390
+ `SEARCH_VIRTUAL_TYPES` (`@objectstack/spec/data`), pinned to `formula` alone —
391
+ the same spec storage fact the search ingress gate, the engine's search
392
+ resolution and the FILTER axis' dotted-head classifier already read. It is
393
+ deliberately **not** the spec's `COMPUTED_VALUE_TYPES`: that set is the WRITE
394
+ contract ("never client-written") and gating a sort with it would refuse the two
395
+ types that sort correctly — `summary` is a `table.float` the engine maintains,
396
+ `autonumber` a `table.string` the engine assigns. Both directions are pinned by
397
+ test, and the predicate boundary itself is pinned alongside them so the two
398
+ "must not flag" cases cannot quietly stop meaning anything.
399
+
400
+ Registry-injected system columns (`created_at`, `owner_id`, …) are skipped:
401
+ they are real at runtime, never appear in authored `fields`, and `created_at` is
402
+ the single most common ordering in the platform's own list views.
403
+
404
+ ## FROM → TO
405
+
406
+ ```ts
407
+ // before — parsed green, published, then 400 INVALID_SORT on every load
408
+ listViews: {
409
+ forecast: { type: 'grid', sort: [{ field: 'expected_revenue', order: 'desc' }] },
410
+ }
411
+
412
+ // after — refused at authoring time, naming the field, the position and the fix
413
+ listViews: {
414
+ // denormalise the computed value onto a stored column and sort by that
415
+ forecast: { type: 'grid', sort: [{ field: 'expected_revenue_stored', order: 'desc' }] },
416
+ }
417
+ ```
418
+
419
+ The rule joins `REFERENCE_INTEGRITY_RULES`, so it runs on `os validate`,
420
+ `os lint` and `os compile` at once rather than being wired per command.
421
+
422
+ ### Patch Changes
423
+
424
+ - 34392a1: docs(lint): the `readonlyWhen` field-rule diagnostic no longer cites `ADR-0057 D10` (#9255)
425
+
426
+ The author-visible consequence text for a faulting `readonlyWhen` predicate said
427
+ "Per ADR-0057 D10 the server is the one that decides". The rule it states is
428
+ correct and unchanged — the server locks the field while the form still renders
429
+ it editable — but the citation does not resolve: `D10` of the ERP-authorization
430
+ `ADR-0057` decides Setup-nav capability surfacing, and the other `ADR-0057`
431
+ (system data lifecycle) carries no D-numbered decisions at all. An author who
432
+ followed the anchor landed on an unrelated decision and had no way to tell
433
+ whether the code or their search was wrong.
434
+
435
+ The diagnostic now states the rule on its own authority, which is where it
436
+ always rested. No behaviour, no message semantics and no rule changed — only
437
+ the traceability claim. Recording the rule as an actual decision is tracked
438
+ separately in #9628.
439
+ - 62b1427: fix(lint): drop the `element:filter` entry from `COMPONENT_FIELD_SPECS` (#9220)
440
+
441
+ The whole `element:filter` element retired at element grain (ADR-0049 — no
442
+ renderer ever shipped for it), so every `ElementFilterProps` key is a
443
+ `retiredKey()` tombstone and no spec-conformant page carries `fields` on it.
444
+ The field-binding rule's job (resolve a field NAME against the object) is not
445
+ the question a retired key raises: an authored key is already reported by name
446
+ with the element-retirement prescription through the #5068 props gate, and the
447
+ binding entry would only add a second finding about a key that no longer
448
+ exists — the #5775/#6629 residue class the package's own
449
+ `component-field-specs-liveness` gate refuses.
450
+ - 818c27c: fix(lint): the three ADR-0120 uniqueness rules name the object in the `where` slot instead of repeating the config path (#9600)
451
+
452
+ `AuthoringFinding` declares two location slots with different jobs — `where`
453
+ ("human-readable location", e.g. `object "leave_request"`) and `path` ("config
454
+ path", e.g. `objects[3].sharingModel`). Three registry adapters set the first
455
+ from the second (`where: f.path`), so every CLI command printed the same
456
+ positional string twice and the only human-readable slot said nothing the `at`
457
+ clause did not already say:
458
+
459
+ ```
460
+ • objects[44].indexes[1]: "sys_account" declares index [provider_id, account_id] with bare `unique: true` …
461
+ rule: unique/unscoped-declared-index at objects[44].indexes[1]
462
+ ```
463
+
464
+ That index is a position in the MERGED object array, which appears in no file
465
+ the author wrote. `unique/unscoped-declared-index`, `unique/double-declaration`
466
+ and `unique/legacy-organization-composite` now spell it the way the rest of the
467
+ table does:
468
+
469
+ ```
470
+ • object "sys_account" · index [provider_id, account_id]: "sys_account" declares index …
471
+ rule: unique/unscoped-declared-index at objects[44].indexes[1]
472
+ ```
473
+
474
+ An index is identified by its `name` when it has one, and otherwise by the
475
+ columns the author actually wrote (`· index [provider_id, account_id]`) — both
476
+ searchable in their source, which a bare ordinal is not.
477
+
478
+ `where` is stated by the rule functions themselves rather than reconstructed in
479
+ the adapter, because only the rule still holds the object it walked. Their
480
+ return type is now `LocatedLintIssue` (a `LintIssue` with a REQUIRED `where`),
481
+ newly exported, so a fourth rule joining this family cannot reach the adapter
482
+ without one — a `f.where ?? f.path` fallback at the adapter would have let the
483
+ positional spelling ship again silently.
484
+
485
+ Display text only, and the rules' population is unchanged: measured over the 45
486
+ object declarations `@objectstack/platform-objects` and
487
+ `@objectstack/metadata-core` ship, the registry produced 1050 findings from the
488
+ same 5 rules before and after, with the count of findings whose `where` was a
489
+ bare config path going 72 to 0. `path` is deliberately untouched and stays
490
+ positional — it is the slot that is supposed to be a config path, and the
491
+ runtime gate's `fingerprint` reads `where` and `path` together, so making
492
+ `where` more specific cannot merge two findings that were distinct.
493
+ - e43b211: fix(spec): the retirement prescriptions state what `os migrate meta` actually does (#9529)
494
+
495
+ Every `retiredKey()` prescription whose surface an ADR-0087 conversion covers
496
+ closed with a maintainer-ruled sentence (2026-08-09, #6856):
497
+
498
+ > Run `os migrate meta --from N` to rewrite existing sources automatically.
499
+
500
+ The command has never rewritten an authored source file. It replays the
501
+ conversion chain over the loaded stack **in memory**, prints the attributed
502
+ mechanical change list (`Applied N mechanical change(s)`, one line per site as
503
+ `path: from → to (conversionId)`), and writes exactly one file — the `--out`
504
+ JSON snapshot, when you ask for it. Every write site in
505
+ `packages/cli/src/commands/migrate/meta.ts` is that snapshot; there is no
506
+ `--write` / `--fix` / in-place flag. So an author who followed the prescription
507
+ got the chain replayed, a printed diff and optionally a JSON document in a shape
508
+ their per-artifact `.ts` modules are not written in — and then still edited every
509
+ file by hand, with nothing in the message saying so.
510
+
511
+ Under the maintainer's ruling of 2026-08-18 the sentence is withdrawn in favour
512
+ of an honest one, class-wide:
513
+
514
+ > Run `os migrate meta --from N` to list the mechanical edits for existing
515
+ > sources; apply them by hand.
516
+
517
+ The partial-value conversions keep their two-clause shape, reworded the same way
518
+ (`… to list the mechanical edits for the \`1y\` case; the other durations are
519
+ reported for you to re-state.`). Behaviour is unchanged in both packages — this
520
+ is message text only, and no accept/reject verdict moves.
521
+
522
+ The claim is withdrawn from every shipped site, not only the canonical sentence:
523
+ the variant phrasings in tombstone and conversion-registry prose ("rewrites
524
+ author sources", "rewrites it for you", "only `os migrate meta` rewrites
525
+ sources") go with it, as do the upgrade-path statements in the hand-written docs
526
+ (`upgrading.mdx` now carries the same "does not rewrite your source files" fact
527
+ the `objectstack-upgrade` skill already told operators). The class-wide pin
528
+ `packages/spec/src/shared/retired-key-migrate-sentence.test.ts` moves in
529
+ lockstep and now holds **both** directions: the new sentence is required where a
530
+ prescription names the command, and the withdrawn claim is a hard failure
531
+ wherever it reappears — including in a prescription that spells the bare command
532
+ without `--from N`, which the sentence-shape check alone would not have seen.
533
+
534
+ The in-place AST codemod that would make the original claim true is commissioned
535
+ separately for v18 (#9591); when it lands, the sentence may be restored by
536
+ editing that one pin in the same PR.
537
+ - Updated dependencies [56656aa]
538
+ - Updated dependencies [07e630e]
539
+ - Updated dependencies [2f65b1b]
540
+ - Updated dependencies [720ee95]
541
+ - Updated dependencies [f287435]
542
+ - Updated dependencies [9aa8890]
543
+ - Updated dependencies [7c9c1dd]
544
+ - Updated dependencies [75b7c24]
545
+ - Updated dependencies [d5552ca]
546
+ - Updated dependencies [d9813a9]
547
+ - Updated dependencies [8640fb2]
548
+ - Updated dependencies [2420641]
549
+ - Updated dependencies [2ad91c3]
550
+ - Updated dependencies [f57fb38]
551
+ - Updated dependencies [00777a0]
552
+ - Updated dependencies [d491625]
553
+ - Updated dependencies [420804d]
554
+ - Updated dependencies [716ac9b]
555
+ - Updated dependencies [62b1427]
556
+ - Updated dependencies [7ea1372]
557
+ - Updated dependencies [23abe27]
558
+ - Updated dependencies [985a9cd]
559
+ - Updated dependencies [a8189ae]
560
+ - Updated dependencies [26e70fb]
561
+ - Updated dependencies [42b05af]
562
+ - Updated dependencies [2b292ce]
563
+ - Updated dependencies [abcf853]
564
+ - Updated dependencies [8b9eba5]
565
+ - Updated dependencies [d575779]
566
+ - Updated dependencies [94f7ef8]
567
+ - Updated dependencies [c5ac5e4]
568
+ - Updated dependencies [a777944]
569
+ - Updated dependencies [dd88e1c]
570
+ - Updated dependencies [856527c]
571
+ - Updated dependencies [870f710]
572
+ - Updated dependencies [79c46da]
573
+ - Updated dependencies [7ff3975]
574
+ - Updated dependencies [29d055b]
575
+ - Updated dependencies [65589d6]
576
+ - Updated dependencies [2c86fe3]
577
+ - Updated dependencies [e196c6a]
578
+ - Updated dependencies [4ab7523]
579
+ - Updated dependencies [19539b4]
580
+ - Updated dependencies [11b779e]
581
+ - Updated dependencies [739fe5b]
582
+ - Updated dependencies [4bfe1a5]
583
+ - Updated dependencies [2065e31]
584
+ - Updated dependencies [b69d0f5]
585
+ - Updated dependencies [4d47afe]
586
+ - Updated dependencies [e4e5c6e]
587
+ - Updated dependencies [9a56784]
588
+ - Updated dependencies [d00d2f6]
589
+ - Updated dependencies [df0c12d]
590
+ - Updated dependencies [d31785f]
591
+ - Updated dependencies [c308a4f]
592
+ - Updated dependencies [e2899f6]
593
+ - Updated dependencies [3851f87]
594
+ - Updated dependencies [2a29caa]
595
+ - Updated dependencies [09a6eee]
596
+ - Updated dependencies [1a7f907]
597
+ - Updated dependencies [cd455c8]
598
+ - Updated dependencies [30d3752]
599
+ - Updated dependencies [c80e7ae]
600
+ - Updated dependencies [09a9a8a]
601
+ - Updated dependencies [07026cf]
602
+ - Updated dependencies [5d4f3d5]
603
+ - Updated dependencies [4d80e8b]
604
+ - Updated dependencies [30b1c63]
605
+ - Updated dependencies [079b457]
606
+ - Updated dependencies [e43b211]
607
+ - Updated dependencies [890b38f]
608
+ - Updated dependencies [8bee54b]
609
+ - Updated dependencies [7a537ce]
610
+ - Updated dependencies [593c4bf]
611
+ - Updated dependencies [ff08691]
612
+ - Updated dependencies [60e0f90]
613
+ - Updated dependencies [90c5285]
614
+ - Updated dependencies [7901b2d]
615
+ - Updated dependencies [56bca91]
616
+ - Updated dependencies [79394d7]
617
+ - Updated dependencies [730fd9a]
618
+ - Updated dependencies [44bc51d]
619
+ - Updated dependencies [73cfddf]
620
+ - Updated dependencies [d634e66]
621
+ - @objectstack/spec@17.1.0
622
+ - @objectstack/formula@17.1.0
623
+ - @objectstack/sdui-parser@17.1.0
624
+
3
625
  ## 17.0.0
4
626
 
5
627
  ### Minor Changes