Skip to content

Commit 98583cd

Browse files
fix(formula): a bare field ref or mistyped root faults with Unknown variable; the refusal, template and docs stop saying it evaluates to null (#31117)
<!-- claude-projects-attribution: {"github_login":"marchtian"} --> _Requested by **田騏** · [project thread](https://claude.ai/code/project/chan_01VmuUhXU9SSUzqJYyXSmYSi?thread=cmsg_01VmuUhXU9SSUzqJYyXSmYSiP9RS1KjicEPyef7VNhFzFa)_ Fixes #31081 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么:** `os validate` / `os build` 对裸字段引用(写了 `done` 而不是 `record.done`)和拼错根名的报错,不再说「表达式静默求值为 null」,改为说清真实机制:CEL 报 `Unknown variable`,后果由所在位置决定(公式字段读到 null、error 级校验规则拒写、操作按钮的 `visible` 把按钮藏起来)。脚手架生成的 AGENTS.md 和四篇文档同步改正;`validating-metadata` 的「Fail open」一行注明它说的是 Console 显示,服务端写入另有判定。 - **为什么改:** 旧说法只对公式字段成立。对校验规则、`requiredWhen`、选项 `visibleWhen` 来说,实际是拒绝写入;`readonlyWhen` 是锁住字段。作者照旧说法排查会找错方向。 - **风险与回滚:** 只改英文提示文字、注释和文档,错误码与参数不变,接受/拒绝的范围不变;一个 patch changeset(`@objectstack/formula`、`create-objectstack`)。回滚即 revert。 - **先看:** `packages/formula/src/validate.ts`(`bare-reference` 与 `unbound-root` 两条消息);`content/docs/deployment/validating-metadata.mdx`(Fail open 行与其后的 submit 段);`packages/create-objectstack/src/templates/AGENTS.md`。 - **两分钟验证:** 在 `packages/formula` 下运行 `node -e "import('@objectstack/formula').then(m=>console.log(m.validateExpression('predicate','!done',{scope:'record'}).errors[0].message))"`,应看到 `done is an unknown variable ... faults with Unknown variable: done ... Write record.done`,不再出现 `silently evaluates to null`。 - **需要你做的:** 无,席位落地。 ## What changes A bare field name in a `record`-scoped expression (`done` where `record.done` is meant) is never read as the field. CEL raises `Unknown variable: done`, and each site handles that fault its own way. Each site below was re-read against the code path that runs at it before rewording. | Site | Was | Now | What the runtime does (read on `origin/main` 5f00374) | |:---|:---|:---|:---| | `packages/formula/src/validate.ts` `bare-reference` refusal (printed by `os validate`, `os build`, `validate_expression`) | "resolves to nothing and the expression silently evaluates to null" | "is an unknown variable and the expression faults with `Unknown variable: NAME` instead of reading the field. The site's fault rule decides the outcome (a formula field reads null, an `error` validation rule refuses the write, an action's `visible` hides the action)." | One template serves every record-scoped site (`expression-refusal.ts`: a code names one message template), so the sentence names the mechanism and three outcomes that are each true where they are named: formula fault reads null (`reportFormulaFault`, objectql); an `error` validation rule is fail-closed (`rule-validator.ts`, script/cross_field docblock); Console `visible` fails closed (`DeclaredActionsBar.tsx`, `action:button`). | | `validate.ts` `unbound-root` refusal | same phrase | "`NAME.…` is an unknown variable, so the expression faults with `Unknown variable: NAME` instead of reading it" | Same fault: `firstUndeclaredReference` keys on cel-js's `Unknown variable` (`cel-engine.ts`). | | `validate.ts` doc comments on `fieldTypes` and `scope` | "silently evaluates to `null` / never fires" | fault, formula reads null, a predicate is refused, locked, hidden or skipped by its site | `faultOutcome` docblock in the same file already says this per outcome. | | `packages/formula/src/cel-engine.ts` `firstUndeclaredReference` docblock | same | same as above | Comment only. | | `packages/create-objectstack/src/templates/AGENTS.md` | "evaluates to `null` and silently hides an action on every record" | the predicate cannot be evaluated on any record: an action whose `visible` reads it is hidden on every record, an `error` validation rule that reads it refuses every write | The outcome for `visible` was right; the mechanism was not. Ships in `dist/templates` (`tsup.config.ts` copies it), hence the `create-objectstack` patch. | | `content/docs/deployment/validating-metadata.mdx` code comment | "resolves to null → the action is hidden on EVERY record" | unknown variable, predicate faults on every record, the Console hides an action whose `visible` faults | As above. | | `validating-metadata.mdx` "Fail open" row | unqualified | "Fail open in the Console's rendering", plus: the server judges `requiredWhen`, `readonlyWhen` and an option's `visibleWhen` again on the write | Triage 6701911557: the row describes the Console. Server: `requiredWhen` and option `visibleWhen` refuse the write (ADR-0137 D2, `unevaluableOptionGateError`). | | `validating-metadata.mdx` submit paragraph | "A broken `requiredWhen` or `readonlyWhen` is refused by the server" | adds an option's `visibleWhen`, and the one exception: a `readonlyWhen` reading a name the write does not bind holds the lock (the change to that field is dropped, the rest lands) | `isReadonlyWhenLocked` returns `true` on `unknownVariableOf`, `stripReadonlyWhenFields` drops the field (#4889 carve-out); every other fault refuses. | | `content/docs/getting-started/build-with-claude-code.mdx` code comment and the quoted `os validate` transcript | old comment; old message verbatim | new comment; new message verbatim | The transcript quotes the refusal, so it follows it. | | `content/docs/data-modeling/formulas.mdx` build-time check row | "a bare `amount` resolves to nothing and evaluates to `null`" | unknown variable, faults; a formula reads `null`, a predicate takes its site's fault outcome | Added to the claim's surface (claim amendment 6706775171). | | `content/docs/data-modeling/validation.mdx` callout | "resolves to nothing and the rule silently never fires" | unknown variable; an `error` rule refuses every write it judges, a `warning` / `info` rule is skipped with a log line | `error` fail-closed; advisory fail-open (`TYPE_MISMATCH_FAULT_ARMS['fail-open']` wording). Claim amendment 6706775171. | | `packages/lint/scripts/check-doc-formula-expressions.mjs` header, `packages/cli/test/authoring-rule-command-parity.test.ts` fixture comment | "does not throw … silently evaluates to null"; "silently evaluates to null" | never reads the field, faults, a formula reads null; the `error`-by-default rule refuses every write | Comments only. Claim amendment 6706775171. | The `content/docs/**` pages are a `domain:devx` surface; they ride along in this `domain:server` PR as triage 6701911557 allows. Left as they are, because they are true where they stand: the `type-mismatch` / `date-arithmetic` formula-field sentences ("the field silently evaluates to null") and the `cel-engine.ts` ternary note, which describe a formula field. ## Tests - `packages/formula/src/expression-refusal-codes.test.ts`: the `bare-reference` and `unbound-root` pins carry the new text. Codes and params unchanged. - Every other assertion on these refusals pins the code or the `bare reference NAME` subject, which is unchanged, so none moved. ## Acceptance notes - `ui/packages/app-shell/src/views/metadata-admin/celAuthoring.ts` (two comments) and `skills/**` still carry the old phrase; out of this claim's surface, listed in the report for the seat. - `os validate` prints the message unwrapped; the transcript in `build-with-claude-code.mdx` is hand-wrapped as the old one was. `check:docs-transcript-drift` compares the rule count only. ## Verification Head: `064a4d46f` <details><summary>Local runs</summary> All on head `064a4d46f`, worktree off `origin/main` `5f0037449`. - `pnpm --filter @objectstack/formula test` :: exit 0 — Test Files 65 passed (65), Tests 1703 passed (1703); `pnpm --filter @objectstack/formula typecheck` :: exit 0. - `pnpm --filter @objectstack/lint exec vitest run --maxWorkers=2` on the seven files that assert these refusals (`validate-expressions.test.ts`, `validate-expressions.field-conditional-formatting.test.ts`, and the five `runtime-gate.object-*-writes.test.ts` that pin a bare reference) :: exit 0 — Test Files 7 passed (7), Tests 610 passed (610). - `pnpm --filter create-objectstack test` :: exit 0 — Test Files 25 passed (25), Tests 374 passed (374). - `eslint --no-inline-config` over the five changed code files :: exit 0. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 97 derived; 96 run with exit 0. Three first answered exit 3 (prerequisite) and were re-run green after the prerequisite was met: `check-plugin-teardown-shape.mjs --self-test` (fetched the pinned fixture commit on this shallow clone), `check:skill-examples` (built the `@objectstack/client-react` closure; 283 examples across 3 surfaces type-check), `check:lean-entry-closure` (built the `@objectstack/objectql` closure). NOT MEASURED: `check:dual-build-cjs-loads`, reason: it needs every package's `dist` (a full `pnpm build`), and this diff changes no module shape; CI's `Lint & Repo Gates` runs it. - `dispatch-gates --ran` :: exit 0 — "97 derived famil(ies) accounted for — 96 run, 1 NOT-MEASURED". </details> 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude <noreply@anthropic.com>
1 parent b5696b4 commit 98583cd

11 files changed

Lines changed: 72 additions & 32 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/formula': patch
3+
'create-objectstack': patch
4+
---
5+
6+
fix(formula): the bare-reference and unbound-root refusals say the expression faults, not that it evaluates to null
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
- **`@objectstack/formula`.** In a `record`-scoped expression, a bare field name (`amount` where `record.amount` is meant) and a mistyped root (`pge.…` where the surface binds `page`) are unknown variables: the expression faults with `Unknown variable: NAME` and never reads the field. The `bare-reference` and `unbound-root` refusals used to say the name "resolves to nothing and the expression silently evaluates to null", which is true only of a formula field. They now say the expression faults, and `bare-reference` names three outcomes of that fault: a formula field reads null, an `error` validation rule refuses the write, an action's `visible` hides the action. `os validate`, `os build` and the `validate_expression` tool print the new sentences.
12+
- Codes and params are unchanged (`bare-reference` `{ name }`, `unbound-root` `{ name, roots, suggestion }`). What is refused, warned or passed is unchanged. Only the English `message` changed, so a consumer that matched the old text re-keys to the `code`.
13+
- **`create-objectstack`.** The scaffolded `AGENTS.md` no longer says a bare field reference "evaluates to `null`". It now says the predicate cannot be evaluated on any record, so an action whose `visible` reads it is hidden everywhere and an `error` validation rule that reads it refuses every write. Projects scaffolded earlier keep their copy until it is edited.

‎content/docs/data-modeling/formulas.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -239,7 +239,7 @@ come at two severities: **errors** fail the build; **warnings** are advisory
239239

240240
| Check | Example | Severity |
241241
|:---|:---|:---|
242-
| **Qualify field references** — a formula / validation / predicate binds only the `record` namespace, so a bare `amount` resolves to nothing and evaluates to `null`. Write `record.amount`. | `amount > 100` → `record.amount > 100` | error |
242+
| **Qualify field references** — a formula / validation / predicate binds only the `record` namespace, so a bare `amount` is an unknown variable and the expression faults instead of reading the field: a formula reads `null`, and a predicate takes its site's fault outcome (an `error` validation rule refuses the write, an action's `visible` hides the action). Write `record.amount`. | `amount > 100` → `record.amount > 100` | error |
243243
| **Field must exist** — a typo'd `record.<field>` is flagged with a did-you-mean. | `record.amont` → `record.amount` | error |
244244
| **Unknown function** — a misspelled or nonexistent stdlib call. | `isBlnk(record.x)` | error |
245245
| **Type soundness** — a text or boolean field used with an arithmetic (`+ - * / %`) or ordering (`< > <= >=`) operator against a number faults at runtime unless the text is numeric. In a formula it evaluates to `null`. Where a fault does something else it is an error, worded for what happens: a validation rule, a field `requiredWhen` / `readonlyWhen`, an option `visibleWhen` or a hook `condition` refuses the write; a record-triggered flow's start, edge or `decision` branch condition fails the run (the write that fired it still lands), whether it reads the field bare or as `record.<field>`, and so does a time-relative flow's that sweeps the same object, where no write fires the run and only the run for that row fails; a bare name that a declared flow variable or a `screen` field holds reads that value, so it is not judged as the field, and a bare name after a node that can bind any name (`script`, `connector_action`, `wait`, `subflow`, `map`, a screen with no fields) gets the warning instead, while its `record.<field>` read is still refused; an advisory (`severity: 'warning'` / `'info'`) validation rule is skipped, so it never gives its advisory. Store it as a number field, compare text with text, or convert explicitly (`size(record.name)` for the length; `double(record.name)`, which itself faults on text that is not a number). | `record.title * 2`; `record.name > 5` in a validation rule → `size(record.name) > 5` | warning in a formula or a display predicate, and in a condition of any other flow, where the warning says the run fails if the condition faults (what such a flow binds under the name depends on how it is started); error at the sites named |

‎content/docs/data-modeling/validation.mdx‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -390,8 +390,10 @@ To compare against a field's previous value, reference `previous.<field>`
390390
(e.g. `record.stage != previous.stage`).
391391

392392
> **Qualify your references.** A validation predicate binds only the `record`
393-
> namespace, so a bare `status == "won"` resolves to nothing and the rule
394-
> silently never fires — write `record.status == "won"`. `objectstack build`
393+
> namespace, so a bare `status == "won"` is an unknown variable and the rule
394+
> cannot be evaluated: an `error` rule then refuses every write it judges, and a
395+
> `warning` / `info` rule is skipped with a log line and never applied — write
396+
> `record.status == "won"`. `objectstack build`
395397
> flags bare references, unknown fields, and text/boolean fields misused in
396398
> arithmetic — see [Build-time validation](/docs/data-modeling/formulas#build-time-validation).
397399

‎content/docs/deployment/validating-metadata.mdx‎

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,9 @@ they reference record fields through the `record.` scope:
2828

2929
```ts
3030
// ✗ Wrong — `done` is a bare reference. It type-checks (it's just a string),
31-
// but at runtime it resolves to null → the action is hidden on EVERY record.
31+
// but at runtime it is an unknown variable, so the predicate faults on every
32+
// record — and the Console hides an action whose `visible` faults: hidden on
33+
// EVERY record.
3234
{ name: 'mark_done', visible: '!done' }
3335

3436
// ✓ Right
@@ -896,16 +898,19 @@ What the failure does depends on what the predicate guards:
896898

897899
| Predicate | On failure |
898900
| :--- | :--- |
899-
| A field's `visibleWhen`, `readonlyWhen`, `requiredWhen`; a view's `visibleOn`; an option's `visibleWhen`; list conditional formatting | **Fail open**: the rule's safe default applies, so a broken rule never hides a field. A rule that never fires while its field stays visible is the symptom, usually a bare field name where `record.<field>` was meant |
901+
| A field's `visibleWhen`, `readonlyWhen`, `requiredWhen`; a view's `visibleOn`; an option's `visibleWhen`; list conditional formatting | **Fail open in the Console's rendering**: the rule's safe default applies, so a broken rule never hides a field. A rule that never fires while its field stays visible is the symptom, usually a bare field name where `record.<field>` was meant. This row is the Console's display only; the server judges `requiredWhen`, `readonlyWhen` and an option's `visibleWhen` again on the write (see below) |
900902
| An object field group's `visibleWhen` | **Fail closed**: the whole group is hidden on the form and the record page |
901903
| A page node's `visibleWhen` / `visible` / `hidden`, and a `page:tabs` item's `visibleWhen` | **Fail closed**: the node or tab is withheld; a withheld related list is not counted in its tab badge |
902904
| A navigation item's or area's `visible` | **Fail open**: the item renders for everyone, reported under `app-shell:visible` |
903905

904906
At **submit** a broken field rule is refused, not passed. A field `visibleWhen` that
905907
cannot be evaluated refuses the submit on the client, with a message naming the field
906-
and the rule, because no server evaluates `visibleWhen`. A broken `requiredWhen` or
907-
`readonlyWhen` is refused by the server, and the form shows that refusal beside the
908-
input. A `visibleWhen` that reads `previous` cannot be evaluated on a create form, so
908+
and the rule, because no server evaluates a field's `visibleWhen`. A broken
909+
`requiredWhen` or `readonlyWhen`, or an option's `visibleWhen`, is refused by the
910+
server, and the form shows that refusal beside the input. One exception: a
911+
`readonlyWhen` that reads a name the write does not bind (a bare field name, or
912+
`parent` where no master-detail header was resolved) holds the lock instead: the
913+
server drops the incoming change to that field and the rest of the write lands. A `visibleWhen` that reads `previous` cannot be evaluated on a create form, so
909914
such a form is always refused.
910915

911916
A **blank** rule (`''`, whitespace, or an envelope whose `source` is blank) is not "no

‎content/docs/getting-started/build-with-claude-code.mdx‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -300,7 +300,8 @@ reference** — `status` instead of `record.status`:
300300
{/* os:check */}
301301
```typescript
302302
// ✗ Wrong — bare `status`. Type-checks (it's just a string), but at runtime
303-
// it resolves to null, so the action is hidden on EVERY ticket.
303+
// it is an unknown variable, so the predicate faults on every ticket — and
304+
// the Console hides an action whose `visible` faults: hidden on EVERY ticket.
304305
visible: 'status != "resolved"'
305306
```
306307

@@ -315,8 +316,11 @@ visible: 'status != "resolved"'
315316
✗ Author-time rules failed (1 issue)
316317
• stack · action 'resolve_ticket' visible: bare reference `status` — a
317318
formula/validation expression binds the record as the `record` namespace,
318-
not at top level, so `status` resolves to nothing and the expression
319-
silently evaluates to null. Write `record.status`.
319+
not at top level, so `status` is an unknown variable and the expression
320+
faults with `Unknown variable: status` instead of reading the field. The
321+
site's fault rule decides the outcome (a formula field reads null, an
322+
`error` validation rule refuses the write, an action's `visible` hides the
323+
action). Write `record.status`.
320324
— source: `status != "resolved"`
321325
rule: expression-invalid at stack · action 'resolve_ticket' visible
322326
```

‎packages/cli/test/authoring-rule-command-parity.test.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,8 @@ const CASES: ReadonlyArray<{ rule: string; blindTo: readonly AuthoringCommand[];
9696
blindTo: ['lint'],
9797
stack: withBaseline({
9898
// The planted defect is the BARE `lead_score` (a record-scoped predicate
99-
// binds fields under `record`, so this silently evaluates to null) — not
99+
// binds fields under `record`, so `lead_score` is an unknown variable and
100+
// the rule, `error` by default, refuses every write it judges) — not
100101
// the key it is written under. Spelled `condition`, which is the only key
101102
// `validation.zod.ts` declares: `expression` is one of the four names it
102103
// rejects outright, so a fixture using it planted TWO defects and let the

‎packages/create-objectstack/src/templates/AGENTS.md‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,11 @@ that defines business objects, views, automations, and AI agents in TypeScript.
1515
## Verify your work (do this after every metadata change)
1616

1717
Metadata mistakes fail **silently at runtime**, not at edit time — a bare field
18-
reference in a predicate (`done` instead of `record.done`) evaluates to `null`
19-
and silently hides an action on every record; a dangling widget binding renders
20-
an empty chart. Catch them before you ship:
18+
reference in a predicate (`done` instead of `record.done`) is an unknown
19+
variable, so the predicate cannot be evaluated on any record: an action whose
20+
`visible` reads it is hidden on every record, and an `error` validation rule
21+
that reads it refuses every write; a dangling widget binding renders an empty chart. Catch
22+
them before you ship:
2123

2224
```bash
2325
{{PM}} run validate # schema + CEL predicates + widget bindings (no artifact)

‎packages/formula/src/cel-engine.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -351,8 +351,10 @@ let recordScopeEnv: Environment | undefined;
351351
* In a `record`-scoped CEL site — a `Field.formula` or an object validation
352352
* predicate — the evaluation scope binds only the `record`/`previous`/… *namespaces*
353353
* (no field flattening). A bare top-level identifier like `amount` or `status`
354-
* therefore resolves to nothing and the expression silently evaluates to `null`
355-
* / never fires (#1928, the class behind #1927's broken formulas). Returns the
354+
* is therefore an unknown variable: the expression faults with `Unknown
355+
* variable: <name>` instead of reading the field, and the site's fault rule
356+
* decides the outcome — a formula reads null, a predicate is refused, locked,
357+
* hidden or skipped (#1928, the class behind #1927's broken formulas). Returns the
356358
* first such bare reference, or `null`.
357359
*
358360
* Acts ONLY on cel-js's `Unknown variable: X` fault, so it cannot false-positive

‎packages/formula/src/expression-refusal-codes.test.ts‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -274,8 +274,9 @@ const PINS: { readonly [C in ExpressionRefusalCode]: readonly [Pin<C>, ...Pin<C>
274274
produce: () => onlyError('predicate', "pge.selectedProjectId != ''", { scope: 'record', roots: ['page'] }),
275275
params: { name: 'pge', roots: ['page'], suggestion: 'page' },
276276
message:
277-
'unbound root `pge` — beyond the record this authoring surface binds `page`, and `pge` is none of them, ' +
278-
'so `pge.…` resolves to nothing and the expression silently evaluates to null. Did you mean `page`?',
277+
'unbound root `pge` — beyond the record this authoring surface binds `page`, and `pge` is none of them: ' +
278+
'`pge.…` is an unknown variable, so the expression faults with `Unknown variable: pge` instead of reading it. ' +
279+
'Did you mean `page`?',
279280
},
280281
],
281282
'bare-reference': [
@@ -284,7 +285,9 @@ const PINS: { readonly [C in ExpressionRefusalCode]: readonly [Pin<C>, ...Pin<C>
284285
params: { name: 'amount' },
285286
message:
286287
'bare reference `amount` — a formula/validation expression binds the record as the `record` namespace, ' +
287-
'not at top level, so `amount` resolves to nothing and the expression silently evaluates to null. Write `record.amount`.',
288+
'not at top level, so `amount` is an unknown variable and the expression faults with `Unknown variable: amount` ' +
289+
'instead of reading the field. The site\'s fault rule decides the outcome (a formula field reads null, an ' +
290+
'`error` validation rule refuses the write, an action\'s `visible` hides the action). Write `record.amount`.',
288291
},
289292
],
290293
'date-arithmetic': [

‎packages/formula/src/validate.ts‎

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -94,8 +94,9 @@ export interface ExprSchemaHint {
9494
* #1928 tier 4 — field name → spec field type (`'text'`, `'currency'`,
9595
* `'boolean'`, `'date'`, …). Enables the advisory type-soundness check: a
9696
* text or boolean field used with an arithmetic/ordering operator against a
97-
* number faults at runtime and the expression silently evaluates to `null`,
98-
* so it is surfaced as a NON-blocking warning. Absent ⇒ the check is skipped.
97+
* number faults at runtime — a formula field reads that fault as `null` —
98+
* so it is surfaced as a NON-blocking warning, or as an error where the site
99+
* declares its {@link faultOutcome}. Absent ⇒ the check is skipped.
99100
* Consulted at both scopes: `record.<field>` at a `'record'` site, the bare
100101
* `<field>` and its `record.<field>` / `previous.<field>` read at a
101102
* `'flattened'` one (#29959).
@@ -106,9 +107,12 @@ export interface ExprSchemaHint {
106107
* identifier is legal (#1928):
107108
* - `'record'` → the record is bound only as the `record` namespace, with
108109
* no field flattening (`Field.formula`, object validation
109-
* predicates). A bare `amount` resolves to nothing and the
110-
* expression silently evaluates to `null` / never fires, so
111-
* it MUST be written `record.amount`. We flag bare refs.
110+
* predicates). A bare `amount` is an unknown variable: the
111+
* expression faults (`Unknown variable: amount`) instead of
112+
* reading the field, and the site's fault rule decides the
113+
* outcome (a formula reads null, a predicate is refused,
114+
* locked, hidden or skipped), so it MUST be written
115+
* `record.amount`. We flag bare refs.
112116
* - `'flattened'` → the record's own fields are spread to top-level alongside
113117
* flow variables (flow / automation conditions), so bare
114118
* `status` is correct and is NOT an error. Flow variables
@@ -1247,9 +1251,9 @@ function mistypedRootRefusal(
12471251
{ name: bare, roots: [...surfaceRoots], suggestion },
12481252
source,
12491253
`unbound root \`${bare}\` — beyond the record this authoring surface binds ` +
1250-
`\`${surfaceRoots.join('`, `')}\`, and \`${bare}\` is none of them, so \`${bare}.…\` ` +
1251-
`resolves to nothing and the expression silently evaluates to null. ` +
1252-
`Did you mean \`${suggestion}\`?`,
1254+
`\`${surfaceRoots.join('`, `')}\`, and \`${bare}\` is none of them: \`${bare}.…\` ` +
1255+
`is an unknown variable, so the expression faults with \`Unknown variable: ${bare}\` ` +
1256+
`instead of reading it. Did you mean \`${suggestion}\`?`,
12531257
);
12541258
}
12551259

@@ -1492,8 +1496,11 @@ export function validateExpression(
14921496
{ name: bare },
14931497
source,
14941498
`bare reference \`${bare}\` — a formula/validation expression binds the record as the ` +
1495-
`\`record\` namespace, not at top level, so \`${bare}\` resolves to nothing and the ` +
1496-
`expression silently evaluates to null. Write \`record.${bare}\`.`,
1499+
`\`record\` namespace, not at top level, so \`${bare}\` is an unknown variable and the ` +
1500+
`expression faults with \`Unknown variable: ${bare}\` instead of reading the field. The ` +
1501+
`site's fault rule decides the outcome (a formula field reads null, an \`error\` ` +
1502+
`validation rule refuses the write, an action's \`visible\` hides the action). ` +
1503+
`Write \`record.${bare}\`.`,
14971504
),
14981505
);
14991506
} else if (schema.fieldTypes) {

0 commit comments

Comments
 (0)