Found while measuring section-visibleWhen / field-requiredWhen scope equivalence for objectui#6900. Not fixed there — that card is measurement-only and this lands in packages/lint. Filed unassigned for triage.
What was measured
fieldRuleRootIssue (packages/lint/src/validate-expressions.ts) produces the good field-level diagnostic only for roots it can see in SCOPE_ROOTS, because it filters candidates through that list:
const kept = SCOPE_ROOTS.filter(
(r) => !(FIELD_RULE_BOUND_ROOTS).includes(r) && roots.roots.includes(r),
);
if (kept.length === 0) return null;
app is not a SCOPE_ROOTS member (measured on @objectstack/formula@17.2.0: the 27-entry list has os, ctx, user, current_user, features, data … and no app). So a field-level requiredWhen reading app falls through to the generic bare-reference check and is told to write a shape that binds on no layer.
Repro against published @objectstack/lint@17.2.0 via validateStackExpressions, one object with memo.requiredWhen:
authored requiredWhen | diagnostic |
|---|
app.locale == 'en' | bare reference `app` — … so `app` resolves to nothing and the expression silently evaluates to null. Write `record.app`. |
record.app == 'en' (following that advice) | ``unknown field app on `invoice``` |
current_user.id == 'U1' (control) | `requiredWhen` reads `current_user`, but a field-level conditional rule binds only `record` (plus `previous`, and `parent` on a master-detail line item) … |
The control shows the correct message exists and fires for every root that IS in SCOPE_ROOTS — current_user, user, ctx, os, features, data all get it. app alone does not.
Why app is a shape authors actually reach for
This is not a hypothetical root. objectui's buildExpressionScope (packages/app-shell/src/providers/ExpressionProvider.tsx) binds it: { current_user, user, ctx: { user }, os: { user }, app, data, features }. So app.locale == 'en' is a working predicate on a form view's section, and an author moving that decision down to a server-enforced field rule is exactly the path that meets this message.
Severity — deliberately stated low
⚠️ Milder than the #6290 case this resembles. There the followed advice produced a silent no-op; here a second gate catches it (unknown field 'app' on 'invoice'), so the author is not left with a silently inert rule. The cost is a misleading first diagnostic and a wasted correction cycle, and the message actively asserts something false about where app binds. Diagnostic quality, not a correctness hole. ⛔ I am not proposing a severity — triage's call.
The fix shape, not built
Two candidates, no recommendation without the owning seat's read:
- Judge field-rule roots against the client-bound vocabulary too, not only
SCOPE_ROOTS — i.e. the complement is "roots bound at some evaluation site", of which SCOPE_ROOTS is currently a proper subset. - Add
app to SCOPE_ROOTS, which routes it to the existing correct branch for free — but that widens a platform-wide lint baseline for one renderer-tier root, and SCOPE_ROOTS' own doc calls itself a deliberately generous "never faults" baseline, so this may be the wrong lever.
Note (1) restates the structural point #6713 already made for this rule: a hand-maintained list doing a per-surface job drifts. SCOPE_ROOTS membership is the current proxy for "is this a namespace root at all", and app is the measured counterexample.
Provenance
Measured in /home/user/objectui-issue-6900 against published @objectstack/formula@17.2.0 and @objectstack/lint@17.2.0; source read at objectstackea0708c.
Found while measuring section-
visibleWhen/ field-requiredWhenscope equivalence for objectui#6900. Not fixed there — that card is measurement-only and this lands inpackages/lint. Filed unassigned for triage.What was measured
fieldRuleRootIssue(packages/lint/src/validate-expressions.ts) produces the good field-level diagnostic only for roots it can see inSCOPE_ROOTS, because it filters candidates through that list:appis not aSCOPE_ROOTSmember (measured on@objectstack/formula@17.2.0: the 27-entry list hasos,ctx,user,current_user,features,data… and noapp). So a field-levelrequiredWhenreadingappfalls through to the generic bare-reference check and is told to write a shape that binds on no layer.Repro against published
@objectstack/lint@17.2.0viavalidateStackExpressions, one object withmemo.requiredWhen:requiredWhenapp.locale == 'en'bare reference `app` — … so `app` resolves to nothing and the expression silently evaluates to null. Write `record.app`.record.app == 'en'(following that advice)appon `invoice```current_user.id == 'U1'(control)`requiredWhen` reads `current_user`, but a field-level conditional rule binds only `record` (plus `previous`, and `parent` on a master-detail line item) …The control shows the correct message exists and fires for every root that IS in
SCOPE_ROOTS—current_user,user,ctx,os,features,dataall get it.appalone does not.Why
appis a shape authors actually reach forThis is not a hypothetical root. objectui's
buildExpressionScope(packages/app-shell/src/providers/ExpressionProvider.tsx) binds it:{ current_user, user, ctx: { user }, os: { user }, app, data, features }. Soapp.locale == 'en'is a working predicate on a form view's section, and an author moving that decision down to a server-enforced field rule is exactly the path that meets this message.Severity — deliberately stated low
unknown field 'app' on 'invoice'), so the author is not left with a silently inert rule. The cost is a misleading first diagnostic and a wasted correction cycle, and the message actively asserts something false about whereappbinds. Diagnostic quality, not a correctness hole. ⛔ I am not proposing a severity — triage's call.The fix shape, not built
Two candidates, no recommendation without the owning seat's read:
SCOPE_ROOTS— i.e. the complement is "roots bound at some evaluation site", of whichSCOPE_ROOTSis currently a proper subset.apptoSCOPE_ROOTS, which routes it to the existing correct branch for free — but that widens a platform-wide lint baseline for one renderer-tier root, andSCOPE_ROOTS' own doc calls itself a deliberately generous "never faults" baseline, so this may be the wrong lever.Note (1) restates the structural point #6713 already made for this rule: a hand-maintained list doing a per-surface job drifts.
SCOPE_ROOTSmembership is the current proxy for "is this a namespace root at all", andappis the measured counterexample.Provenance
Measured in
/home/user/objectui-issue-6900against published@objectstack/formula@17.2.0and@objectstack/lint@17.2.0; source read atobjectstackea0708c.