Found while implementing #3582 (PR #3809). Out of scope there; filing rather than widening that PR.
What happens
A flow node's config.filter is run through the flow template interpolator (service-automation/src/builtin/template.ts) before it reaches the data engine. That interpolator owns {…} in this position, and a whole-string token it cannot resolve becomes undefined:
// find_records node
config: {
objectName: 'opportunity',
filter: { close_date: { $gte: '{current_year_start}' } },
}
interpolateString('{current_year_start}', …) takes the single-token fast path, finds no flow variable of that name, and returns undefined. The engine then receives { close_date: { $gte: undefined } }.
{30_days_ago} fails the same way by a different route: it does not match the dotted-path grammar, so it falls into the arithmetic branch, _days_ago is substituted with null, and new Function('return (30null)') throws — also undefined.
Either way the bound silently disappears. No warning at registration, none at run time.
Why it matters now
Before PR #3809 nothing resolved filter placeholders server-side at all, so this was one instance of a general gap. That PR makes {current_year_start} / {current_user_id} work everywhere else on the server — the ObjectQL read path and the analytics dataset executor — which leaves flow filters as the one server-side surface where a filter token is still silently dropped. That inconsistency is worse than the uniform gap was: an author who has just learned the tokens work will reasonably try one in a flow.
A one-line caveat now sits in skills/objectstack-query/rules/filters.md, but documentation is a poor substitute for a diagnostic here — the failure mode is exactly the silent-zero class #3574 and #3582 were about.
Repro
defineFlow({
name: 'ytd_deals',
nodes: [
{ id: 'start', type: 'start', config: { triggerType: 'manual' } },
{ id: 'q', type: 'find_records', config: {
objectName: 'opportunity',
filter: { close_date: { $gte: '{current_year_start}' } },
} },
],
edges: [{ id: 'e1', source: 'start', target: 'q' }],
});
Expected: rows closing this year. Actual: the bound is dropped, every row matches, no diagnostic.
Options
Two dialects meet in this one config slot, so the decision is which owns it — worth deciding deliberately rather than by accident of evaluation order:
-
Pass through — the interpolator leaves a whole-string token alone when isKnownFilterToken() accepts it and no flow variable shadows the name, letting resolveFilterTokens() handle it downstream. Makes flow filters consistent with every other surface. The cost is one cross-dialect exception, narrowly scoped to filter-bearing config keys.
-
Reject at registration — extend validateFlowExpressions / validate-flow-template-paths to flag a filter-token spelling inside a flow filter, telling the author to compute the bound in an earlier node. Keeps the dialects separate; the token stays unsupported, but loudly.
Either beats today's silence. (1) is what an author expects; (2) is the cheaper and more conservative change. Flagging the trade-off rather than picking one, since it is a contract decision about who owns {…} in a flow config.
Found while implementing #3582 (PR #3809). Out of scope there; filing rather than widening that PR.
What happens
A flow node's
config.filteris run through the flow template interpolator (service-automation/src/builtin/template.ts) before it reaches the data engine. That interpolator owns{…}in this position, and a whole-string token it cannot resolve becomesundefined:interpolateString('{current_year_start}', …)takes the single-token fast path, finds no flow variable of that name, and returnsundefined. The engine then receives{ close_date: { $gte: undefined } }.{30_days_ago}fails the same way by a different route: it does not match the dotted-path grammar, so it falls into the arithmetic branch,_days_agois substituted withnull, andnew Function('return (30null)')throws — alsoundefined.Either way the bound silently disappears. No warning at registration, none at run time.
Why it matters now
Before PR #3809 nothing resolved filter placeholders server-side at all, so this was one instance of a general gap. That PR makes
{current_year_start}/{current_user_id}work everywhere else on the server — the ObjectQL read path and the analytics dataset executor — which leaves flow filters as the one server-side surface where a filter token is still silently dropped. That inconsistency is worse than the uniform gap was: an author who has just learned the tokens work will reasonably try one in a flow.A one-line caveat now sits in
skills/objectstack-query/rules/filters.md, but documentation is a poor substitute for a diagnostic here — the failure mode is exactly the silent-zero class #3574 and #3582 were about.Repro
Expected: rows closing this year. Actual: the bound is dropped, every row matches, no diagnostic.
Options
Two dialects meet in this one config slot, so the decision is which owns it — worth deciding deliberately rather than by accident of evaluation order:
Pass through — the interpolator leaves a whole-string token alone when
isKnownFilterToken()accepts it and no flow variable shadows the name, lettingresolveFilterTokens()handle it downstream. Makes flow filters consistent with every other surface. The cost is one cross-dialect exception, narrowly scoped to filter-bearing config keys.Reject at registration — extend
validateFlowExpressions/validate-flow-template-pathsto flag a filter-token spelling inside a flow filter, telling the author to compute the bound in an earlier node. Keeps the dialects separate; the token stays unsupported, but loudly.Either beats today's silence. (1) is what an author expects; (2) is the cheaper and more conservative change. Flagging the trade-off rather than picking one, since it is a contract decision about who owns
{…}in a flow config.