Repository navigation
A filter with an operator outside VALID_AST_OPERATORS is silently dropped, not rejected — single-condition views return unfiltered results #3948
Description
A bare-triple filter whose operator is not in VALID_AST_OPERATORS is passed through unconverted and then skipped entirely by the SQL driver. No WHERE clause is emitted, no error is raised, and the caller gets every row. The user sees a list they believe is filtered and it isn't.
This is reachable today through an ordinary ObjectUI view-config affordance — it is not hypothetical.
The chain
Each hop verified by reading the code; the spec hop was executed.
1. isFilterAST gates a bare triple on the operator being known — packages/spec/src/data/filter.zod.ts:400:
if (filter.length >= 2 && typeof filter[1] === 'string') {
return VALID_AST_OPERATORS.has(filter[1].toLowerCase());
}2. The protocol treats that as a conversion gate, not a validation gate — packages/metadata-protocol/src/protocol.ts:2754-2757:
if (isFilterAST(parsedFilter)) {
parsedFilter = parseFilterAST(parsedFilter);
}
options.where = parsedFilter; // <-- unconverted array assigned as-is3. applyFilters walks that array — packages/plugins/driver-sql/src/sql-driver.ts:3702-3708. For a bare triple the three elements are all strings, so each hits the string branch, matches neither and nor or, and is continued:
for (const item of filters) {
if (typeof item === 'string') {
if (item.toLowerCase() === 'or') nextJoin = 'or';
else if (item.toLowerCase() === 'and') nextJoin = 'and';
continue; // <-- 'close_date', 'before', '2024-01-01' all land here
}
if (Array.isArray(item)) { ... }
}No Array.isArray branch is ever taken. No predicate is emitted.
driver-memory has the same shape (default: return null, memory-driver.ts:768).
Executed evidence
Against published @objectstack/spec:
["close_date","before","2024-01-01"] isFilterAST=false *** PASSED THROUGH UNCONVERTED ***
["close_date","after","2024-01-01"] isFilterAST=false *** PASSED THROUGH UNCONVERTED ***
["stage","not in",["won","lost"]] isFilterAST=false *** PASSED THROUGH UNCONVERTED ***
["status","=","active"] isFilterAST=true converted -> {"status":"active"}
Why those three operators, and why it is reachable
VALID_AST_OPERATORS (filter.zod.ts:352) contains not_in and nin but not 'not in' (with a space), and contains neither before nor after.
ObjectUI's view-config builder emits exactly those three verbatim — packages/plugin-view/src/config/view-config-utils.ts:46:
'notIn': 'not in', // space — VALID_AST_OPERATORS has not_in / nin
'before': 'before', // absent
'after': 'after', // absentand data-objectstack's normalizeFilterOperator ends ?? op, so they reach the wire unchanged.
The trigger is the single-condition case. toSpecFilter (view-config-utils.ts:158) returns a bare triple when there is exactly one condition with AND logic:
if (triplets.length === 1 && logic === 'and') return triplets[0];So: an author adds one date filter "before " to a view → the saved view returns every row, silently. Add a second condition and the shape becomes nested, which reaches the driver's wider switch and throws instead — the failure mode flips on how many conditions the user happened to add.
Scope of impact
Not a permission bypass, to be explicit about it. Row-scoping middleware $and-composes onto the same ast.where, so a scoped object yields {$and: [<raw array>, <scope>]}; the $and arm (sql-driver.ts:3987) calls applyFilterCondition on each element, and Object.entries(['close_date','before','2024-01-01']) produces keys "0"/"1"/"2" — it would attempt to filter on columns named 0, 1, 2 and error. The scope arm survives. (This arm is reasoned from the code, not executed.)
So the impact is: on objects without row-scoping, a silently unfiltered result set; on scoped objects, a confusing SQL error. Both are wrong; neither crosses a permission boundary.
Backends disagree on the failure mode
| Path | Unknown operator → |
|---|---|
driver-sql, $-object |
throw (sql-driver.ts:4016) |
| driver-sql, nested AST | throw (sql-driver.ts:3866) |
| driver-sql, bare triple | silently drops the filter |
| driver-memory, AST | silently drops the condition (memory-driver.ts:768) |
| driver-mongodb, AST | passes $${op} raw to Mongo → server error |
mapAnalyticsOperator |
silently becomes $eq (protocol.ts:3800) |
matches-filter (RLS) |
false — fail closed |
analytics preview-evaluator |
true — fail open (preview-evaluator.ts:44) |
Six behaviours for one input class.
Suggested fix
- In
applyFilters(sql-driver.ts:3702) andmemory-driver.ts:768: an array item that is neither a recognised join keyword nor an array — and a bare triple whose operator is unrecognised — should throw, matching what the nested and$-object paths already do. Silently emitting no predicate is the one outcome that must not happen. - Consider making
protocol.ts:2754reject rather than pass through: if the value looks like a filter array butisFilterASTsays no, that is a malformed filter, not an opaquewhere. - Independently,
before/after/'not in'are ObjectUI-side spellings that no spec vocabulary defines — tracked at Vocabulary consolidation (#2901): what shipped, and why Track C items 1–3 are not planned objectui#2945. They should be lowered client-side to</>/not_in(ObjectUI's ownListView.tsx:75already does exactly this forbefore/after; the view-config persistence path does not). Fixing that removes today's trigger but not the underlying silent-drop.
Why this blocks other work
Any narrowing of VALID_AST_OPERATORS — e.g. consolidating the filter-operator vocabularies — converts working bare-triple filters into silently unfiltered queries. Item 1 above should land before any such narrowing is attempted, otherwise the change is untestable: it passes CI, passes os build, and degrades quietly against stored view metadata.
Found while auditing spec-enum ↔ renderer coverage for objectstack-ai/objectui#2901.
Activity
os-zhuang commentedon Jul 30, 2026
Correction to item 3 of the report — I had the attribution backwards, and it makes this issue bigger, not smaller.
I wrote that
before/after"are ObjectUI-side spellings that no spec vocabulary defines". That is wrong. They are canonical members ofVIEW_FILTER_OPERATORS(packages/spec/src/ui/view.zod.ts:90):So this is not a client emitting junk. It is two spec vocabularies disagreeing — the view layer blesses an operator as canonical authoring vocabulary, and the query layer's AST gate does not accept it.
The disagreement is 8 of 19, not 2
These
VIEW_FILTER_OPERATORSmembers are absent fromVALID_AST_OPERATORS:equalsis on that list — the most common filter operator there is. Every one of them is a spellingViewFilterRuleSchemavalidates anddefineStackaccepts.They are not all reachable today only because ObjectUI's adapter happens to translate six of them (
FILTER_OPERATOR_ALIASES,packages/data-objectstack/src/index.ts:51). It had no entry forbefore/after— which is the entire reason those two surfaced and the other six did not. The safety of the query path is currently resting on a hand-written table in a different repository being complete, and it wasn't.What that changes about the fix
Item 3 as written pointed the wrong way. The client-side lowering is worth doing and is done — objectstack-ai/objectui#2974 — but it is a mitigation, not the fix. It leaves the contract still self-contradictory and the next gap still silent.
The durable fix belongs here, and there are two shapes:
AST_OPERATOR_MAPfolds the view vocabulary. Add the eight, mappingequals→=,before→<,after→>, etc.parseFilterASTthen accepts anything the view layer can author. Cheapest, and purely additive toVALID_AST_OPERATORS— no narrowing, so none of the migration risk in the original report applies.VIEW_FILTER_OPERATORScannot fail to have an AST lowering.filter.zod.ts:362already documents thatVALID_AST_OPERATORSis kept in sync with ObjectUI's adapter by hand; that comment is the bug. Two vocabularies plus five independent alias tables cannot be held in agreement by convention.Items 1 and 2 of the original report stand unchanged, and item 1 — making the driver throw rather than skip — is still the prerequisite. It is what turns "8 of 19 canonical operators silently return every row" into "8 of 19 raise an error", which is the difference between a latent data-correctness bug and a caught one.
os-zhuang commentedon Jul 30, 2026
Reopened — my commit message said "Closes #3948 item 1", which GitHub read as closing the whole issue. Only item 1 is done.
Done — #4029. Both drivers now throw on a filter element that is neither a logical keyword nor a condition array, so the bare-triple silent drop is gone and the three code paths (bare / nested /
$-object) finally agree.driver-memoryadditionally gained the sevenVALID_AST_OPERATORSmembers it had been dropping (not_in,is_null,is_not_null,isnull,isnotnull,is_empty,is_not_empty) — throwing without implementing those would have traded a silent wrong-result for a hard regression on operators the spec blesses.Verified: driver-sql 487 pass, driver-memory 153 pass, objectql 1171 pass, plus the Dogfood Regression Gate.
Still open:
Item 2 — make
protocol.ts:2754reject rather than pass through. It is currently a conversion gate: whenisFilterAST()says no, the raw value is assigned towhereunchanged. After #4029 that now surfaces as a driver error rather than silence, which is a big improvement, but the error names a driver-level symptom for what is really a malformed request. Rejecting at the protocol boundary would return a 4xx naming the operator instead.Item 3 — the two spec vocabularies still disagree. This is the root cause and it is untouched. 8 of the 19 canonical
VIEW_FILTER_OPERATORSare absent fromVALID_AST_OPERATORS—equals,not_equals,greater_than,less_than,greater_than_or_equal,less_than_or_equal,before,after. An author can declare any of them on aViewFilterRule,ViewFilterRuleSchemavalidates them, anddefineStackaccepts them; whether the query then works depends on a hand-written alias table in a different repository being complete.After #4029 an incomplete table produces a loud error instead of unfiltered rows, so this is no longer a correctness hazard — it is now a reliability one. The fix is purely additive and carries none of the migration risk in the original report: teach
AST_OPERATOR_MAPthe eight spellings (equals→=,before→<,after→>, …), or better, derive one vocabulary from the other so a token added toVIEW_FILTER_OPERATORScannot ship without an AST lowering.filter.zod.ts:362currently documents the cross-repo hand-sync as the mechanism; that comment is the bug.fix(spec,drivers): the view filter vocabulary and the AST vocabulary …
os-zhuang commentedon Jul 30, 2026
Item 3 done — #4039. Only item 2 remains.
AST_OPERATOR_MAPis now the single source of truth:VALID_AST_OPERATORSis derived from its keys rather than restated, so an operator can no longer passisFilterAST()without also having a lowering. The two were independent hand-written lists that happened to agree, with nothing enforcing it — and both failure directions were invisible.The eight canonical view spellings are in, plus the squashed/short forms stored metadata carries. New export
canonicalAstOperator(op)folds every accepted spelling of one comparison onto a single infix form, and both drivers call it instead of growing private alias lists — which is what let them accept different vocabularies before #4029.like/ilikeare deliberately not folded ontocontains, since driver-sql passes them to SQL verbatim and folding would silently wrap the value in%…%.Widening only, so no stored filter stops validating and there is no migration.
Verified: spec 6922, objectql 1171, driver-sql 487, driver-memory 178, plus
Check Generated Artifacts,Spec property liveness,Temporal Conformance (live PG + MySQL)and both Dogfood Regression Gate shards. The parity test was confirmed to fail without the fix.Worth recording how the driver half surfaced: driver-memory's parity test from #4029 iterates
VALID_AST_OPERATORSdirectly, so widening the gate failed it immediately — the drivers' array paths lagged by 20 spellings. driver-sql had the identical gap and no test to catch it. That is the argument forcanonicalAstOperatorover two more alias lists.Item 2 still open — make
protocol.ts:2754reject rather than pass through.It remains a conversion gate: when
isFilterAST()says no, the raw value is assigned towhereunchanged. After #4029 that surfaces as a driver error rather than silence, and after #4039 the 8-of-19 mismatch that made it reachable from ordinary authoring is gone — so this is now the last of the three, and the least urgent.What it still buys: the error names a driver-level symptom for what is really a malformed request. Rejecting at the protocol boundary would return a 4xx naming the operator, instead of surfacing as a SQL-layer failure. Worth doing, no longer load-bearing.
fix(view,components): the spec→FilterBuilder operator table covers th…
fix(driver-sql,driver-memory): an uncompilable filter throws instead …
refactor(spec)!: retire three orphan operator vocabularies (objectui#…
os-zhuang commentedon Jul 30, 2026
Status check against
main— item 1 has landed, item 3 has landed, item 2 has notRe-read the code on
dc530b442while working through objectstack-ai/objectui#2945, which lists this issue as its blocker. Recording what is actually true now, because the issue text still reads as though nothing has shipped.✅ Item 1 — the drivers throw
driver-sql'sapplyFilters(sql-driver.ts:4743) anddriver-memory(memory-driver.ts:811,:819) both throw on an unrecognized operator, with#3948in the comments and dedicated regression tests:packages/plugins/driver-sql/src/sql-driver-filter-no-silent-drop.test.tspackages/plugins/driver-memory/src/memory-filter-ast-vocabulary.test.tsThe silent drop is gone, which means objectui#2945's prerequisite 1 is satisfied and narrowing
VALID_AST_OPERATORSis now testable.AST_OPERATOR_MAPalso gainedbefore/after, andVALID_AST_OPERATORSis now derived from its keys — so the "a name in one but not the other" failure mode this issue described is structurally closed, not just fixed.✅ Item 3 — the objectui spellings
Fixed in two parts, and the reachability argument in this issue is now stale:
ListView.mapOperatoranddata-objectstack'snormalizeFilterOperatorto the spec vocabulary in both directions, with parity guards. Its guard found eight more missed spellings on its first run.BUILDER_TO_SPEC_OP/toSpecFilter— theview-config-utils.ts:46table this issue cites as its trigger. It had zero callers anywhere in objectui, was not exported from@object-ui/plugin-view, and had no test; it was left behind when the studio's spec-driven inspector replacedbuildViewConfigSchema.So the trigger described in "Why those three operators, and why it is reachable" was already dead code when this issue was filed. That does not weaken the finding — the silent drop was real and worth fixing on its own terms — but the "reachable today through an ordinary ObjectUI view-config affordance" framing no longer holds, and anyone reading this issue to prioritise work should know that.
❌ Item 2 — the protocol still passes an unparsed array through
protocol.ts:2849-2852is unchanged:Its remaining value is error quality and locality, not correctness: the drivers now throw, so nothing is silently dropped either way — you just get a driver-level error instead of a rejection at the boundary where the malformed filter arrived.
Worth noting for whoever picks it up:
isFilterASTdoes accept nested arrays (filter.zod.ts:516), so the naive form of this change does not break legitimate[[cond], [cond]]filters. What it would newly reject is an array-shapedwherethat is not an AST at all — including any driver-specific extension a caller passes opaquely. That is a request-path narrowing, so it wants a deliberate decision rather than a drive-by, which is presumably why the issue said "Consider".Not part of this issue, but adjacent
objectstack-ai/objectui#2945's prerequisite 2 —
saveMetadiscarding the normalizedfiltersubtree, so every save mints newVIEW_FILTER_OPERATOR_ALIASESrows — is #4107.Suggested disposition: this issue's headline defect is fixed. Either close it and file item 2 separately as an error-quality improvement, or retitle it to item 2 alone. Leaving it open under the current title keeps objectui#2945 looking blocked on something that has shipped.
os-zhuang commentedon Jul 30, 2026
Closing — the headline defect is fixed and verified; item 2 is now #4121
Verified against
origin/mainrather than from memory:origin/mainbefore/afternow lowerfilter.zod.ts:399-400—'before': '$lt','after': '$gt'sql-driver.ts:4743throwsUnrecognized filter operatormemory-driver.ts— 7 throw sites,#3948in the commentsdriver-sql/src/sql-driver-filter-no-silent-drop.test.ts,driver-memory/src/memory-filter-ast-vocabulary.test.tsTest Coregreen on the four PRs merged today (#4061, #4070, #4107 here, plus objectui#2989).changeset/filter-no-silent-drop.mdThis issue's own evidence table is now retired. It opened with:
beforeandafterare inAST_OPERATOR_MAPnow, soisFilterASTreturns true for that input and it is parsed rather than passed through. AndVALID_AST_OPERATORSis derived from that map's keys, so the "a name in one list but not the other" failure mode this issue described is structurally closed, not merely patched.The reachability framing did not survive scrutiny
The issue argued the bug was "reachable today through an ordinary ObjectUI view-config affordance", via
toSpecFilteratview-config-utils.ts:158. That function had zero callers anywhere in objectui, was not exported from@object-ui/plugin-view, and had no test — it was left behind when the studio's spec-driven inspector replacedbuildViewConfigSchema. It is deleted in objectstack-ai/objectui#2989.The defect was real and worth fixing on its own terms: a driver that silently returns every row is wrong whether or not a caller currently reaches it, and
driver-memorywas worse than described (it cast each string to a logic keyword and returned{}, matching everything). But anyone reading this thread to prioritise work should know the "reachable today" claim was mistaken.Item 3 also done
ListView.mapOperatoranddata-objectstack'snormalizeFilterOperatorto the spec vocabulary in both directions, with parity guards — the guard found eight more missed spellings on its first run.view-config-utils), which resolved only 10 of the 19 canonical view operators, and deleted the dead write direction.Item 2 continues as #4121
The protocol still assigns an unparsed array to
options.where. Its remaining value is error locality, not correctness — the drivers throw now, so nothing is dropped either way; you just get a driver-level message several layers from the request. #4121 carries it, including the trap thatisFilterASTalready accepts nested arrays (so the naive change does not break[[cond],[cond]]) and the note that it is a request-path narrowing needing a deliberate decision.Downstream unblocked
objectstack-ai/objectui#2945 listed this issue as prerequisite 1 for narrowing
VALID_AST_OPERATORS. That prerequisite is satisfied — the narrowing is now testable, because a dropped operator fails loudly instead of passing CI and degrading against stored rows. Prerequisite 2 landed as #4107. The only remaining blocker there is a production data scan, which is an operational task, not a code change.Closing as fixed.
fix(service-analytics): the matrix's sixth consumer, and the dropped …
docs(service-analytics): the filter normalizer states its real coverage
9 remaining items
feat(spec): 声明 `FilterArray` 为仅输入的授权糖(objectstack-ai#5158 拍板 C 第 1 步) (…
fix(driver-memory): refuse the filters the live query path cannot eva…
fix(analytics,rest): an analytics filter refusal reaches the caller a…
fix(driver-sql,driver-memory,driver-mongodb): refuse out-of-contract …
fix(driver-memory): the analytics face stops round-tripping comparand…
fix(driver-memory): the analytics face compiles $notContains to a pre…
feat(spec): $icontains(ASCII 折叠域)+ $contains 族钉死大小写敏感 + $regex 退役指引表 (o…
chore(qa,objectql): drop dogfood's unconsumed driver-memory devDep + …
feat(spec,drivers,objectql,analytics,formula): $icontains reaches eve…
fix(objectql): gate `having`'s $icontains comparand shape (objectstac…
fix(driver-mongodb): the `$contains` family is case-SENSITIVE — drop …
fix(driver-memory): make the `$contains` family case-exact and answer…
fix(service-analytics): the draft preview refuses a filter operator i…
fix(service-analytics): the draft preview refuses a filter operator i…
test(spec): the third data/ file group's test titles state each cited…