Filed unassigned, unlabelled, by the #7727 dev (PM loop R3, session session_01YBWFb5YgMU5dw8p2VKj16S) for PM triage. Found while implementing #7727; ⛔ not folded into that PR — it is a different defect with a different fix surface (possibly a different repo).
What
packages/app-shell/src/views/metadata-admin/celAuthoring.ts delegates its lint to @objectstack/formula's validateExpression. Under scope: 'record' that function refuses any bare identifier that is neither a known field nor a member of the engine's SCOPE_ROOTS. Measured against @objectstack/formula@17.2.0 (the version this worktree resolves), from packages/app-shell:
validateExpression('predicate', "size(ROOT) >= 0", { objectName:'invoice', fields:['status','amount'], scope:'record' })
ACCEPTED : record previous parent input os current_user user vars features ctx data env settings
REFUSED : app tenant org zzz
and the refusal message for app is, verbatim:
bare reference app — a formula/validation expression binds the record as the record namespace, not at top level, so app resolves to nothing and the expression silently evaluates to null. Write record.app.
SCOPE_ROOTS (same build) reads:
["record","previous","input","output","os","vars","variables","automation","context","args","item","env","user","step","result","trigger","event","payload","data","params","config","settings","ctx","features","parent","current","current_user"]
There is no app in it.
Why it matters
app IS bound at runtime for exactly the surfaces this lint serves. packages/app-shell/src/providers/ExpressionProvider.tsx:88:
export function buildExpressionScope({ user = {}, app = {}, data = {}, features = {} }: ExpressionScopeInput = {}): Record<string, any> {
return { current_user: user, user, ctx: { user }, os: { user }, app, data, features };
}
That bag becomes the PredicateScopeProvider scope, which plugin-grid's ObjectGrid.tsx:2086 and plugin-list's ListView.tsx:639 hand to resolveConditionalFormatting / evalRowPredicate. So app.name == 'crm' in a row predicate resolves and evaluates.
The gap was invisible while every row-predicate authoring site linted in the flattened scope, because flattened accepts ANY bare identifier — measured, size(zzz) >= 0 lints clean there. #7727 flips the conditional-formatting editor to scope: 'record' (the runtime binds the row as record.* only since #5741 Phase 2), and that is what makes the gap reachable:
An editor that suggests a root and then refuses it is the declared-vs-enforced shape in the other direction, and it is user-visible.
Fix shape (for triage, not ruled)
Two candidate directions, and they land in different repos — this is the part that needs a decision, not code:
- A — producer side (
objectstack). Add app to @objectstack/formula's SCOPE_ROOTS. Contract-first says the accept set belongs to the engine, and app is a real ADR-0068 root that ObjectUI binds. Cost: the engine's root vocabulary grows for every consumer.
- B — consumer side (
objectui). Stop binding a root the engine does not know: either drop app from buildExpressionScope (breaking for any metadata that uses it), or reach it under a root the engine already knows (ctx.app / os.app), and drop app from ROW_PREDICATE_ROOTS.
⛔ Whichever wins, the wrong answer is a consumer-side suppression of the diagnostic in celAuthoring.ts — that is the lenient-fallback shape AGENTS.md #0.1 bans.
Interim state on main after #7727: the collision is pinned by a characterization test in ConditionalFormattingEditor.test.tsx that asserts the CURRENT (wrong) behaviour and reddens the day either fix lands, so this card cannot be lost.
Related
os has the mirror-image shape and is filed separately — bound by buildExpressionScope, accepted by the engine, but not advertised.
Dedup
search_issues (REST is 403 from this seat class) with a control query that returned #7727 itself in the same session. Nearest hits, none covering this: #7431, #6487, #2413. No open card names the app root against SCOPE_ROOTS.
Filed unassigned, unlabelled, by the #7727 dev (PM loop R3, session
session_01YBWFb5YgMU5dw8p2VKj16S) for PM triage. Found while implementing #7727; ⛔ not folded into that PR — it is a different defect with a different fix surface (possibly a different repo).What
packages/app-shell/src/views/metadata-admin/celAuthoring.tsdelegates its lint to@objectstack/formula'svalidateExpression. Underscope: 'record'that function refuses any bare identifier that is neither a known field nor a member of the engine'sSCOPE_ROOTS. Measured against@objectstack/formula@17.2.0(the version this worktree resolves), frompackages/app-shell:and the refusal message for
appis, verbatim:SCOPE_ROOTS(same build) reads:There is no
appin it.Why it matters
appIS bound at runtime for exactly the surfaces this lint serves.packages/app-shell/src/providers/ExpressionProvider.tsx:88:That bag becomes the
PredicateScopeProviderscope, whichplugin-grid'sObjectGrid.tsx:2086andplugin-list'sListView.tsx:639hand toresolveConditionalFormatting/evalRowPredicate. Soapp.name == 'crm'in a row predicate resolves and evaluates.The gap was invisible while every row-predicate authoring site linted in the
flattenedscope, because flattened accepts ANY bare identifier — measured,size(zzz) >= 0lints clean there. #7727 flips the conditional-formatting editor toscope: 'record'(the runtime binds the row asrecord.*only since #5741 Phase 2), and that is what makes the gap reachable:ConditionalFormattingEditor.tsx'sROW_PREDICATE_ROOTSadvertisesappto autocomplete, because it is genuinely bound;app.name == 'crm'as an error, with the nonsense fixrecord.app;onBlockingIssuesChange(CEL blocking errors are dropped on the floor in ConditionBuilder and ConditionalFormattingEditor too — same ungated-Save family as #4306 #4527) counts them, so Save is held shut on a rule that would have worked.An editor that suggests a root and then refuses it is the declared-vs-enforced shape in the other direction, and it is user-visible.
Fix shape (for triage, not ruled)
Two candidate directions, and they land in different repos — this is the part that needs a decision, not code:
objectstack). Addappto@objectstack/formula'sSCOPE_ROOTS. Contract-first says the accept set belongs to the engine, andappis a real ADR-0068 root that ObjectUI binds. Cost: the engine's root vocabulary grows for every consumer.objectui). Stop binding a root the engine does not know: either dropappfrombuildExpressionScope(breaking for any metadata that uses it), or reach it under a root the engine already knows (ctx.app/os.app), and dropappfromROW_PREDICATE_ROOTS.⛔ Whichever wins, the wrong answer is a consumer-side suppression of the diagnostic in
celAuthoring.ts— that is the lenient-fallback shape AGENTS.md #0.1 bans.Interim state on
mainafter #7727: the collision is pinned by a characterization test inConditionalFormattingEditor.test.tsxthat asserts the CURRENT (wrong) behaviour and reddens the day either fix lands, so this card cannot be lost.Related
oshas the mirror-image shape and is filed separately — bound bybuildExpressionScope, accepted by the engine, but not advertised.Dedup
search_issues(REST is 403 from this seat class) with a control query that returned #7727 itself in the same session. Nearest hits, none covering this: #7431, #6487, #2413. No open card names theapproot againstSCOPE_ROOTS.