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
13 changes: 13 additions & 0 deletions .changeset/30025-flow-bare-name-screen-open-binder.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@objectstack/formula': patch
'@objectstack/lint': patch
---

fix(lint): a flow condition's bare-name type verdict no longer refuses a name a `screen` field holds, and stands down to the warning after a node that can bind any name (#30025)

Clause-②: yes (widening) — the author-time gate (`os build`, `os validate`, the package publish and the flow publish door) no longer refuses, on a record-triggered flow (or a time-relative sweep of its own object), a `type-mismatch-run-fails` on a BARE condition name that a `screen` node's declared field holds (no verdict now), nor on a bare name in a flow with a node that can bind a name no reader sees (`script`, `connector_action`, `wait`, `subflow`, `map`, a screen with no input contract), which is now the `type-mismatch-run-may-fail` warning. A `record.<field>` / `previous.<field>` read keeps its refusal, and nothing new is refused. The null-guard verdict's bare names also leave a screen's field names out. Added beside them: the optional `bareNamesUnproven` key of `@objectstack/formula`'s `ExprSchemaHint`, and `collectScreenFieldNames` in `@objectstack/lint`'s flow-variable-scope module.
Metadata-protocol: none
Skills-docs: updated here — `content/docs/data-modeling/formulas.mdx` (the type-soundness row names the screen field and the open binder); `skills/**` and the MCP skill text teach no bare flow-condition name a screen or an open binder holds, so no skill or eval moves.

