Repository navigation
Explicit syntax for a legitimately optional filter condition (design only — no implementation until a real request) #3862
Description
Status: design placeholder. Do not implement until a real user asks. Filed so the next person who hits the guard finds the reasoning instead of re-deriving it — or worse, relaxing the guard.
The gap
Since #3810, a CRUD node refuses to run when interpolation erased any authored filter condition. That is correct as a default: an absent condition does not narrow a query, it widens it (see the decision note on #3810 for why get_record keeps the hard failure).
But it closes off a shape that is sometimes genuinely intended:
Filter by
campaignif the run received one; otherwise return all rows.
Today that is inexpressible. { campaign: '{campaignId}' } on a run without campaignId is indistinguishable — to the guard and to a reader — from { campaign: '{campiagnId}' }, a typo. Both erase a condition; the guard refuses both, which is the right call precisely because it cannot tell them apart.
The constraint any design must respect
Fail-closed stays the default. The author must say "this condition is optional" explicitly, at authoring time. An implicit or inferred optionality re-opens the fail-open direction #3810 closed, and the typo case is far more common than the optional case.
Options sketched (not chosen)
A. An $optional marker on the condition
filter: { campaign: { $optional: '{campaignId}' } }- Local and readable; the intent sits exactly where the risk is.
- Needs a spec addition and a runtime branch in
resolveNodeFilter(drop this key when it does not resolve, keep refusing on every other erasure). - The lint in fix(lint,cli): a filter reference that cannot resolve fails the build, not the run (#3426, #3810) #3861 must learn the marker or it will error on a deliberately-optional filter at build time.
- Question: does an unresolved
$optionaldrop the condition, or is there a fallback value slot?
B. Branch to two get_record nodes
- Zero new syntax; a
decisionnode routes on whether the variable is set, each branch carrying its own fully-literal filter. - Both queries are explicit and greppable — arguably the clearest thing to read.
- Verbose, and it duplicates the rest of the filter across both branches. Gets ugly with two or more optional conditions (2^N branches).
C. Author-side default at the variable, not the filter
- Give the flow variable a declared default (
campaignId ?? '*') so the token always resolves. - Requires a filter semantic for "match anything," which is a bigger and riskier change than either of the above.
Recommendation if this ever gets picked up
Start from A, and require it to be inert unless explicitly written. B already works today with no platform change — point people at it in the meantime; it is the honest answer for the one-or-two-condition case.
Do not
Relax the #3810 guard or downgrade it to a warning to serve this use case. That trades a deterministic build/run failure for a silent data-widening bug, and the optional case is the rare one. See the decision note on #3810.
Related
- Flow node filters silently blank date macros: the template engine consumes
{…}before the query engine sees it #3810 — the guard, and why it fails closed - fix(lint,cli): a filter reference that cannot resolve fails the build, not the run (#3426, #3810) #3861 — raises the build-time lint to
errorfor filter positions - Node-level error branches: let a flow handle a failed node instead of aborting the run #3863 — node-level error branches, which would lower the cost of strict guards generally
Activity
os-zhuang commentedon Jul 28, 2026
Closing: not planned
Decided proactively rather than left pending a user request. Three reasons, in order of weight.
1. This is syntax, not capability — the capability already ships
Option B in the body (a
decisionnode routing to twoget_recordnodes, each with a fully literal filter) expresses "filter by campaign if one was passed" today, with no platform change. It is also the more readable of the two: both queries are explicit and greppable.So
$optionalwould not unlock anything. It would shorten something already expressible — and pay for that shortening with the risk below.2. Its dominant practical effect would be a universal guard-suppressor
The #3810 guard works on AI authors precisely because it is a deterministic hard signal: this filter is broken, fix it. An AI hits the error, reads the named template, corrects the field name, converges in one round.
Introduce
$optionaland the same error now has two fixes available:$optional— the error disappears, and the result is wrongFix 2 is cheaper and works on every filter error. A generation loop optimizing for "make the diagnostic go away" reaches for it by default. We would be shipping a one-token switch whose only function is to disable the safety net, and handing it to the actor most likely to pull it reflexively.
Since ObjectStack's premise is that AI authors the metadata, this is not a marginal concern — it is the primary one.
3. The asymmetry that makes this a general rule
Every new syntax slot is another fork an AI can take incorrectly, and the cost is higher for a platform whose pitch is "small enough for a model to hold in context and reason about whole." The bar for adding expressiveness should therefore sit above the usual one, and the bar for tightening a guard below it.
If the underlying need resurfaces, it should not come back as this design
The real need behind this issue is dynamic filters — composing a query from conditions known at run time. That belongs in the data/query layer as explicit filter composition (build a filter from parts, each part present or absent by construction), not as a flow-template token that is allowed to vanish. A vanishing token is indistinguishable from a typo by design; a composed filter is not. Anyone picking this up later should start from that framing, not from
$optional.What stays true
The #3810 guard is unchanged and stays fail-closed. #3861 catches the statically decidable half at build time, across all three commands since #3874. Authors needing a conditional filter branch to two nodes.
Closing as not planned.
Generated by Claude Code