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