Uh oh!
There was an error while loading. Please reload this page.
fix(filter): refuse a where on a virtual formula field at both doors, instead of answering 200 with zero rows (#8296) - #8369
Conversation
…8296) The FILTER axis was the last of the three query axes with no unmaterializable verdict: a `where` on a `formula` field cleared `assertFilterFieldsExist` because the field IS known, reached a driver that materialises no column for it, and answered 200 with zero rows in BOTH directions — while SORT (#6994/#7095) and SEARCH (#6674) refuse the same field by name. Ingress: `assertFilterFieldsExist` grows a second verdict, judged by the same `@objectstack/spec/data` predicate the search axis uses (`isVirtualSearchField`), so gate and drivers cannot disagree about which types have a column. `summary`/`autonumber` keep filtering — both have real stored columns. Engine: `assertFilterIsMaterializable` closes the door the REST ingress cannot reach — a saved report forwards `query.filter` straight into `engine.find` — at `lowerWhereFilterArray`, the one seam every caller-supplied `where` passes through (find/findOne/count/aggregate/ update/delete). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012WMpuAfA2KSdDjGF6tm1bH
…ter-unmaterializable-verdict
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012WMpuAfA2KSdDjGF6tm1bH
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 2 package(s): 16 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also reference the affected code. These are read-only:
|
os-zhuang
commented
Aug 13, 2026
PM ruling on the red |
…ter-unmaterializable-verdict
…velope (#8296) `derived-flag-removal.test.ts` registers a test-local formula-shaped object (`derived_task`, invented by that file) to record why #7226 removed two inert flags rather than deriving them, and it pinned the exact behaviour #8296 abolishes: filtering a formula answering 0 rows with no error. Its three filtering assertions now assert the rejection envelope (status 400, INVALID_FIELD, field, object) instead of an empty array, and the `it` title no longer claims "0 rows, no error". #7226's decision is unchanged and its reasoning is stronger: a formula field still materialises no column and still cannot carry a predicate, so the eight app filters that named those flags still could not have worked. Only the failure mode changed, from an invisible zero to a named 400 -- which is the exception this very docblock had named as the safe design. Both docblocks are rewritten to state that. The read/projection half (a formula COMPUTES both flags correctly) and the stored-column CONTROL assertions are untouched; nothing under examples/app-todo/src/ or objectstack.config.ts is touched, and that app declares no formula field at all. The changeset's blast-radius sentence is corrected in the same commit: the original sweep covered app source and missed test files, which is where current behaviour is pinned and therefore where a behaviour change lands first. No app metadata filters a formula field -- that half held. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012WMpuAfA2KSdDjGF6tm1bH
os-zhuang
commented
Aug 13, 2026
ACCEPT — PM review, |
Uh oh!
There was an error while loading. Please reload this page.
… relay) Merge commit first, regeneration as its own commit per the sanctioned sequence; api-surface and export-origins regenerated after a fresh spec build, spec-changes/upgrade-guide/docs from the merged registry. Both sides verified present: the #8057 retirement (two [RETIRED] marks, the prescription const, tombstones + engine refusal) and main's #8296/#8369 virtual-formula where-refusal doors. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Euoy6wyfzgiWtgCg4s6JK2
Fixes#8296
formulais the one field type no driver materialises a column for. Three queryaxes can name a field; until this PR only two of them said so.
formulafield400 INVALID_SORT— ingress (#6994) and engine (#7095)400 INVALID_FIELD— refused by name (#6674)Dispatched under the standing maintainer ruling of 2026-08-12 (covering #7529 /
#7893 / #8010 / #7912): a declaration the platform cannot honour is refused at
the latest checkpoint that can see the whole picture, naming the offending key
path, and never answered 200.
Premise re-measured on current
mainThe card's line numbers were stale, so this was re-measured against
cb43296efby function name, on a real
ObjectQLwith the real protocol on top —is_opena
formulaover the storedstatuscolumn,subtask_totalasummary:Both directions are wrong and the
falseone is the dangerous one: the samepredicate against a stored boolean returns every row, so a filter meaning
"not yet done" silently became "no records at all" — a changed row SET under a
200, which no amount of inspecting the response can reveal. The formula READS
correctly in that very same response, so the field is visibly populated and
simultaneously unfilterable.
Both doors, because the engine door is author-reachable
The card left "ingress-only, or the engine door too?" open. Measured: the
engine door is required.
plugin-reports'executeReportforwards a savedreport's filter verbatim —
— which passes through no REST ingress at all, exactly as #7095 measured for
query.orderByone axis over. An ingress-only fix would have left that halfopen.
assertFilterFieldsExist(packages/metadata-protocol) grows asecond verdict after
unknown. Covers everything reachingfindData: thelist route,
POST /data/:object/query, the export route and the RPCdispatcher, in every filter spelling (
where/filter/filters/$filter, the array sugar, and nested$and/$or), naming the caller's ownwire spelling in
param.assertFilterIsMaterializable(
packages/objectql/src/filter-comparand-shape.ts) runs insidelowerWhereFilterArray, the ONE seam every caller-suppliedwherepassesthrough, so
find,findOne,count,aggregate,updateanddeleteallanswer alike and a new verb cannot miss the gate by omission. It judges the
CALLER's
whereonly — a middleware-injected RLS / sharing / tenant predicateis the platform's own and is never refused.
Both answer
400 INVALID_FIELDwithfield/fields/object(andparamat ingress), and both prescribe the remedy the sort and search axes already
share, with only the verb changed to name this axis:
One vocabulary, and the two types that must NOT be caught
Both doors judge the field with the same
@objectstack/spec/datapredicate thesearch axis uses (
isVirtualSearchField/SEARCH_VIRTUAL_TYPES) rather than alocally minted type list, so a gate and the drivers cannot disagree about which
types have a column.
summaryandautonumberstill filter — both get realstored columns; the set is exactly
formula, and both are pinned as controls onboth doors. Reading, projecting and computing a formula field are untouched.
INVALID_FIELDrather thanINVALID_FILTER, and no new code minted: thisverdict is about the NAME's type, which is what the ingress door already answers
with
INVALID_FIELDon its neighbouringunknownverdict and what the SEARCHaxis answers for this very field class.
INVALID_FILTERis objectql'sVALUE-shape envelope (#5869 / #7047) — a different fact. (The triage comment
suggested the
INVALID_FILTERfamily; its operative constraint — do not mint anew top-level code without checking the ADR-0114 catalog — is honoured. Happy to
flip the constant if the reviewer prefers.)
Blast radius — measured on source AND tests, with one exception
The card's second open question was the migration risk: a filter refusal turns
today's silent-zero surfaces into loud 4xx.
App metadata: clean, and that half of the sweep held. Every
formulafielddeclared in shipped app metadata was enumerated —
crm_contact.full_name,crm_opportunity.expected_revenue/days_to_close,crm_lead.is_closed,showcase_project.budget_remaining,showcase_field_zoo.f_formula— and eachoccurrence checked: they appear only as view COLUMNS, form fields, an FLS
permission entry, translations and a record-level CEL predicate. No example
app, seed, view filter, saved report, flow or dashboard in this repo filters on
a formula field. (The one
wherehit,case.is_closedin an analytics unittest, is a mocked
executeAggregatewith no registry and no formula field, andits key is dotted — a shape neither door judges.)
One TEST does filter one, and it is updated in this PR.
examples/app-todo/test/derived-flag-removal.test.tsregisters a formula-shapedobject of its own (
derived_task, invented by that file — not app metadata, notin
defineStack) to record why #7226 removed two inert flags rather thanderiving them. It pinned exactly the behaviour this PR abolishes: filtering a
formula answering 0 rows with no error. Updated here —
(
status: 400,code: 'INVALID_FIELD',field,object) instead of anempty array;
ittitle no longer claims "0 rows, no error";whereon a virtual formula field returns 0 rows silently, while sort and search refuse the same field with a 400 #8296 truth.The read/projection half of that file (a formula still COMPUTES both flags
correctly) and its stored-column CONTROL assertions are untouched and still
pass. Nothing under
examples/app-todo/src/**or itsobjectstack.config.tswas touched — that app declares no formula field at all.
#7226's decision stands, and its reasoning is stronger. A formula field
still materialises no column and still cannot carry a predicate, so the eight
app filters that named those flags still could not have worked; removal in
favour of stored columns is still the only repair. Only the failure mode
changed, from an invisible zero to a named 400 — and that test's own docblock
had named the missing exception as the safe design ("an exception would have
been safe, because someone would have seen it"). This PR supplies it.
The reusable lesson. The first sweep enumerated formula fields declared
outside tests. That reads as "empty in-tree" but excluded exactly the
population that broke. A sweep for a BEHAVIOUR change has to cover test files:
tests are where the old answer is pinned, so a behaviour change lands on the pin
before it lands anywhere else.
Verification
Reverse verification, direction predicted before running: reverting the three
source files to
origin/mainand rebuilding turned the new pins red and leftevery control green — 21 failed / 150 passed, and the failures are exactly the
refusal pins (11 ingress spellings, both message pins, all 6 engine verbs, the
engine message pin, the cross-door agreement pin). The pre-existing
unknownverdict, the stored/
summary/autonumbercontrols, the blast-radius pins andthe registry-less pin stayed green throughout.
Suites, after merging
mainand rebuilding:objectql196 files / 3522 tests,metadata-protocol79 / 1163,rest110 / 1817,runtime150 / 2306 — allpassing.
pnpm lintclean,objectqltypecheck clean, all 53check:*gatesfrom the ESLint job green, plus
scripts/check-engine-split-ratio.mjs(surfacedby re-deriving gates from the actual changed paths, not named in the dispatch
list), and
packages/speccheck:generatedreports all 13 artifacts up to dateafter the merge.
After the test/docblock/changeset correction above, the whole workspace test
suite was re-run locally (all 76 packages, partitioned into six balanced shards,
dogfood excluded as in CI), with
examples/app-todoat 4 files / 106 testspassing.
Generated by Claude Code