Repository navigation
Description
Symptom
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:
const filters: any[] = [];
if (Array.isArray(picker.filter)) filters.push(...picker.filter); // ViewFilterRule objects {field, operator, value}
if (q) filters.push({ field: displayFields[0], operator: 'contains', value: q }); // same object shape
...
await p.findData({ object: referenceTo, query: { limit, offset: 0, filters, select, sort }, context });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.
Related
- Public lookup route resolves the picker target from legacy field spellings only — omitted
publicPicker.objectanswers 500 against a canonical object schema #7486 (closed) — picker TARGET resolution; different defect, same route. publicPickeris enforced by the REST lookup route but declared nowhere inpackages/spec— no saved form can ever enable it #7467 (closed) —publicPickerspec declaration; the declaration now exists, this is its runtime.- Found from Platform & seeker views + public application form steedos-labs/ats#3 (public application form), where the job picker is the only sensible way for an anonymous applicant to choose the job.
Generated by Claude Code
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 · archive of domain:server seat 1 (#6367) #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…