- **Before.** A record-triggered flow whose `screen` node collects `title` (a number) and whose edge after it reads `title > 5`, with `title` a text field on the trigger object, was refused `type-mismatch-run-fails`: the bare name was typed as the field. The run binds the collected number there (a resume lands a screen's values under their plain names), so the condition runs. The same refusal hit a bare name after a `script`, `connector_action`, `wait`, `subflow` or `map` node, or a screen with no input contract, any of which can bind the name to a value nothing can see at build.
- **Now.** A screen's declared field names join the names a bare read holds instead of the field, so they are not typed as it (the null-guard verdict leaves them out too). In a flow with a node that can bind any name, a bare-name mismatch is the `type-mismatch-run-may-fail` warning, as the null-guard verdict already stands its bare names down there; `record.<field>` is still refused. The stand-down reads the flow's nodes only: an entrance (and the publish door's per-write snapshot, which sees none) changes which record both the bare name and `record.<field>` read, not whether a node rebinds the bare name, so a bare-name refusal on a record-triggered flow with no such node is unchanged at every door.
2 changes: 1 addition & 1 deletion content/docs/data-modeling/formulas.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,7 @@ come at two severities: **errors** fail the build; **warnings** are advisory
| **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 |
| **Field must exist** — a typo'd `record.<field>` is flagged with a did-you-mean. | `record.amont` → `record.amount` | error |
| **Unknown function** — a misspelled or nonexistent stdlib call. | `isBlnk(record.x)` | error |
| **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; a bare name that a declared flow variable holds reads the variable, so it is not judged as the field; 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 |
| **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; 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 |
| **Date arithmetic** — a `date` / `datetime` field used with `+` / `-` against a number (a `YYYY-MM-DD` value is a string at runtime, so this always faulted to `null`). Use the date helpers instead. | `record.end_date - record.start_date + 1` → `daysBetween(record.start_date, record.end_date) + 1`; `today() + 30` → `daysFromNow(30)`; `record.date + n` → `addDays(record.date, n)` | error (#3306) |
| **Date subtraction** — the runtime holds a `date` / `datetime` field as its text (`YYYY-MM-DD`, or an ISO-8601 instant), so subtracting two of them faults and the formula reads `null`; a timestamp minus one computes a duration, which a field reads as `{}`. Use `daysBetween`. A condition may still compare a duration (`today() - record.start_date > duration("72h")`). | `record.end_date - record.start_date` → `daysBetween(record.start_date, record.end_date)`; `today() - record.start_date` → `daysBetween(record.start_date, today())` | error |

Expand Down
33 changes: 33 additions & 0 deletions packages/formula/src/validate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -851,6 +851,39 @@ describe('validateExpression (ADR-0032)', () => {
.toEqual(['type-mismatch']);
});

// [#30025] Where the run can bind a bare name no reader sees (an open
// binder node, a contract-less screen), a bare-name mismatch is not proven
// to be the field's: it takes no declared outcome and is the run-may-fail
// warning. A `record.<field>` read still is the field and keeps the error,
// also when a bare mismatch comes first in the same source.
it('drops a bare-name mismatch to the run-may-fail warning where bare names are unproven, keeping record.<field> refused (#30025)', () => {
const flat = { ...schema, scope: 'flattened' as const, faultOutcome: 'run-fails' as const, bareNamesUnproven: true };
for (const source of ['name > 5', 'is_active > 0', 'name * 2 > 1']) {
const r = validateExpression('predicate', source, flat);
expect(r.errors, source).toEqual([]);
expect(r.warnings.map((w) => w.code), source).toEqual(['type-mismatch-run-may-fail']);
expect(r.warnings[0].message, source).toContain('which this check cannot prove here');
}
for (const [source, reference] of [
['record.name > 5', 'record.name'],
['previous.is_active > 0', 'previous.is_active'],
['name > 5 && record.title > 5', 'record.title'],
] as const) {
const r = validateExpression('predicate', source, flat);
expect(r.warnings, source).toEqual([]);
expect(r.errors.map((e) => [e.code, (e.params as { reference?: string }).reference]), source)
.toEqual([['type-mismatch-run-fails', reference]]);
}
// Controls: the key absent is the error as before; a bare name the run
// binds as a variable is still untyped; a `record`-scoped site ignores it.
expect(validateExpression('predicate', 'name > 5', { ...flat, bareNamesUnproven: undefined }).errors.map((e) => e.code))
.toEqual(['type-mismatch-run-fails']);
const shadowed = validateExpression('predicate', 'name > 5', { ...flat, flowVariables: ['name'] });
expect([shadowed.errors, shadowed.warnings]).toEqual([[], []]);
expect(validateExpression('predicate', 'record.name > 5', { ...schema, faultOutcome: 'fail-closed' as const, bareNamesUnproven: true }).errors.map((e) => e.code))
.toEqual(['type-mismatch-fail-closed']);
});

// [#29959] With no declared outcome a flattened condition is a flow's
// that is not proven to bind the field: a warning, but one saying the run
// fails on the fault, since a flow condition never reads null.
Expand Down
36 changes: 32 additions & 4 deletions packages/formula/src/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,18 @@ export interface ExprSchemaHint {
* typed. Absent ⇒ every bare field name is typed, exactly as before this key.
*/
flowVariables?: readonly string[];
/**
* [#30025] `'flattened'` scope only: the flow's run can bind a name no static
* reader sees — a node that hands out the live variable map or folds a
* resume bag in (`script`, `connector_action`, `wait`, `subflow`, `map`, a
* screen with no input contract; `@objectstack/lint`'s `flowCelRootScope`
* over the flow's nodes, `provable: false`). A bare name
* is then not proven to hold the field, so a bare-name `type-mismatch` takes
* no declared {@link faultOutcome}: it is the `type-mismatch-run-may-fail`
* warning. A `record.<name>` / `previous.<name>` read still reads the field
* and keeps the outcome. Absent ⇒ every verdict is exactly as before this key.
*/
bareNamesUnproven?: boolean;
}

/**
Expand Down Expand Up @@ -414,14 +426,29 @@ function typeSoundnessIssue(
role: 'predicate' | 'value',
faultOutcome: ExprSchemaHint['faultOutcome'],
flowVariables?: readonly string[],
bareNamesUnproven?: boolean,
): { issue: ExprValidationError; severity: 'error' | 'warning' } | null {
const celFieldTypes = toCelFieldTypes(fieldTypes);
// [#29959] A bare name a flow variable holds is not the field (see
// `ExprSchemaHint.flowVariables`), for every bare-name verdict here.
const shadowed = scope === 'flattened' && flowVariables && flowVariables.length > 0
? new Set(flowVariables)
: undefined;
const mismatch = firstTypeMismatch(source, celFieldTypes, scope, shadowed);
let mismatch = firstTypeMismatch(source, celFieldTypes, scope, shadowed);
// [#30025] Where the run can bind a bare name no reader sees
// (`ExprSchemaHint.bareNamesUnproven`), a bare-name mismatch is not proven to
// be the field's, so it takes no declared outcome. A `record.<field>` /
// `previous.<field>` mismatch in the same source still does: with every bare
// name left `dyn`, only the record-root pass can answer, and its answer wins.
let outcome = faultOutcome;
if (
mismatch && scope === 'flattened' && bareNamesUnproven && faultOutcome !== undefined
&& mismatch.category === 'type-mismatch' && mismatch.root === undefined
) {
const rootRead = firstTypeMismatch(source, celFieldTypes, scope, new Set(Object.keys(celFieldTypes)));
if (rootRead && rootRead.root !== undefined) mismatch = rootRead;
else outcome = undefined;
}
if (!mismatch) {
const bareCelTypes = shadowed
? Object.fromEntries(Object.entries(celFieldTypes).filter(([name]) => !shadowed.has(name)))
Expand Down Expand Up @@ -472,10 +499,10 @@ function typeSoundnessIssue(
`type mismatch \`${mismatch.operands}\` — ${subject} but is used with \`${mismatch.operator}\` ` +
`against a number. CEL has no such operation, so wherever the value is not numeric text ` +
`(an empty value included) the expression faults`;
if (faultOutcome !== undefined) {
if (outcome !== undefined) {
// [#29926] One code per outcome: a code names ONE message template
// (`expression-refusal.ts`), and the three outcomes are three sentences.
const arm = TYPE_MISMATCH_FAULT_ARMS[faultOutcome];
const arm = TYPE_MISMATCH_FAULT_ARMS[outcome];
return {
severity: 'error',
issue: refusal(arm.code, params, source, `${faults}, and here ${arm.clause} ${convert}`),
Expand Down Expand Up @@ -1405,7 +1432,8 @@ export function validateExpression(
// date-field arithmetic (error). Flow variables stay `dyn` (never flagged);
// equality/ordering of a date is runtime-safe (never flagged).
if (schema.fieldTypes) {
const r = typeSoundnessIssue(source, schema.fieldTypes, 'flattened', role, schema.faultOutcome, schema.flowVariables);
const r = typeSoundnessIssue(source, schema.fieldTypes, 'flattened', role, schema.faultOutcome, schema.flowVariables,
schema.bareNamesUnproven);
if (r) (r.severity === 'error' ? errors : warnings).push(r.issue);
}
}
Expand Down
33 changes: 33 additions & 0 deletions packages/lint/src/flow-variable-scope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,9 @@ export const VARIABLE_NAME_CONFIG_KEYS = ['iteratorVariable', 'indexVariable', '
*/
const ASSIGNMENT_NODE_TYPE = 'assignment';

/** The node type whose resume binds its declared fields' names ({@link collectScreenFieldNames}). */
const SCREEN_NODE_TYPE = 'screen';

/**
* The keys an `assignments` ARRAY entry may name its target with — the three
* `logic-nodes.ts` reads, in its precedence order. Exported so the guard can
Expand Down Expand Up @@ -248,6 +251,36 @@ export function collectFlowVariableNames(
return names;
}

/**
* [#30025] Every `screen` node's field `name`, across every graph of the flow:
* the names a resume binds. A screen's collected inputs land under their plain
* names (`applyResumeSignal`, the resume bag's `variables`), over whatever the
* run flattened there, so downstream of the screen a bare read of one holds the
* collected value, not the record's field. {@link collectFlowVariableNames}
* does not collect them (its consumers predate the screen row), so a caller
* that needs every name a bare read can hold instead of the field unions both.
*
* Only the names a screen DECLARES: a screen with no input contract folds any
* bag, which no reader can enumerate — `flowCelRootScope` reports such a flow
* as not provable, and that is the caller's other half.
*/
export function collectScreenFieldNames(graphs: readonly FlowGraphLike[]): ReadonlySet<string> {
const names = new Set<string>();
for (const graph of graphs) {
for (const flowNode of recordsOf(graph.nodes)) {
if (flowNode.type !== SCREEN_NODE_TYPE) continue;
const rawConfig = flowNode.config;
if (!rawConfig || typeof rawConfig !== 'object' || Array.isArray(rawConfig)) continue;
const fields = (rawConfig as AnyRec).fields;
if (!Array.isArray(fields)) continue;
for (const field of recordsOf(fields)) {
if (typeof field.name === 'string' && field.name) names.add(field.name);
}
}
}
return names;
}

/**
* Upper bound on the bare-root enumeration below. A predicate reaching for more
* distinct undeclared roots than this is pathological, and the loop must
Expand Down
Loading
Loading