Symptom
POST /api/v1/data/showcase_invoice/query with {"where":{"not_a_field":"x"}} answers:
200 { "records": [], "total": 0 }
— no code, no mention of the unknown name. Identically for the $filter door (GET ...?$filter=...) and for the filter-AST door (POST {filter: [...]}).
Expected: 400 INVALID_FIELD naming the field, the same envelope the sibling door already produces.
Same-run control on the same object, same field name: GET /api/v1/data/showcase_invoice?not_a_field=x → 400 INVALID_FIELD.
So one endpoint family answers two different verdicts for one mistake, chosen by which door the caller used — and the losing answer is "indistinguishable from no data", which is exactly what #4134 closed for the bare-key door: an empty result set that a client cannot tell apart from a filter that legitimately matched nothing.
Root cause
The #4134 read-path field-existence gate (assertQueryParamsAreFields, backed by resolveQueryFields, in packages/metadata-protocol/src/protocol.ts) covers only the implicit filters findData derives from leftover query parameters. The explicit filter axes — a where object, a $filter string, a filter AST — reach the driver without passing that gate, so an unknown key is lowered into a field-equality predicate that can only match zero rows.
Relationship to #4134 (closed/completed, fixed by PR #4169): this is not a regression of it — the bare-key control still answers 400 INVALID_FIELD in this run. It is the sibling door #4134's fix did not reach, and the gate it introduced (resolveQueryFields, explicitly documented as "ONE resolution shared by all four read axes") is the natural place for the fix to land.
Reproduction
- Boot showcase on a fresh isolated file DB (
SqlDriver / better-sqlite3); authenticate as admin@objectos.ai.
POST /api/v1/data/showcase_invoice/query body {"where":{"not_a_field":"x"}} → 200 {"records":[],"total":0}, no code.
- Repeat through the
$filter door and the AST (filter) door — same 200/0.
- Control, same run and same object:
GET /api/v1/data/showcase_invoice?not_a_field=x → 400 INVALID_FIELD.
Source
Extracted from the QA run #7463 (framework a86db17).
Symptom
POST /api/v1/data/showcase_invoice/querywith{"where":{"not_a_field":"x"}}answers:— no
code, no mention of the unknown name. Identically for the$filterdoor (GET ...?$filter=...) and for the filter-AST door (POST {filter: [...]}).Expected:
400 INVALID_FIELDnaming the field, the same envelope the sibling door already produces.Same-run control on the same object, same field name:
GET /api/v1/data/showcase_invoice?not_a_field=x→400 INVALID_FIELD.So one endpoint family answers two different verdicts for one mistake, chosen by which door the caller used — and the losing answer is "indistinguishable from no data", which is exactly what #4134 closed for the bare-key door: an empty result set that a client cannot tell apart from a filter that legitimately matched nothing.
Root cause
The #4134 read-path field-existence gate (
assertQueryParamsAreFields, backed byresolveQueryFields, inpackages/metadata-protocol/src/protocol.ts) covers only the implicit filtersfindDataderives from leftover query parameters. The explicit filter axes — awhereobject, a$filterstring, a filter AST — reach the driver without passing that gate, so an unknown key is lowered into a field-equality predicate that can only match zero rows.Relationship to #4134 (closed/completed, fixed by PR #4169): this is not a regression of it — the bare-key control still answers
400 INVALID_FIELDin this run. It is the sibling door #4134's fix did not reach, and the gate it introduced (resolveQueryFields, explicitly documented as "ONE resolution shared by all four read axes") is the natural place for the fix to land.Reproduction
SqlDriver/ better-sqlite3); authenticate asadmin@objectos.ai.POST /api/v1/data/showcase_invoice/querybody{"where":{"not_a_field":"x"}}→200 {"records":[],"total":0}, nocode.$filterdoor and the AST (filter) door — same 200/0.GET /api/v1/data/showcase_invoice?not_a_field=x→400 INVALID_FIELD.Source
Extracted from the QA run #7463 (framework a86db17).