Skip to content

Commit 0593be8

Browse files
committed
fix(spec): the value-slot refusal's has() guard tests each step off vars, so it holds for an unbound variable
A refused path named a guard on its last key, has(source.id) ? source.id : null, for "one that may be absent". has() evaluates everything but its last selection, so where the variable itself was never bound (an isInput variable the caller left out, with no default) that guard failed the run with Unknown variable: source. The judge now prints a chain off vars, which holds only the bound variables: has(vars.source) && has(vars.source.id) ? vars.source.id : null. flow-bare-dollar-reference's value-slot hint carries the judge's words, so it is fixed with it. Pinned through the engine (value-slot-template-grammar.test.ts: an unbound root, a null root, an absent key, a bound value, and a full run with an unsupplied isInput variable against the last-key control), in the judge's own test, and on the lint hint (four judges, 0 refusals). The D3 entry, the flows doc row and the changeset name the chain. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN
1 parent 31f20e0 commit 0593be8

8 files changed

Lines changed: 120 additions & 25 deletions

File tree

‎.changeset/19939-flow-value-slot-date-macros-refused.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ Clause-②: no (narrowing)
2424

2525
**Where an envelope is literal data, the remedy names none there.** The refusal used to prescribe an envelope at whatever position it refused a token. Inside an object or list value, and in the legacy `assignment` shapes, the executor reads an envelope as data: the prescribed metadata registered and stored the envelope object. The refusal there now names what evaluates — the whole value built as one CEL map or list literal (mixed values each wrapped in `dyn(…)`), or the assignment moved into the `assignments` map. This holds for every token, not only the date macros. An envelope-shaped object in those positions is still data, unchanged.
2626

27+
**The `has()` guard a refusal names now holds for an absent variable.** For a path that may be absent, the refusal named a guard on its last key, `has(source.id) ? source.id : null`. `has()` evaluates everything but its last selection, so where the variable itself was never bound — an `isInput` variable the caller left out, with no default — that guard failed the run (`Unknown variable: source`). The guard now tests each step off `vars`, which holds only the variables the run has bound: `has(vars.source) && has(vars.source.id) ? vars.source.id : null` answers `null` there, and where a key on the way is absent or `null`. `flow-bare-dollar-reference`'s value-slot hint carries the same guard. The value-slot retirement's earlier entry on this line prints the last-key guard; this entry supersedes it.
28+
2729
## FROM → TO
2830

2931
| you wrote | write instead | what changes |
@@ -35,6 +37,7 @@ Clause-②: no (narrowing)
3537
| `'{TODAY() + days}'` | `{ dialect: 'cel', source: 'isoDate(addDays(today(), days))' }` | an absent `days`, or one that is not a number, fails the run where the template silently added 0 |
3638
| `'Due {TODAY()} by {$User.Id}'` | `{ dialect: 'cel', source: "'Due ' + isoDate(today()) + ' by ' + current_user.id" }` | guard the run user where a flow can run without one |
3739
| `payload: { due: '{TODAY()}' }` (a string inside an object value) | `payload: { dialect: 'cel', source: "{'due': isoDate(today())}" }` | an envelope written at `payload.due` would be stored as an object |
40+
| the guard an earlier refusal named, `has(source.id) ? source.id : null` | `has(vars.source) && has(vars.source.id) ? vars.source.id : null` | `null` where `source` was never bound, where the last-key guard failed the run |
3841
| a text slot's `assignments: { due: '{TODAY() + 7}' }`, then `'Due {{ due }}'` | `assignments: { due: { dialect: 'cel', source: 'isoDate(daysFromNow(7))' } }`, then `'Due {{ due }}'` | the text-slot remedy published earlier on this line computed the date through the value-slot spelling this change refuses |
3942

4043
**The one-line fix: write a date macro as `isoDate(…)` / `isoDatetime(…)` in a CEL envelope, with a whole number of days.**
@@ -47,5 +50,5 @@ Clause-②: no (narrowing)
4750

