Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions .changeset/current-user-scope-root.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
---
"@objectstack/formula": patch
"@objectstack/lint": patch
---

fix(formula,lint): `current_user` becomes a declared root, and its field-level rejection becomes a real rule (#6290)

`@objectstack/formula` told two stories about one root. `introspectScope` handed
`current_user` to authors as a legal namespace and `checkRoleCatalog`'s four
position-membership regexes all lead with it — both correct, because ADR-0068 D1
makes `current_user` THE canonical spelling and `buildScope` really does mount
the same `EvalUser` under it. Only `cel-engine.ts`'s `SCOPE_ROOTS` disagreed, so
the strict environment read the blessed spelling as a BARE FIELD REFERENCE while
its two aliases (`user`, `ctx`) passed unremarked.

Three things change.

**1. `SCOPE_ROOTS` declares `current_user`.** That list is a "never faults"
baseline, not a per-surface contract, and it now advertises exactly what the
package advertises elsewhere. A new pin asserts the property directly: every
root `introspectScope` reports must resolve in the strict env.

**2. The wrong prescription is gone.** Because the rejection used to fall out of
the baseline's omission, the author got the GENERIC bare-field diagnostic —
"Write `record.current_user`". That shape binds on no layer of the platform, so
an author who followed the message ended up with something strictly worse than
what they started with, still silent. The field-level verdict now comes from a
rule of its own in `@objectstack/lint`, which names the real failure (unbound ⇒
fault ⇒ visibility falls back to `true` ⇒ the field a `current_user` test was
meant to hide stays visible for everyone, #6146) and prescribes surfaces that
exist: move the predicate to the option's own `visibleWhen`, declare field-level
security on a permission set (`fields: { '<object>.<field>': { readable: false } }`),
or rewrite it against `record`. It covers `visibleWhen`, `readonlyWhen` and
`requiredWhen`, which share the one evaluator.

**3. Per-option `visibleWhen` is validated at all.** `validate-expressions.ts`
walked field-level conditional rules and stopped there, so `SelectOption.visibleWhen`
— an authorable CEL slot the client filters on AND the server enforces — reached
compile, validate and run time checked by nobody. A bare field reference, a
reference to a field that does not exist, a syntax error or a template-dialect
predicate in an option all shipped in silence, and the option simply never
offered itself. Options are now walked, located by option value, on the same
`record` scope as their host field.

The two surfaces deliberately give opposite verdicts on `current_user`, because
their evaluators differ: field-level rules go through `evalFieldPredicate`
(`record` + `previous` + `parent`, never a user), options through
`resolveCascadingOptions` against the host's predicate scope, which does bind it
(ADR-0068 / objectui#2284). The showcase's role-gated option
(`'admin' in current_user.positions`) had never met this rule before and is now
pinned as the legal usage it is.

Sweep: `objectstack validate` is clean on all three example apps
(`app-showcase`, `app-crm`, `app-todo`) with the option walk active — zero new
findings, including the showcase object that carries both a record-scoped
cascade and the role-gated option.
32 changes: 32 additions & 0 deletions packages/formula/src/cel-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,40 @@ const SCOPE_ROOTS = [
// (the submit-time snapshot) and `vars`. Declared here so the strict lint
// env doesn't misread `current.x` as a bare field reference.
'current',
// ADR-0068 D1's CANONICAL user root, and the last one this list was missing
// (#6290). `buildScope` mounts the same `EvalUser` object under
// `current_user` / `user` / `ctx.user` / `os.user` whenever the evaluation
// carries a user, and this package already told the rest of the platform so:
// `introspectScope` lists `current_user` among the roots it hands an author,
// and `checkRoleCatalog`'s four position-membership regexes all lead with it.
// Only this list disagreed — so the one spelling ADR-0068 calls canonical was
// the one spelling the strict env read as a BARE FIELD, while its two aliases
// (`user`, `ctx`) passed. One package, two accounts of the same root.
'current_user',
] as const;

/*
* Why widening this list is the safe direction, and where the narrow verdict
* lives instead (#6290).
*
* This list is a "never faults" BASELINE, not a per-surface contract — the
* doc-comment above says so, and every entry is generous by construction. A
* surface that binds a CLOSED set of roots does not express that by hoping the
* baseline omits the others; it says so at the surface, through
* `collectCelRootIdentifiers` (that helper reads the AST and is completely
* independent of this list — see the approval-node approvers in #3447 P2, and
* `@objectstack/lint`'s field-level `*When` gate for `current_user`).
*
* That matters here because field- and section-level `visibleWhen` genuinely do
* NOT bind `current_user` (#6146, measured at both ends: `evalFieldPredicate`
* binds `record` + `previous` + `parent` and nothing else). Before #6290 that
* surface's rejection came out of this list's omission as a SIDE EFFECT, and it
* showed: the diagnostic was the generic bare-field one, so it prescribed
* "Write `record.current_user`" — a shape that binds on no layer at all. A
* verdict that belongs to one surface now reads as that surface's own rule,
* with that surface's own prescription.
*/

/**
* A `record`-scoped environment (`unlistedVariablesAreDyn: false`) for detecting
* bare field references. It reuses the real stdlib so function calls don't fault;
Expand Down
46 changes: 46 additions & 0 deletions packages/formula/src/validate.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, it, expect } from 'vitest';
import { validateExpression, introspectScope, expectedDialect, inferExpressionType } from './validate';
import { firstUndeclaredReference } from './cel-engine';

describe('validateExpression (ADR-0032)', () => {
describe('predicates (CEL)', () => {
Expand Down Expand Up @@ -343,6 +344,51 @@ describe('validateExpression (ADR-0032)', () => {
expect(scope.roots).toContain('record');
expect(scope.functions).toContain('daysFromNow');
});

/**
* [#6290] The package must give ONE answer about what a root is.
*
* `introspectScope` is the roots list this package HANDS an author (and the
* agent authoring tool); `firstUndeclaredReference` is the strict env that
* JUDGES what an author wrote, off `cel-engine.ts`'s `SCOPE_ROOTS`. Nothing
* kept the two in step, and they had drifted on exactly the root ADR-0068
* D1 calls canonical: `current_user` was advertised here and read as a bare
* field reference there — so the one spelling the ADR blesses was the one
* spelling the validator refused, while its two aliases (`user`, `ctx`)
* passed.
*
* Pinned as behaviour rather than as list equality: `SCOPE_ROOTS` is a
* generous baseline and stays free to declare MORE than it advertises (it
* carries `trigger`, `step`, `parent`, … for sites this introspection does
* not describe). What it may never do again is advertise a root it then
* faults on. Delete `'current_user'` from `SCOPE_ROOTS` and this goes red.
*/
it('every root `introspectScope` advertises really resolves in the strict env (#6290)', () => {
const advertised = introspectScope('predicate').roots;
expect(advertised).toContain('current_user');
const faulting = advertised.filter((root) => firstUndeclaredReference(`${root}.x`) !== null);
expect(faulting).toEqual([]);
});

/**
* [#6290] The same drift, seen from `checkRoleCatalog`'s side: its four
* position-membership regexes accept `current_user` / `user` / `ctx.user`
* as the user subject, so a role-catalog verdict on a `current_user`
* predicate was only ever reachable at sites that do not run the
* `record`-scope bare-ref check. All three spellings now reach it.
*/
it('a role-catalog verdict is reachable through every ADR-0068 user spelling (#6290)', () => {
for (const subject of ['current_user', 'user', 'ctx.user']) {
const r = validateExpression('predicate', `'org_admni' in ${subject}.positions`, {
scope: 'record',
roleCatalog: ['org_admin', 'org_member'],
});
expect(r.ok).toBe(false);
// The role typo is the finding — not a bare reference to the subject.
expect(r.errors.map((e) => e.message).join('\n')).toContain('unknown role `org_admni`');
expect(r.errors.map((e) => e.message).join('\n')).not.toContain('bare reference');
}
});
});
});

Expand Down
Loading
Loading