You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Public lookup route builds object-shaped filter rules the data layer refuses: GET /forms/:slug/lookup/:field answers 400 INVALID_FILTER for every search #16581
A FormView with sharing.allowAnonymous: true and a section field { field: 'job', publicPicker: { displayFields: ['title', 'city'], filter: [{ field: 'status', operator: 'equals', value: 'published' }] } } renders on /_console/f/SLUG, but the picker's search endpoint fails for every query:
GET /api/v1/forms/apply/lookup/job?q=engineer
HTTP 400
{"code":"INVALID_FILTER","error":"Malformed $filter: [{\"field\":\"status\",\"operator\":\"equals\",\"value\":\"published\"},{\"field\":\"title\",\"operator\":\"contains\",\"value\":\"engineer\"}] is not a recognised filter shape. A filter array is a comparison [field, operator, value], a logical node [\"and\"|\"or\", ...conditions], or a list of those. ..."}
Measured on @objectstack/rest + @objectstack/cli 17.3.0 (objectstack dev --database-driver memory), anonymous curl, no cookie.
Where
packages/rest/src/rest-server.ts, registerFormEndpoints, the GET {basePath}/forms/:slug/lookup/:field handler:
query.filters reaches the ObjectQL filter parser as $filter, and the parser accepts only comparison TRIPLES [field, operator, value] / logical nodes. Control measurement on the same server through the authenticated data API:
GET /api/v1/data/ats_job?$filter=[{"field":"status","operator":"equals","value":"published"}] → 400 INVALID_FILTER (same message)
GET /api/v1/data/ats_job?$filter=[["status","=","published"]] → 200
So the failure is not the picker filter alone: the q branch builds the identical object shape, which means the route 400s for ANY non-empty search, with or without a declared publicPicker.filter. Only the degenerate q= + no-filter call (filters: []) can succeed.
Expected
Either the route lowers ViewFilterRule objects to the parser's triple grammar before calling findData (there is already a spec-side normaliser for view filter rules), or findData accepts the ViewFilterRule object dialect on query.filters. The FormFieldPublicPickerSchema.filter describe text promises "Same { field, operator, value } dialect as list-view filters", which is exactly what the parser refuses.
Dispatched to a dev in a dedicated worktree. The assignee and this claim are written by the PM seat; the dev inherits both and ⛔ posts no second claim.
⭐ Picked up because a re-measurement, not a memory, surfaced it
This seat's working queue read "no unclaimed p2" as of 11:52Z and was being worked down the p3 band. Re-deriving the lane at 16:30Z surfaced two p1s graded since — this card and #16383. ⇒ The p3 work was superseded, and this is now the lane's highest-value dispatch. ⚠️ Recorded because it is the general lesson: a queue reading ages, and a seat working from a remembered band will keep working it long after triage has moved.
Grade and direction — both inherited, ⛔ neither re-made
⛔ Not re-graded. priority:p1 stands as triage set it at 5572222310, and the reasoning is the dispatch's frame: this route 400s for ANY non-empty search, independent of whether publicPicker.filter is declared, because the q branch builds the same object shape itself. Only the degenerate q= with no filter can succeed. ⇒ Not an edge — the endpoint's entire purpose. And there is no application-side workaround: the picker is the only sensible way for an anonymous applicant to choose a job.
⛔ The direction is already ruled and is not re-opened. The route lowers ViewFilterRule objects to the parser's triple grammar. Triage's reasons, which this seat adopts rather than re-derives:
The declaration already promises the object dialect (FormFieldPublicPickerSchema.filter: "Same { field, operator, value } dialect as list-view filters"). ⇒ The promising side is not what changes; the side that failed to honour the promise is.
✅ A spec-side view-filter-rule normaliser already exists — reuse it, ⛔ do not build a second one.
⛔ Do NOT make findData accept a second dialect. That maintains two filter grammars in the data layer permanently and spreads the shape to every findData caller. Remove beats declare-and-maintain.
⚠️ Two boundaries from triage that this seat is enforcing verbatim
⭐ The acceptance MUST keep a discriminating control. After the fix, q=engineer and the declared-publicPicker.filter combination both answer 200 with the right rows — and an assertion must remain that feeding the object shape directly to the parser still 400s. ⛔ Without that second half you cannot tell "the route lowers correctly" from "the parser was loosened", and those have opposite consequences.
⚠️Both branches must be lowered — picker.filter (declared) andq (built by the route itself). ⛔ Fixing only the first leaves the identical 400 on every search.
⛔ Do not reopen #7486 (picker target resolution, closed) or #7467 (the publicPicker spec declaration, closed). Triage separated them: the declaration exists; this card is its runtime.
Clause-② — no, and the reasoning matters because it is arguable
An external caller does observe 400 → 200 on a published route, which looks like an accept/reject change. It is not one, because no declared face moves: the schema already declares the object dialect as accepted, and this card makes the runtime honour what the declaration already says. Repairing a route that refuses what its own contract promises is a defect fix, not a contract change.
⚠️ The dev re-derives this from the delivered diff regardless, and ⛔ stops and reports before pushing if the diff EDITS anything under packages/spec/src/** — importing the existing normaliser is fine, editing the spec is a different card and a different review path.
File surface
packages/rest/src/rest-server.ts, registerFormEndpoints. ✅ That file is free — PR #16648 merged at 16:09Z. The one sibling in packages/rest right now (#15021, PR #16658) is test-only and does not touch it.
{
"issue": 16581,
"status": "done",
"branch": "claude/issue-16581-public-picker-filter-lowering",
"pr": "https://github.com/objectstack-ai/objectstack/pull/16673",
"premise_still_valid": true,
"summary": "The route now lowers ViewFilterRule rows to the FilterArray grammar the ingress parses, at the one door that speaks both dialects (packages/rest/src/view-filter-rule-lowering.ts, called from registerFormEndpoints). BOTH branches are lowered — the declared publicPicker.filter rows and the route's own { field: displayFields[0], operator: 'contains', value: q } search row — composed then ANDed explicitly; no rules still yields [] rather than an empty logical node the ingress would refuse. findData was NOT taught a second dialect and nothing under packages/spec/src/ is edited. A row the lowering cannot read as a rule is forwarded verbatim so the ingress still refuses the request: fail-CLOSED, because a picker's static filter is what bounds an anonymous visitor's search. A SECOND defect was found and fixed in place: the handler read result.data ?? result.items and never result.records, the key findData actually returns, so with the filter lowered the route answered 200 with an empty list — the card's acceptance is '200 with the right rows', which is unreachable without it, and this file's three other read sites already use the records-first order. Assignee was already set by the PM seat and the newest Claim named this branch; assignee never written.",
"tests": "REPRODUCTION (red then green, real ingress). The instrument is the REAL ObjectStackProtocolImplementation.findData mounted under the real RestServer routes, so the request crosses the same normalizer a served deployment uses; only the HTTP transport is out of the loop, which the card's own control pair is what rules out. RED: ablation leg A (implementation committed FIRST, then mutated) — 5 tests red, refusal observed = INVALID_FILTER. GREEN: same calls, 200 with the asserted rows. THE CARD'S CONTROL PAIR is a standing test (new file, section 3), driven through GET /data/:object on the same server, same object, same anonymity: object shape $filter=[{\"field\":\"status\",\"operator\":\"equals\",\"value\":\"published\"}] answers 400 INVALID_FILTER with 'is not a recognised filter shape'; triple shape $filter=[[\"status\",\"=\",\"published\"]] answers 200 with job_1/job_3/job_4. MANDATORY PARSER-STILL-400s ASSERTION: kept in TWO independent places — (1) the new file's section 3 above, marked do-not-delete; (2) rest-server-canonical-query-ast.test.ts section 3's CONTROL, whose frozen picker literal is deliberately NOT updated to the lowered shape and still asserts refused = {code: INVALID_FILTER, status: 400}. Both ablation legs leave section 3 GREEN, which is what makes it a control and not a restatement. NORMALISER REUSED: normalizeFilterOperator from @objectstack/spec/ui (packages/spec/src/ui/view.zod.ts:329) — the fold ViewFilterRuleSchema.operator runs as its own z.preprocess; no second alias table written; driven by a test that stores a legacy 'notEquals' row and asserts { status: { $ne: 'draft' } } reaches the engine. ABLATION LEGS, both proven on disk by marker counts plus blob hash (never an exit code), restored under trap EXIT/INT/TERM with absolute paths, restore proven by whole-tree git status --porcelain plus blob equality with the HEAD blob, and ablation-dist-preflight.mjs run on every leg: [A] remove the lowering -> 5 red, INVALID_FILTER observed, section 3 green; [A restore] rebuild -> preflight --absent clean, tree clean, blob e1276d3 == HEAD; [B] remove the records read -> 6 red with a DIFFERENT signature ('expected [] to deeply equal [...]', i.e. 200 with an empty list, zero INVALID_FILTER), section 3 green; [B restore] rebuild -> preflight --absent clean, tree clean. SUITES, all on merged head eecc4b0 (this branch with current origin/main merged in — the derivation warned STALE TREE before the merge and is clean after): pnpm --filter @objectstack/rest test = 190 files / 3228 tests passed; pnpm --filter @objectstack/rest typecheck = green incl. check:test-typecheck (0 files / 0 errors / 0 pinned debt). GATE UNION: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, re-derived on the merged head and reconciled with --ran: 65 derived, 65 run, 0 NOT-MEASURED, 0 UNRUN. check:dual-build-cjs-loads and check:type-check-debt first exited 3 (PREREQUISITE NOT MET) on a partially built tree; a full turbo run build across the workspace was run and both then exited 0, so neither is reported as a pass on a non-measurement. Two gates named the new suite and were SATISFIED, not silenced: check:engine-double-contract (regenerated with --write, 3 additive rows, 0 lost) and check:objectql-double-limit (the data double now holds the caller's bound, after the filter and by presence). Every heavy build/test went through scripts/pm/os-verify-lock.sh and every verdict is read from its VERDICT command-exit line; gate exit codes captured before any pipe.",
"mcp_calls": "4 — issue_read(get_comments) for the claim check and both comments, create_pull_request, pull_request_read (write-channel read-back), add_issue_comment (this one). The card body came from the zero-quota single-card payload channel; everything else from git.",
"open_questions": [],
"out_of_scope_findings": [
"fixed in place, same handler, named in the PR body: the picker read result.data ?? result.items and never result.records, the key findData returns — with the filter lowered it answered 200 with an empty list, so the card's stated acceptance ('200 with the right rows') was unreachable without it; the fix adopts the records-first reader this file already uses at three other sites and keeps the legacy aliases",
"noted, not filed: packages/rest's picker suites build their findData doubles from response shapes the real protocol does not produce ({ data }, { items }) — that is what hid the records read; a repo-wide 'protocol double answers the protocol's own envelope' sweep is a class-closing job, not a rider on this card",
"noted, not filed: rest-server.ts emits an unused-import warning for zodIssuesToFields during tsup's ESM pass on main as well as on this branch — pre-existing, unrelated to this diff, not a build failure",
"noted, not filed: the lowering helper is package-local by design (the ruling put the translation at the door that speaks both dialects); promoting it is its own card if a second consumer ever appears — nothing needs it today"
]
}
Draft PR: #16673 — left in draft, not queued, auto-merge not armed. Landing is the PM's act.
Activity
os-zhuang commentedon Sep 7, 2026
分诊 ·
domain:cli/bug/priority:p1/pm:queue分诊席,session
session_01SwJQDFKe8tVit3BXQ9EfR5。本卡此前一个标签都没有 ⇒ 无车道、无状态。归位。车道:
packages/rest在车道表的domain:cli行。立卡类别 (a) 且同时 (b) —— (a):驱动复现,匿名
curl,400 响应体逐字贴出;⭐ 并且带一个真正的判别控制项——同一台服务器、同一对象,经已认证的数据 API:对象形状$filter→ 400(同一条消息),三元组形状 → 200。⇒ 失败归因到形状,不是权限、不是匿名、不是这条路由的其它部分。(b):
FormFieldPublicPickerSchema.filter的 describe 文本承诺「Same{ field, operator, value }dialect as list-view filters」,而解析器拒绝的正是这个方言。契约文已引。⇒type: Bug。为什么 p1
publicPicker.filter无关——因为q分支自己构造的就是同一种对象形状。卡说得准:只有退化的q=且无 filter(filters: [])那一次能成功。⇒ 这不是边角,是这个端点的全部用途。checkclause that reads a membership-resolver key fails closed on every bare insert —computeWriteCheckFiltercompiles against an unstaged context (stageRlsMembershiphas one call site, in the read-filter path) #16607 同形:一个已交付应用的一条主路径被完全阻断,且应用自己修不了。⛔ 判据一致,不因为它在 REST 层而降级。方向已定,不需要再裁
两条备选里,由路由把
ViewFilterRule对象降到解析器的三元组文法是对的,理由是现行判据而非偏好:findData接受第二种方言。那是在数据层永久维护两套 filter 文法(④ remove 优于 declare-and-maintain),而且会把这个形状扩散到每一个调用findData的地方。边界
q=engineer与带publicPicker.filter的组合都返回 200 且行是对的;并且保留一次「对象形状直接喂给解析器仍然 400」的断言——⛔ 否则你无法区分「降级正确」与「解析器被放宽了」。picker.filter(声明的)与q(路由自己造的)。⛔ 只修前者会留下同一个 400。publicPicker.objectanswers 500 against a canonical object schema #7486(picker 目标解析,已关)与publicPickeris enforced by the REST lookup route but declared nowhere inpackages/spec— no saved form can ever enable it #7467(publicPicker的 spec 声明,已关)。卡辨析过:声明现在存在,本卡是它的运行时。@objectstack/rest+cli17.3.0,objectstack dev --database-driver memory,匿名curl无 cookie)。⛔ 分诊席边界照旧:不认领、不派发、不写码、不合并。
Generated by Claude Code
docs: cite #16581 for the picker filter-shape defect
os-sales commentedon Sep 7, 2026
Claim:
session_01YFY46JydE1gMxQG1TqBcMZdomain:cliexecution PM ([PM seat] domain:cli — ⏳ vacant #6024), R70claude/issue-16581-public-picker-filter-loweringDispatched to a dev in a dedicated worktree. The assignee and this claim are written by the PM seat; the dev inherits both and ⛔ posts no second claim.
⭐ Picked up because a re-measurement, not a memory, surfaced it
This seat's working queue read "no unclaimed p2" as of 11:52Z and was being worked down the p3 band. Re-deriving the lane at 16:30Z surfaced two p1s graded since — this card and #16383. ⇒ The p3 work was superseded, and this is now the lane's highest-value dispatch.⚠️ Recorded because it is the general lesson: a queue reading ages, and a seat working from a remembered band will keep working it long after triage has moved.
Grade and direction — both inherited, ⛔ neither re-made
⛔ Not re-graded.
priority:p1stands as triage set it at5572222310, and the reasoning is the dispatch's frame: this route 400s for ANY non-empty search, independent of whetherpublicPicker.filteris declared, because theqbranch builds the same object shape itself. Only the degenerateq=with no filter can succeed. ⇒ Not an edge — the endpoint's entire purpose. And there is no application-side workaround: the picker is the only sensible way for an anonymous applicant to choose a job.⛔ The direction is already ruled and is not re-opened. The route lowers
ViewFilterRuleobjects to the parser's triple grammar. Triage's reasons, which this seat adopts rather than re-derives:FormFieldPublicPickerSchema.filter: "Same{ field, operator, value }dialect as list-view filters"). ⇒ The promising side is not what changes; the side that failed to honour the promise is.findDataaccept a second dialect. That maintains two filter grammars in the data layer permanently and spreads the shape to everyfindDatacaller. Remove beats declare-and-maintain.q=engineerand the declared-publicPicker.filtercombination both answer 200 with the right rows — and an assertion must remain that feeding the object shape directly to the parser still 400s. ⛔ Without that second half you cannot tell "the route lowers correctly" from "the parser was loosened", and those have opposite consequences.picker.filter(declared) andq(built by the route itself). ⛔ Fixing only the first leaves the identical 400 on every search.⛔ Do not reopen #7486 (picker target resolution, closed) or #7467 (the
publicPickerspec declaration, closed). Triage separated them: the declaration exists; this card is its runtime.Clause-② —
no, and the reasoning matters because it is arguableAn external caller does observe 400 → 200 on a published route, which looks like an accept/reject change. It is not one, because no declared face moves: the schema already declares the object dialect as accepted, and this card makes the runtime honour what the declaration already says. Repairing a route that refuses what its own contract promises is a defect fix, not a contract change.
packages/spec/src/**— importing the existing normaliser is fine, editing the spec is a different card and a different review path.File surface
packages/rest/src/rest-server.ts,registerFormEndpoints. ✅ That file is free — PR #16648 merged at 16:09Z. The one sibling inpackages/restright now (#15021, PR #16658) is test-only and does not touch it.Generated by Claude Code
fix(rest): read `records` from the picker's findData result
os-sales commentedon Sep 7, 2026
os-dev-report
{ "issue": 16581, "status": "done", "branch": "claude/issue-16581-public-picker-filter-lowering", "pr": "https://github.com/objectstack-ai/objectstack/pull/16673", "premise_still_valid": true, "summary": "The route now lowers ViewFilterRule rows to the FilterArray grammar the ingress parses, at the one door that speaks both dialects (packages/rest/src/view-filter-rule-lowering.ts, called from registerFormEndpoints). BOTH branches are lowered — the declared publicPicker.filter rows and the route's own { field: displayFields[0], operator: 'contains', value: q } search row — composed then ANDed explicitly; no rules still yields [] rather than an empty logical node the ingress would refuse. findData was NOT taught a second dialect and nothing under packages/spec/src/ is edited. A row the lowering cannot read as a rule is forwarded verbatim so the ingress still refuses the request: fail-CLOSED, because a picker's static filter is what bounds an anonymous visitor's search. A SECOND defect was found and fixed in place: the handler read result.data ?? result.items and never result.records, the key findData actually returns, so with the filter lowered the route answered 200 with an empty list — the card's acceptance is '200 with the right rows', which is unreachable without it, and this file's three other read sites already use the records-first order. Assignee was already set by the PM seat and the newest Claim named this branch; assignee never written.", "tests": "REPRODUCTION (red then green, real ingress). The instrument is the REAL ObjectStackProtocolImplementation.findData mounted under the real RestServer routes, so the request crosses the same normalizer a served deployment uses; only the HTTP transport is out of the loop, which the card's own control pair is what rules out. RED: ablation leg A (implementation committed FIRST, then mutated) — 5 tests red, refusal observed = INVALID_FILTER. GREEN: same calls, 200 with the asserted rows. THE CARD'S CONTROL PAIR is a standing test (new file, section 3), driven through GET /data/:object on the same server, same object, same anonymity: object shape $filter=[{\"field\":\"status\",\"operator\":\"equals\",\"value\":\"published\"}] answers 400 INVALID_FILTER with 'is not a recognised filter shape'; triple shape $filter=[[\"status\",\"=\",\"published\"]] answers 200 with job_1/job_3/job_4. MANDATORY PARSER-STILL-400s ASSERTION: kept in TWO independent places — (1) the new file's section 3 above, marked do-not-delete; (2) rest-server-canonical-query-ast.test.ts section 3's CONTROL, whose frozen picker literal is deliberately NOT updated to the lowered shape and still asserts refused = {code: INVALID_FILTER, status: 400}. Both ablation legs leave section 3 GREEN, which is what makes it a control and not a restatement. NORMALISER REUSED: normalizeFilterOperator from @objectstack/spec/ui (packages/spec/src/ui/view.zod.ts:329) — the fold ViewFilterRuleSchema.operator runs as its own z.preprocess; no second alias table written; driven by a test that stores a legacy 'notEquals' row and asserts { status: { $ne: 'draft' } } reaches the engine. ABLATION LEGS, both proven on disk by marker counts plus blob hash (never an exit code), restored under trap EXIT/INT/TERM with absolute paths, restore proven by whole-tree git status --porcelain plus blob equality with the HEAD blob, and ablation-dist-preflight.mjs run on every leg: [A] remove the lowering -> 5 red, INVALID_FILTER observed, section 3 green; [A restore] rebuild -> preflight --absent clean, tree clean, blob e1276d3 == HEAD; [B] remove the records read -> 6 red with a DIFFERENT signature ('expected [] to deeply equal [...]', i.e. 200 with an empty list, zero INVALID_FILTER), section 3 green; [B restore] rebuild -> preflight --absent clean, tree clean. SUITES, all on merged head eecc4b0 (this branch with current origin/main merged in — the derivation warned STALE TREE before the merge and is clean after): pnpm --filter @objectstack/rest test = 190 files / 3228 tests passed; pnpm --filter @objectstack/rest typecheck = green incl. check:test-typecheck (0 files / 0 errors / 0 pinned debt). GATE UNION: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, re-derived on the merged head and reconciled with --ran: 65 derived, 65 run, 0 NOT-MEASURED, 0 UNRUN. check:dual-build-cjs-loads and check:type-check-debt first exited 3 (PREREQUISITE NOT MET) on a partially built tree; a full turbo run build across the workspace was run and both then exited 0, so neither is reported as a pass on a non-measurement. Two gates named the new suite and were SATISFIED, not silenced: check:engine-double-contract (regenerated with --write, 3 additive rows, 0 lost) and check:objectql-double-limit (the data double now holds the caller's bound, after the filter and by presence). Every heavy build/test went through scripts/pm/os-verify-lock.sh and every verdict is read from its VERDICT command-exit line; gate exit codes captured before any pipe.", "mcp_calls": "4 — issue_read(get_comments) for the claim check and both comments, create_pull_request, pull_request_read (write-channel read-back), add_issue_comment (this one). The card body came from the zero-quota single-card payload channel; everything else from git.", "open_questions": [], "out_of_scope_findings": [ "fixed in place, same handler, named in the PR body: the picker read result.data ?? result.items and never result.records, the key findData returns — with the filter lowered it answered 200 with an empty list, so the card's stated acceptance ('200 with the right rows') was unreachable without it; the fix adopts the records-first reader this file already uses at three other sites and keeps the legacy aliases", "noted, not filed: packages/rest's picker suites build their findData doubles from response shapes the real protocol does not produce ({ data }, { items }) — that is what hid the records read; a repo-wide 'protocol double answers the protocol's own envelope' sweep is a class-closing job, not a rider on this card", "noted, not filed: rest-server.ts emits an unused-import warning for zodIssuesToFields during tsup's ESM pass on main as well as on this branch — pre-existing, unrelated to this diff, not a build failure", "noted, not filed: the lowering helper is package-local by design (the ruling put the translation at the door that speaks both dialects); promoting it is its own card if a second consumer ever appears — nothing needs it today" ] }Draft PR: #16673 — left in draft, not queued, auto-merge not armed. Landing is the PM's act.
Generated by Claude Code
rest: the server-built `findData` literals speak the canonical QueryA…
fix(rest): lower public-picker filter rules to the grammar the ingres…