4851
- **The refusal.** `valueSlotTemplateRefusals` / `flowNodeValueTemplateRefusals` (`@objectstack/spec/automation`) keep no token; the text-slot judge's date-macro remedy (`textSlotTemplateRefusal`) names the CEL envelope. `FlowValueSlotSchema` / `AssignmentValueSchema` say so in their descriptions.
4952
- **The ledger.** The step-18 D3 entries `flow-value-slot-template-dialect-refused` (amended to refuse the date macros and name their forms) and `flow-text-slot-single-brace-refused` (its date remedy). No key is removed, so there is no tombstone, and there is no D2 conversion.
50-
- **`@objectstack/lint`.** `flow-double-brace-interpolation`'s hint in a value slot names the CEL envelope for the holes the author meant, where it named `{record.title}`, which the slot refuses; `flow-bare-dollar-reference`'s value-slot hint asks the judge at the string's own position, so a nested or legacy-shape string gets the spelling that evaluates there.
53+
- **`@objectstack/lint`.** `flow-double-brace-interpolation`'s hint in a value slot names the CEL envelope for the holes the author meant, where it named `{record.title}`, which the slot refuses; `flow-bare-dollar-reference`'s value-slot hint asks the judge at the string's own position, so a nested or legacy-shape string gets the spelling that evaluates there, and carries the judge's step-by-step `has()` guard.
5154
- **`@objectstack/service-automation`'s README.** Its *Expressions* section names the date macros' CEL forms.

‎content/docs/automation/flows.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -265,7 +265,7 @@ for some input, so the rewrite is yours to judge.
265265

266266
| you wrote | write instead | what changes |
267267
|:---|:---|:---|
268-
| `'{record.owner}'`, `'{x}'` | `{ dialect: 'cel', source: 'record.owner' }` | CEL refuses an **absent** variable or key where the template wrote nothing — guard one that may be absent: `has(record.owner) ? record.owner : null`, `has(vars.x) ? vars.x : null` (writes `null`) |
268+
| `'{record.owner}'`, `'{x}'` | `{ dialect: 'cel', source: 'record.owner' }` | CEL refuses an **absent** variable or key where the template wrote nothing — guard one that may be absent a step at a time from `vars`, which holds only the variables the run has bound: `has(vars.record) && has(vars.record.owner) ? vars.record.owner : null`, `has(vars.x) ? vars.x : null` (writes `null`). A guard on the last key alone does not hold where the variable is absent: `has(source.id)` fails the run when `source` was never bound (an `isInput` variable the caller left out) |
269269
| `'{items.0}'` | `source: 'items[0]'` | an empty list fails the run |
270270
| `'{$error.message}'` | `source: 'vars["$error"].message'` | a `$`-named variable is read through `vars` |
271271
| `'{round(x * 100) / 100}'` | `source: 'round(x * 100) / 100.0'` | CEL divides two integers as integers: keep a decimal operand on every division |

‎packages/lint/src/lint-flow-patterns.test.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2720,6 +2720,21 @@ describe('#16405 — an `http` node payload is not a region, and both #1315 rule
27202720
}
27212721
});
27222722

2723+
// #19939 pass 3: the `has()` guard the hint carries for "one that may be
2724+
// absent" must hold where the VARIABLE is absent too: `has(source.id)`
2725+
// fails the run when `source` was never bound (an `isInput` variable the
2726+
// caller left out). The judge's guard tests each step off `vars`, and
2727+
// the hint carries the judge's words — that the guard answers `null`
2728+
// there is pinned through the engine in `service-automation`'s
2729+
// `value-slot-template-grammar.test.ts`.
2730+
it('the `has()` guard it carries tests each step off `vars`, and passes every judge as an envelope', () => {
2731+
const [f] = bareDollar(valueFlow('create_record', { objectName: 'task', fields: { who: '$source.id' } }));
2732+
const guard = 'has(vars.source) && has(vars.source.id) ? vars.source.id : null';
2733+
expect(f!.hint).toContain(`\`${guard}\``);
2734+
expect(f!.hint).not.toContain('`has(source.id)');
2735+
expect(refusalsOf(valueFlow('create_record', { objectName: 'task', fields: { who: CEL(guard) } }))).toEqual(NONE);
2736+
});
2737+
27232738
it('control: outside the value and text slots the single brace still resolves, and the hint still names it', () => {
27242739
const [f] = lintFlowPatterns(httpFlow({ ticket: '$source.id' })).filter((x) => x.rule === FLOW_BARE_DOLLAR_REF);
27252740
expect(f!.hint).toContain('Wrap it and bind a variable: `{source.id}`');

‎packages/services/service-automation/src/builtin/value-slot-template-grammar.test.ts‎

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,12 +35,15 @@
3535
* template — including a head variable named like an identifier CEL claims
3636
* for itself (`{list.0}` → `vars["list"][0]`, #22290), or one the flow
3737
* scope binds over it (`{vars.0}` → `vars["vars"][0]`), and inside an
38-
* EXPRESSION token (`{int * 2}` → `vars["int"] * 2`).
38+
* EXPRESSION token (`{int * 2}` → `vars["int"] * 2`) — and the `has()`
39+
* guard it prints answers `null`, rather than failing the run, where the
40+
* variable itself was never bound (#19939 pass 3).
3941
*/
4042

4143
import { describe, expect, it } from 'vitest';
4244
import { VALUE_SLOT_TEMPLATE_REFUSAL, valueSlotTemplateRefusals } from '@objectstack/spec/automation';
4345
import { AutomationEngine } from '../engine.js';
46+
import { registerLogicNodes } from './logic-nodes.js';
4447
import { interpolateString } from './template.js';
4548

4649
const VARIABLES = new Map<string, unknown>([
@@ -196,11 +199,62 @@ describe('the remedy a refused path prints reads, through CEL, the value the int
196199
});
197200

198201
it('the guard is read off the refusal too, so each row above evaluates it where one is printed', () => {
199-
expect(printedSpellings('{list.tags}')).toEqual(['vars["list"].tags', 'has(vars.list.tags) ? vars.list.tags : null']);
202+
expect(printedSpellings('{list.tags}')).toEqual(['vars["list"].tags', 'has(vars.list) && has(vars.list.tags) ? vars.list.tags : null']);
200203
expect(printedSpellings('{list}')).toEqual(['vars["list"]', 'has(vars.list) ? vars.list : null']);
201204
expect(printedSpellings('{null.tags}')).toEqual(['vars["null"].tags']);
202205
});
203206

207+
// The guard is for "one that may be absent" — the VARIABLE as well as a key.
208+
// A variable the run never bound (an `isInput` one the caller left out, with
209+
// no default: `seedDeclaredVariables` sets nothing for it) is absent from the
210+
// scope, and `has()` evaluates everything but its last selection, so a guard
211+
// on the last key alone, `has(source.id)`, fails the run there. The printed
212+
// guard tests each step off `vars`, which holds only the bound variables.
213+
it.each([
214+
['{source.id}', 'source', { id: 'r1' }, 'r1'],
215+
['{record.owner.name}', 'record', { owner: { name: 'Ada' } }, 'Ada'],
216+
['{list.tags}', 'list', { tags: 'T' }, 'T'],
217+
['{x}', 'x', 7, 7],
218+
])('the printed guard of %s answers `null` where the variable, or a key on the way, is absent', (token, root, bound, value) => {
219+
const guard = printedSpellings(token)[1]!;
220+
expect(guard, token).toMatch(/^has\(vars\./);
221+
const evaluate = (variables: Map<string, unknown>) =>
222+
engine.evaluateValueEnvelope({ dialect: 'cel', source: guard }, variables, token);
223+
expect(evaluate(new Map()), `${guard} — ${root} unbound`).toBeNull();
224+
expect(evaluate(new Map([[root, null]])), `${guard} — ${root} null`).toBeNull();
225+
if (typeof bound === 'object') expect(evaluate(new Map([[root, {}]])), `${guard} — a key absent`).toBeNull();
226+
const variables = new Map<string, unknown>([[root, bound]]);
227+
expect(interpolateString(token, variables, CONTEXT)).toEqual(value);
228+
expect(evaluate(variables), `${guard} — bound`).toEqual(value);
229+
});
230+
231+
it('through a run: an `isInput` variable the caller left out — the printed guard writes `null`, a guard on the last key fails the run', async () => {
232+
const guard = printedSpellings('{source.id}')[1]!;
233+
expect(guard).toBe('has(vars.source) && has(vars.source.id) ? vars.source.id : null');
234+
const run = async (source: string) => {
235+
const flowEngine = new AutomationEngine(quiet);
236+
registerLogicNodes(flowEngine, { logger: quiet, getService: () => undefined } as never);
237+
flowEngine.registerFlow('guarded', {
238+
name: 'guarded', label: 'Guarded', type: 'autolaunched',
239+
variables: [{ name: 'source', type: 'object', isInput: true }, { name: 'who', type: 'text', isOutput: true }],
240+
nodes: [
241+
{ id: 'start', type: 'start', label: 'Start' },
242+
{ id: 'assign', type: 'assignment', label: 'Assign', config: { assignments: { who: { dialect: 'cel', source } } } },
243+
{ id: 'end', type: 'end', label: 'End' },
244+
],
245+
edges: [{ id: 'e1', source: 'start', target: 'assign' }, { id: 'e2', source: 'assign', target: 'end' }],
246+
} as never);
247+
return flowEngine.execute('guarded', { params: {} } as never);
248+
};
249+
const guarded = await run(guard);
250+
expect(guarded.success, guarded.error).toBe(true);
251+
expect(guarded.output).toEqual({ who: null });
252+
// Control: the guard the refusal printed before — `has()` on the last key alone.
253+
const lastKeyOnly = await run('has(source.id) ? source.id : null');
254+
expect(lastKeyOnly.success).toBe(false);
255+
expect(lastKeyOnly.error).toContain('Unknown variable: source');
256+
});
257+
204258
it('a later keyword segment, and an index in the middle of a path', () => {
205259
const variables = new Map<string, unknown>([['record', { in: 'x', tags: { null: 'y' } }], ['rows', [{ name: 'r0' }]]]);
206260
expectReadingsAgree('{record.in}', variables, 'x');

‎packages/spec/src/automation/flow-value-slot-template.test.ts‎

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -73,10 +73,18 @@ describe('a value-slot string in the retired `{…}` dialect is refused, with th
7373
expect(message).toContain('the guarded form writes `null`');
7474
});
7575

76-
it('a dotted path: the same path in CEL, guarded on its last key', () => {
76+
// A guard on the last key alone does not hold where the VARIABLE is absent:
77+
// `has(source.id)` fails the run when `source` was never bound (`Unknown
78+
// variable`). So the guard tests each step off `vars`, which holds only the
79+
// bound variables. That it answers `null` there is pinned through the
80+
// engine in `service-automation`'s `value-slot-template-grammar.test.ts`.
81+
it('a dotted path: the same path in CEL, guarded a step at a time from `vars`', () => {
7782
const message = refusalOf('{record.assignee}');
7883
expect(message).toContain("{ dialect: 'cel', source: 'record.assignee' }");
79-
expect(message).toContain('`has(record.assignee) ? record.assignee : null`');
84+
expect(message).toContain('`has(vars.record) && has(vars.record.assignee) ? vars.record.assignee : null`');
85+
expect(message).toContain('a step at a time from `vars`, which holds only the variables the run has bound');
86+
expect(message).not.toContain('`has(record.assignee)');
87+
expect(refusalOf('{a.b.c}')).toContain('`has(vars.a) && has(vars.a.b) && has(vars.a.b.c) ? vars.a.b.c : null`');
8088
});
8189

8290
it('a numeric segment indexes the list: `userList.0` becomes `userList[0]`', () => {
@@ -147,8 +155,8 @@ describe('a head CEL claims is read through `vars`, the route a `$`-named head t
147155

148156
it('a bare claimed variable keeps its guard, selected off `vars`; a keyword cannot be selected, so it gets none', () => {
149157
expect(refusalOf('{list}')).toContain('`has(vars.list) ? vars.list : null`');
150-
expect(refusalOf('{list.tags}')).toContain('`has(vars.list.tags) ? vars.list.tags : null`');
151-
expect(refusalOf('{for.tags}')).toContain('`has(vars.for.tags) ? vars.for.tags : null`');
158+
expect(refusalOf('{list.tags}')).toContain('`has(vars.list) && has(vars.list.tags) ? vars.list.tags : null`');
159+
expect(refusalOf('{for.tags}')).toContain('`has(vars.for) && has(vars.for.tags) ? vars.for.tags : null`');
152160
expect(refusalOf('{null}')).not.toContain('the guarded form');
153161
expect(refusalOf('{in.tags}')).not.toContain('the guarded form');
154162
});
@@ -174,7 +182,9 @@ describe('a head CEL claims is read through `vars`, the route a `$`-named head t
174182
expect(CEL_CLAIMED_IDENTIFIERS.has(ordinary)).toBe(false);
175183
expect(celPath(`${ordinary}.0.key`)).toBe(`${ordinary}[0].key`);
176184
}
177-
expect(refusalOf('{record.assignee}')).toContain('`has(record.assignee) ? record.assignee : null`');
185+
// The path is read bare; its guard, like every head's, tests each step off `vars`.
186+
expect(refusalOf('{record.assignee}')).toContain("source: 'record.assignee' }");
187+
expect(refusalOf('{record.assignee}')).toContain('`has(vars.record) && has(vars.record.assignee) ? vars.record.assignee : null`');
178188
expect(celPath('$error.message')).toBe('vars["$error"].message');
179189
});
180190
});
@@ -197,7 +207,7 @@ describe('a head the flow CEL scope claims is read through `vars`', () => {
197207
expect(celPath(`${name}.0`)).toBe(`vars["${name}"][0]`);
198208
expect(celPath(`${name}.tags`)).toBe(`vars["${name}"].tags`);
199209
expect(refusalOf(`{${name}.0}`)).toContain(`source: 'vars["${name}"][0]' }`);
200-
expect(refusalOf(`{${name}.tags}`)).toContain(`\`has(vars.${name}.tags) ? vars.${name}.tags : null\``);
210+
expect(refusalOf(`{${name}.tags}`)).toContain(`\`has(vars.${name}) && has(vars.${name}.tags) ? vars.${name}.tags : null\``);
201211
});
202212
});
203213

‎packages/spec/src/automation/flow-value-slot-template.ts‎

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,6 @@ import {
107107
TEMPLATE_TOKEN,
108108
celDateMacro,
109109
celExpression,
110-
celHeadReadsThroughVars,
111110
celPath,
112111
isRunUserIdToken,
113112
runUserPathNeverResolved,
@@ -151,23 +150,33 @@ function spanOf(source: string): string {
151150
type Write = (source: string) => string;
152151

153152
/**
154-
* A `has()` guard for a path of keys — the absent-key remedy. `has()` takes a
155-
* field selection over names alone: an index anywhere in its argument
156-
* (`has(a[0].b)`, `has(vars["$x"].y)`) is refused when it runs, so a path with
157-
* a numeric segment, a `$`-named head or a keyword segment gets no guard. A
158-
* bare variable, and a path whose head CEL or the flow scope claims
159-
* ({@link celHeadReadsThroughVars}), is selected off `vars`
160-
* (`has(vars.list.tags)`, `has(vars.vars.tags)`).
153+
* A `has()` guard for a path of keys — the absent-variable and absent-key
154+
* remedy. `has()` takes a field selection over names alone: an index anywhere
155+
* in its argument (`has(a[0].b)`, `has(vars["$x"].y)`) is refused when it
156+
* runs, so a path with a numeric segment, a `$`-named head or a keyword
157+
* segment gets no guard.
158+
*
159+
* The guard tests one step at a time, from `vars`, which holds only the
160+
* variables the run has bound: `has(vars.source) && has(vars.source.id) ?
161+
* vars.source.id : null`. `has()` evaluates everything but its last selection,
162+
* so `has(source.id)` fails the run (`Unknown variable`) when `source` was
163+
* never bound — an `isInput` variable the caller left out, with no default —
164+
* and `has(vars.source.id)` fails it too (`No such key`). The chain answers
165+
* `null` there, and where a key on the way is absent, `null`, or not a map
166+
* (`&&` stops at the first `false`). A path whose head CEL or the flow scope
167+
* claims (`celHeadReadsThroughVars` in the token module) is selected off
168+
* `vars` like any other (`has(vars.list) && has(vars.list.tags)`).
161169
*/
162170
function guardOf(path: string): string | undefined {
163171
const segments = path.split('.');
164172
if (segments[0]!.startsWith('$') || segments.some((s) => /^\d+$/.test(s) || CEL_KEYWORDS.has(s))) return undefined;
165-
const read = segments.length === 1 || celHeadReadsThroughVars(segments[0]!) ? `vars.${path}` : path;
166-
return `has(${read}) ? ${read} : null`;
173+
const steps = segments.map((_, at) => `vars.${segments.slice(0, at + 1).join('.')}`);
174+
return `${steps.map((step) => `has(${step})`).join(' && ')} ? ${steps[steps.length - 1]} : null`;
167175
}
168176

169177
const ABSENT_SENTENCE =
170-
'CEL refuses an absent variable or key where the template wrote nothing, so guard one that may be absent with `has()`';
178+
'CEL refuses an absent variable or key where the template wrote nothing, so guard one that may be absent with '
179+
+ '`has()`, a step at a time from `vars`, which holds only the variables the run has bound';
171180

172181
/**
173182
* The remedy for `{$User.Id}` as a whole value: `current_user.id`, and for a

‎packages/spec/src/migrations/entries/semantic/18.flow-value-slot-template-dialect-refused.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,10 @@ export const entry: SemanticMigration = {
5656
acceptanceCriteria:
5757
'Run objectstack validate: it reports each refused value as expression-invalid at the node and the value\'s '
5858
+ 'path, with the CEL spelling of its tokens. Rewrite each as that envelope; where a variable or key may be '
59-
+ 'absent, guard it (has(record.owner) ? record.owner : null, has(vars.x) ? vars.x : null for a variable) or '
60-
+ 'route around the node. For the run user, find which flows can run without one (a schedule, a record change '
59+
+ 'absent, guard it a step at a time from vars, which holds only the variables the run has bound '
60+
+ '(has(vars.x) ? vars.x : null, has(vars.source) && has(vars.source.id) ? vars.source.id : null; a guard on '
61+
+ 'the last key alone, has(source.id), fails the run when source itself was never bound) or route around the '
62+
+ 'node. For the run user, find which flows can run without one (a schedule, a record change '
6163
+ 'a system write can make): there, guard current_user.id, or skip the node with a start condition or a '
6264
+ 'decision on current_user != null where an update_record must leave the stored value alone. For a date '
6365
+ 'macro with a variable offset, check the variable is always set to a number where the flow runs; write a '

0 commit comments

Comments
 (0)