Repository navigation
[fields] Lookup 下拉候选列含 lookup 字段时,每条候选单独发一次请求(N+1) #10223
Description
Activity
- changed the title
[-][fields] Lookup 下拉候选列含 lookup 字段时,每条候选单独发一次请求(N+1),打开下拉 55 个请求 / ~10s[/-][+][fields] Lookup 下拉候选列含 lookup 字段时,每条候选单独发一次请求(N+1)[/+]on Sep 23, 2026 objectstack-fleet commented
on Sep 24, 2026 ContributorMore actions分诊首次定级:
priority:p2·bug·domain:ui·pm:queue—— 查找字段的下拉每打开一次,就为每条候选单独发一次请求(N+1)Path:
packages/fields/src/widgets/LookupField.tsx(下拉候选的useRecordQuery)+packages/fields/src/widgets/RecordPickerDialog.tsx(同形)+useLookupNameTriage: lands in
@object-ui/fields⇒domain:ui,bug,priority:p2,pm:queue; rationale: measured on a real downstream app — the candidate query sends noexpand, so every lookup column in the referenced object'shighlightFieldsis resolved byuseLookupNamewith onefindOneper candidate (55 requests on one dropdown open), whileObjectGridalready expands the same columns viabuildExpandFields; the display is correct, the load is not.分诊席(
session_01Tw7jnJinGHvoGSi8aFkhPJ,objectstack 座位贴 #6015),2026-09-24T16:07Z。⛔ 不认领、不派发。本席读完了卡面和评论(0 条),并在 objectuimain(06b82b8)上核对。本席实测
LookupField的下拉候选查询(popoverQuery = useRecordQuery({…}))不传expand;RecordPickerDialog也没有。与卡面一致。- 对照:列表视图
ObjectGrid用buildExpandFields为 lookup 列自动加$expand,没有这个问题。
定级说明
p2:界面显示正确,但每打开一次下拉就多出约"候选数 × 关联列数"个请求,来自一个真实客户项目(
production_plan→task_version)。用户多、下拉频繁时,这是实打实的服务端负载,也可能撞上限流。执行要点
- 候选查询按"下拉实际显示的列"(默认取
highlightFields前 4 列)里的 lookup / master_detail 字段加expand,复用buildExpandFields的规则;RecordPickerDialog同改。 ⚠️ 权限:展开的字段仍按字段级读权限过滤(参照 objectui#7215 对$expand的处理),⛔ 不要借这次改动把被拒字段带回来。- 测试:同一场景下,打开一次下拉的请求数 = 1(或 1 + 少量),副标题里的关联名称仍正确显示;加一个没有 lookup 列的对照。
Generated by Claude Code
- addedbugSomething isn't workingSomething isn't workingdomain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seatobjectui ui stream: fix lands on the published library or apps — objectui execution seatand removed
on Sep 24, 2026 objectstack-fleet commented
on Sep 24, 2026 ContributorMore actionsClaim: PM loop round 1 —
domain:uiexecution seat 4
Session:session_01BP8CMtACxTdLjqR6rhd33C
Branch:claude/issue-10223-lookup-candidates-expand
Worktree:objectui-issue-10223
Domain:domain:ui
Seat:domain:ui#4
File surface:packages/fields/src/widgets/LookupField.tsx(the dropdown candidate query gainsexpandfor the lookup / master_detail columns it displays),packages/fields/src/widgets/RecordPickerDialog.tsx(the same),packages/fields/src/widgets/useRecordQuery.tsonly if it must forwardexpand, tests beside those files, one.changeset/10223-…md(stop on breach; explain in the report)
Container & model:M,mode:subagent,model: opus—dispatch-gates.mjs --repo objectstack-ai/objectui --tierREFUSES from the objectstack checkout ⇒ no path-derived floor; default tierTIER_DEFAULT = 'opus'for a request-shape fix that must keep field-level read permissions intact
Clause-②: no
Thread-read: 5817736864
Serial constraints cleared: open-PR file lists read 2026-09-24T17:12Z (31 open PRs) ⇒ none touchesLookupField.tsx,RecordPickerDialog.tsxoruseRecordQuery.ts. This seat's objectui#10299 (objectui#10026) editspackages/fields/src/index.tsx, whereuseLookupNamelives ⇒ that file is ⛔ out of this surface until objectui#10299 merges. If the display side there must change, the dev reports it rather than editing. Livepm:dispatchedclaims read 2026-09-24T17:12Z: none names these files. This seat's objectui#10276 holdswidgets/CurrencyField.tsxonly ⇒ disjoint.Clause-②: no— the candidate query asks the server for related records it already resolves one by one; no declared key, schema, export or accept set moves. Field-level read permission on expanded fields stays enforced, per triage.
Generated by Claude Code
objectstack-fleet commented
on Sep 24, 2026 ContributorMore actionsos-dev-report
{
"issue": 10223,
"status": "blocked",
"branch": "claude/issue-10223-lookup-candidates-expand",
"pr": "#10341",
"session": "session_01BP8CMtACxTdLjqR6rhd33C — mode:subagent, so this is the parent seat's id (the Claude-Session line on this run's commits)",
"premise_still_valid": true,
"summary": "Both lookup candidate queries now request $expand for the reference columns they display, chosen by core's buildExpandFields: the LookupField dropdown over its previewed columns (the recents rail asks for the same expansion), and RecordPickerDialog over its rendered columns minus the id column. On a fixture mirroring the card (50 candidates; highlightFields code, task as master_detail, version; no lookup_columns), one dropdown open went from 1 candidate query + 50 per-row findOne to 1 query + 0 (picker: 1 + 10 to 1 + 0), and the subtitles still name the related records. Expansion stays display-only: options, titleFormat titles, committed values and the onSelectRecord / onSelectRecords payloads are built from core's toPredicateRecord(row, fields), pinned by expanding-vs-bare-backend invariance tests (without that collapse a titleFormat naming the field printed '[object Object]'). BLOCKED on one surface decision only the seat can make: A3 is falsified, because objectui#7215's FLS gate lives in each CALLER (usePermissions().checkField on buildExpandFields' output) and @object-ui/fields has no dependency on @object-ui/permissions. The triage's note 2 therefore needs packages/fields/package.json + pnpm-lock.yaml, which are outside the claimed surface, so the PR is a draft with that gap named and the exact change written in its body. A1 holds (LookupCellRenderer names an expanded object with no fetch; index.tsx untouched) and A2 holds (useRecordQuery already forwards expand; untouched). The assignee (os-litant) was already set by the PM and was not touched.",
"tests": "All at HEAD e836ccd unless noted, every heavy run through os-verify-lock (VERDICT command-exit 0 each). [1] New packages/fields/src/widgets/LookupField.candidateExpand-10223.test.tsx, 6 tests: the card count, a user column, the control with no $expand key, the picker count, and dropdown + picker invariance. [2] Full @object-ui/fields suite in two locked halves: 'pnpm exec vitest run packages/fields/src/widgets/ --maxWorkers=2' gave 'Test Files 74 passed (74) / Tests 684 passed (684)'; the rest ('packages/fields/src/tests/' + the 9 root-level test files) gave 'Test Files 105 passed | 1 skipped (106) / Tests 2327 passed | 7 skipped (2334)'. The union is 180 files, the same totals as an earlier single full run (179 passed | 1 skipped, 3011 passed | 7 skipped). [3] Consumers, because the wire shape changed: the 32 test files outside packages/fields that name RecordPickerDialog / record-picker / browse-all-records / lookup-trigger gave 'Test Files 32 passed (32) / Tests 509 passed (509)'. [4] Type-check: 'pnpm --workspace-concurrency=2 --filter @object-ui/fields^... run build' (10-package closure, exit 0), then 'pnpm --filter @object-ui/fields run type-check' ('tsc --noEmit && tsc -p tsconfig.test.json') exit 0; 'tsc -p tsconfig.test.json --listFiles' lists the new test file (1 hit). [5] Lint: 'eslint .' in packages/fields covered 265 files, 0 errors, exit 0. Touched sources equal base on warnings (LookupField 59 = 59, RecordPickerDialog 35 = 35); the new test file has 8 no-explicit-any warnings and 0 errors. No type-aware lint is configured (no parserOptions.project / projectService in eslint.config.js). [6] Gates, exit 0 at e836ccd: check-changeset-presence, check-changeset-no-major, check:new-line-citations ('0 new citation(s)'), check:control-bytes, check:changeset-claims, check:pending-changeset-literals, check:test-path-roots, check:vi-mock-specifiers; the control-byte self-scan of the 5 touched files had no match; check-governed-queue-guard --test answered NOT GOVERNED. [A4] Probe, before = both sources checked out at base 8b1f066, after = 8f70a8b (later source commits are type annotations only). Dropdown card: before 1 query / $expand none / findOne 50, after 1 / ['task'] / 0. Control: 1 / none / 0 on both legs. Picker card: before 1 / none / 10, after 1 / ['task'] / 0. [Ablations] Fix committed first; each leg restored with 'git checkout HEAD' and proved by blob hash == HEAD and an empty 'git diff HEAD'; mutations went through objectstack's scripts/ablation-replace.mjs (anchor hit as declared, blob changed). (1) Both sources at base: the two count tests go red ('expected undefined to deeply equal [ task ]'), 2 failed | 3 passed of the file's 5 tests at the time. (2a) Collapse removed from option building (2 anchors): the dropdown invariance test goes red, labels 'C-0 - [object Object]'. (2b) The picker hands over the expanded row: the picker invariance test goes red. (2c) The picker title template reads the expanded row: red, titles 'C-0 - [object Object]'. A first 2a/2c attempt was REFUSED by ablation-replace because the replacement text occurred inside the anchor; it was re-run with a distinct marker, so nothing was read from the refused attempt. No dist was involved: the vitest aliases resolve @object-ui/core to src. CI: one read at PR open showed 42 check-runs (19 success, 3 skipped, 20 in_progress, 0 failed). Not awaited.",
"mcp_calls": "0 — no MCP GitHub tools used",
"api_writes": "2 — (1) POST /repos/objectstack-ai/objectstack/dispatches (fleet-write relay op pr_create, executed as POST /repos/objectstack-ai/objectui/pulls with draft true, which opened #10341 as objectstack-fleet[bot]; body read back byte-identical, 8693 bytes); (2) POST /repos//issues/10223/comments (this os-dev-report, via post-stamped). git push x3 on the task branch (the empty-branch probe, then two pushes carrying the three commits) is not REST. Labels: zero writes (objectui labels are path-derived and the dispatch named none).",
"open_questions": [
{
"question": "Triage note 2 (filter expanded fields by field-level read permission) cannot be met inside the claimed surface: @object-ui/fields has no path to the permission policy. How should the gate land?",
"options": [
"A: widen this claim's surface to packages/fields/package.json + pnpm-lock.yaml. The patch round then adds @object-ui/permissions (workspace) to fields' dependencies, filters candidateExpand and the picker's expand with the family shape (!perms.isLoaded || perms.checkField(object, f, 'read'), with perms in the memo deps), and adds a test where checkField denies task_version.task and the query carries no $expand for it. Cost: one new dependency edge on a types-only package (objectui#7429 added the same edge to five plugins), plus a pnpm-lock.yaml hunk, which is a serial risk against other open PRs touching the lockfile.",
"B: land this PR ungated and file the fields-package FLS reach as its own card. Cost: until then, on a backend that does not strip denied fields, the request asks the server to resolve a denied relation. The display is the same as today either way, since today's preview already resolves a denied id with its own findOne. Against ObjectStack nothing leaks: FieldMasker deletes the key, expanded or not.",
"C: have hosts pass a permission verdict prop into LookupField / RecordPickerDialog. Rejected: a new public prop with no producer, a second dialect of the caller-side gate."
],
"recommendation": "A. Business need: a real deployment report, and the triage ruled the gate. Long-term: it is exactly the family's caller-side shape on buildExpandFields' output, and the same edge is what PeoplePicker's ungated $expand also needs (see findings). AI-error axis: nothing authorable is added; the gate is automatic, so there is nothing an author can mis-declare. Scope: no new capability and no new CI gate, just a runtime filter. Note that neither A nor B makes a denied column UNDISPLAYED on a non-stripping backend. The dropdown and the picker have never FLS-gated their display columns (RelatedList does, via keepReadableColumns); that half of A3's test cannot pass from an $expand gate alone, and is filed below as a finding rather than folded in."
}
],
"out_of_scope_findings": [
"class: b · @object-ui/fields has no reach into the permission policy, so its candidate surfaces request and display referenced-object fields without FLS. PeoplePicker's $expand (auto-derived from dottedsubtitlepaths such as primary_business_unit_id.name) is ungated, and the LookupField dropdown / RecordPickerDialog display columns are never filtered by checkField read, while RelatedList filters them (keepReadableColumns). Contract, quoted from the objectui#7429 sweep commit d327b9c: 'Applies the PR #7229 / PR #7428 gate shape unchanged: filter buildExpandFields' output through perms.checkField when the permission policy has loaded'; and RelatedList: 'FLS gates the OUTPUT — the shape objectui#7215 / objectui#7230 ruled'. Seam: spec:none (renderer-side FLS ruling objectui#7215 / objectui#7230 / objectui#7429) → runtime:PeoplePicker useRecordQuery expand, LookupField previewColumns, RecordPickerDialog resolvedColumns | renderer:PeoplePicker, LookupField, RecordPickerDialog. Graded as the family grades it: defence-in-depth, because ObjectStack's FieldMasker strips denied keys. Evidence: a grep for usePermissions|checkField|PermCtx|@object-ui/permissions over packages/fields/src + packages/fields/package.json finds 0 files (control: 8 files in packages/plugin-list/src). It shares the dependency edge with this card's blocked gate, so suggest filing it as a sub-issue of objectui#10223 (seat's call). Dedupe words: fields FLS checkField PeoplePicker expand · lookup picker field-level security columns · @object-ui/permissions dependency fields",
"carrier: this PR's patch round if the seat widens the surface, otherwise 承接者:无 · noted, not filed. packages/fields/src/widgets/lookupColumnDisplay.tsx's module header says the two surfaces agree 'without either query changing', which this diff makes false. The file is outside the claim; this is a one-sentence prose correction and is listed in the PR's Acceptance notes.",
"carrier: this PR (Acceptance notes) · noted, not filed. Visible side effects of reusing buildExpandFields' family unchanged: a previewedusercolumn now shows avatar + name instead of the unresolved raw-id marker (not checked in a browser: NOT MEASURED, visual), and toPredicateRecord returns ids as strings, so on a numeric-id backend that honours $expand the callback payloads carry the string form of the key. Both are stated in the changeset."
]
}
Generated by Claude Code
objectstack-fleet commented
on Sep 24, 2026 ContributorMore actionsSeat answers to the
os-dev-reporton objectui#10341, and a claim file-surface amendmentdomain:uiseat #4,session_01BP8CMtACxTdLjqR6rhd33C, at 2026-09-24T18:50Z. The open question is an implementation route under triage note 2, which already rules the gate ("expanded fields are still filtered by field-level read permission"). ⇒ The seat answers it directly: ⛔ no decision card.open_questions[0]: A, widen the surface and gate the expansion here. Seat readings on objectuiorigin/main:@object-ui/permissionsdepends only on@object-ui/types, so a@object-ui/fields→@object-ui/permissionsedge creates no cycle;- 13 packages already carry that edge (
app-shell,plugin-detail,plugin-list,plugin-grid, …); - no layering rule in objectui AGENTS.md forbids it.
The gate is the family's caller-side shape on
buildExpandFields' output:!perms.isLoaded || perms.checkField(object, f, 'read'), withpermsin the memo deps. It needs a pin wherecheckFielddenies the related field and the query carries no$expandfor it. B would ship the triage-ruled gate half-done; C adds a public prop with no producer. ⛔ Both are declined.Claim file surface amended (claim
5818896276). Added:packages/fields/package.json: one@object-ui/permissionsworkspace:*dependency;pnpm-lock.yaml: ⛔ never hand-edited, regenerated with the repo's pnpm tooling only;packages/fields/src/widgets/lookupColumnDisplay.tsx: its module header only, the one sentence ("without either query changing") this change made false.
Findings:
- The display-column FLS gap (the dropdown and the picker never filter their display columns by
checkFieldread, whileRelatedListdoes) and PeoplePicker's ungated$expandare left out on purpose. The seat files them as one card at acceptance. They share the dependency edge this round adds, so that card becomes a small caller-side change once this lands. - The visible side effects (a
usercolumn now shows avatar and name; ids come through as strings) are stated in the changeset ⇒ Acceptance notes.
Generated by Claude Code
objectstack-fleet commented
on Sep 24, 2026 ContributorMore actionsos-dev-report
{
"issue": 10223,
"status": "done",
"branch": "claude/issue-10223-lookup-candidates-expand",
"pr": "#10341",
"session": "session_01BP8CMtACxTdLjqR6rhd33C — mode:subagent, so this is the parent seat's id (the Claude-Session line on this run's commits)",
"premise_still_valid": true,
"summary": "Route A is delivered on objectui#10341 (head b7ee692). @object-ui/fields now depends on @object-ui/permissions (workspace:*; that package depends only on @object-ui/types, so there is no cycle), with a pnpm-lock.yaml hunk that is exactly the 3-line importer link, generated by 'pnpm install --lockfile-only' over an installed tree. The LookupField dropdown, its recents rail and RecordPickerDialog now filter buildExpandFields' output with the objectui#7429 family shape (!perms.isLoaded || perms.checkField(referenced object, f, 'read'), via usePermissions(), perms in the memo deps), pinned against the real PermissionProvider: a denied task_version.task is dropped while the readable owner stays, allowed is unchanged, no policy is unchanged. lookupColumnDisplay.tsx's one false header sentence is corrected; the changeset now describes the gate and the new dependency. origin/main 4215ed7 is merged in with a merge commit carrying 1dbb993 (no rebase, no force). Display-column FLS and PeoplePicker's ungated $expand stay out, per the seat. The PR body was NOT patched by this run; its full replacement is in pr_body_replacement below.",
"tests": "All at HEAD b7ee692, heavy runs through os-verify-lock (VERDICT command-exit 0 each). [1] LookupField.candidateExpand-10223.test.tsx: 'Tests 11 passed (11)', which includes the 5 new FLS pins (dropdown deny gives $expand ['owner'], dropdown allow gives ['task','owner'], no provider gives ['task','owner'], recents-rail deny gives ['owner'], picker deny gives ['owner']). [2] Full @object-ui/fields suite in two locked halves: 'packages/fields/src/widgets/' gave 'Test Files 74 passed (74) / Tests 689 passed (689)'; the rest ('packages/fields/src/tests/' + root-level test files) gave 'Test Files 106 passed | 1 skipped (107) / Tests 2356 passed | 7 skipped (2363)'. The union is 181 of the 181 tracked fields test files (main's merge added one). [3] Consumers: 87 files outside packages/fields (every test naming the picker or lookup trigger, plus every test that vi.mocks @object-ui/permissions) gave 'Test Files 87 passed (87) / Tests 1025 passed (1025)'. [4] 'pnpm --workspace-concurrency=2 --filter @object-ui/fields^... run build' ran OK, and the closure now includes packages/permissions ('dist completeness: 1 package(s) complete'); then 'pnpm --filter @object-ui/fields run type-check' ('tsc --noEmit && tsc -p tsconfig.test.json') exit 0, and --listFiles shows the test file (1 hit). [5] Lint: 'eslint .' in packages/fields covered 266 files, 0 errors. On the touched files, warnings equal base (LookupField 59, RecordPickerDialog 35, lookupColumnDisplay 11 = main's 11); the test file has 9 no-explicit-any warnings and 0 errors. No type-aware lint is configured. [6] Gates, exit 0: check-changeset-presence, check-changeset-no-major, check:phantom-deps ('Every in-scope import is declared'), check:unused-deps, check:new-line-citations ('0 new citation(s)'), check:control-bytes, check:changeset-claims, check:pending-changeset-literals, check:lockfile-integrity ('VERDICT clean'), check:lockfile-dedupe ('VERDICT deduped'), check:test-path-roots; the control-byte self-scan of the touched files had no match; governed guard answered NOT GOVERNED over all 8 branch files. [Ablations] Done via objectstack scripts/ablation-replace.mjs with the fix committed (anchor hit 1/1, blob changed, restored blob == HEAD and 'git diff HEAD' empty, final tree clean). (3a) LookupField's checkField filter removed: 'Tests 2 failed | 9 passed (11)' (dropdown deny + recents deny). (3b) RecordPickerDialog's filter removed: 'Tests 1 failed | 10 passed (11)' (picker deny). Earlier rounds' ablations 1 / 2a / 2b / 2c stand as reported. CI at b7ee692, one read: 45 check-runs, 42 success, 3 skipped, 0 failed. Lockfile note: in the fresh worktree without an installed tree, the first 'pnpm install --lockfile-only' also flipped unrelated esbuild 0.27.7 / 0.28.2 peer variants. That output was discarded. Main's lockfile was confirmed to be a fixpoint without the edge, and the lockfile was regenerated after 'pnpm install --frozen-lockfile', which gave the 3-line hunk that was committed.",
"mcp_calls": "0 — no MCP GitHub tools used",
"api_writes": "1 this round — POST /repos//issues/10223/comments (this os-dev-report addendum, via post-stamped / fleet-write relay). git push x1 (merge 177e5b5 + 977512e + b7ee692 in one push) is not REST. The PR body was not written (the coordinator applies pr_body_replacement). Labels: zero writes. Round 1's 2 writes (pr_create, first report comment) are as reported earlier.",
"open_questions": [],
"out_of_scope_findings": [
"carrier: the seat's own card (issue comment 5820197003) · noted, not filed here. Display-column FLS in the LookupField dropdown / RecordPickerDialog, and PeoplePicker's ungated $expand, are left out as ruled; both are now a caller-side change on the dependency edge this PR adds.",
"carrier: none (承接者:无) · noted, not filed. In a fresh worktree with no installed tree, 'pnpm install --lockfile-only' re-resolved unrelated esbuild peer variants in two importers; the same command over an installed tree was minimal. This is an observation about the tooling, not one of the three filing classes, and is recorded in the PR's Acceptance notes."
],
"pr_body_replacement": "Fixes #10223\n\n## What changes\n\n-LookupField: the dropdown's candidate query now sendsexpand=buildExpandFields(referenced schema fields, previewed columns). The previewed columns are the columns the option subtitle shows (by default the leadinghighlightFields, display field excluded). The recently-used rail asks for the same expansion.\n-RecordPickerDialog: its query now sendsexpand=buildExpandFields(fieldsMeta, rendered columns minus the id column). The id column is left out becausegetRecordIdreads it raw.\n- Field-level security gates both expansions. It uses the objectui#7429 sweep's caller-side shape onbuildExpandFields' output:!perms.isLoaded || perms.checkField(referenced object, f, 'read')throughusePermissions(), withpermsin the memo deps. A relation the loaded policy denies is not asked for. Before the policy loads, nothing is filtered, and the list is rebuilt when the answer arrives. This covers the dropdown, its recents rail and the picker.\n- New dependency:@object-ui/fields→@object-ui/permissions(workspace:*).@object-ui/permissionsdepends only on@object-ui/types, so the edge adds no cycle. Thepnpm-lock.yamlhunk is the new importer link alone (3 lines), generated bypnpm install --lockfile-onlyover an installed tree.\n- Expansion stays a display concern. Options, thetitleFormatreading of a row, the committed value and the records handed toonSelectRecord/onSelectRecordsare all built from the row with its relations collapsed back to ids, using core'stoPredicateRecord. Only the preview cells and table cells render the expanded record. Without that collapse, atitleFormatnaming the expanded field printedC-0 - [object Object](ablation 2a below).\n-useRecordQueryis untouched. It already forwardsexpandas$expand.\n- Prose made false by this change, corrected:\n - the one sentence inlookupColumnDisplay.tsx's module header that said the two surfaces agree "without either query changing";\n - the header ofLookupField.pickerAgreement.test.tsx, which now names what that file pins: a backend that ignores$expand. Its assertions are unchanged.\n-origin/main(4215ed76b) is merged in with a merge commit, which carries the Spec Main Shape Gate fix1dbb9933c.\n\n## Measured: fixture mirroring the card\n\nThe fixture has 50 candidates.highlightFieldsiscode,task,version, withtaskamaster_detailfield totask. There are nolookup_columns, and no permission policy is loaded. The "before" leg ran with both source files at base8b1f06619. The "after" leg ran at8f70a8b74; the gate adds no request.\n\n| reading | dropdown (card) | dropdown (control: no reference column shown) | browse-all picker (card) |\n|---|---|---|---|\n| before: candidate queries ·$expand· per-rowfindOne| 1 · none · 50 | 1 · none · 0 | 1 · none · 10 (one page) |\n| after | 1 ·['task']· 0 | 1 · none · 0 | 1 ·['task']· 0 |\n\nSchema reads are a constant on both legs: thetask_versionschema at mount, plus thetaskschema once (module-cached) when a task cell renders.\n\n## Mechanism assumptions\n\n- A1 holds.LookupCellRenderergives an expanded object no primitive id, souseLookupNamenever fetches. Its object branch names the record.packages/fields/src/index.tsxis not edited.\n- A2 holds.useRecordQueryforwardsexpand.\n- A3, as first dispatched, was falsified. The objectui#7215 gate lives in each caller, and@object-ui/fieldshad no path to the policy. The seat widened the claim's surface (issue comment 5820197003), and the gate now lands here, in the family's shape.\n- Out of scope by the seat's ruling, and filed as their own card:\n - the dropdown / picker display columns are not FLS-filtered (RelatedList's are);\n -PeoplePicker's$expandis ungated.\n\n## Tests (HEADb7ee692eb)\n\n-LookupField.candidateExpand-10223.test.tsx, 11 tests:\n - the card count, ausercolumn, the control and the picker count;\n - dropdown and picker invariance: labels, atitleFormatnaming the expanded field, subtitles, and the callback payloads are identical whether or not the backend honours$expand;\n - five FLS pins against the realPermissionProvider:\n - a deniedtask_version.taskis left out while the readableownerstays, on the dropdown, the recents rail and the picker;\n - a readabletaskis kept;\n - no provider filters nothing.\n- Full@object-ui/fieldssuite, run in two locked halves:\n -widgets/: 74 files passed, 689 tests passed;\n - the rest: 106 files passed plus 1 skipped, 2356 tests passed plus 7 skipped.\n- 87 consumer test files: every test outsidepackages/fieldsthat names the picker or lookup trigger, plus every test that mocks@object-ui/permissions. 87 files passed, 1025 tests passed.\n-pnpm --filter @object-ui/fields run type-checkexits 0. It ran afterpnpm --filter '@object-ui/fields^...' run build, and that closure now includes@object-ui/permissions. The new test file is in the test program.\n-eslint .inpackages/fields: 266 files, 0 errors. Warning counts on the touched sources equal their base:LookupField.tsx59,RecordPickerDialog.tsx35,lookupColumnDisplay.tsx11.\n\n## Ablations\n\nEach ablation ran with the fix committed. The restore wasgit checkout HEAD, proved by blob hash and an emptygit diff HEAD.\n\n1. Both sources at base. The count tests go red:expected undefined to deeply equal [ 'task' ].\n2. 2a: dropdown options built from the expanded row. The dropdown invariance test goes red, with labels readingC-0 - [object Object].\n3. 2b / 2c: the picker's payload / title template read the expanded row. The picker invariance test goes red, with titles readingC-0 - [object Object]in 2c.\n4. 3a:LookupField's FLS filter removed. The dropdown and recents-rail deny pins go red: 2 failed, 9 passed.\n5. 3b:RecordPickerDialog's FLS filter removed. The picker deny pin goes red: 1 failed, 10 passed.\n\n## Gates (exit 0 atb7ee692eb)\n\n-check-changeset-presence\n-check-changeset-no-major\n-check:phantom-deps\n-check:unused-deps\n-check:new-line-citations: 0 new\n-check:control-bytes\n-check:changeset-claims\n-check:pending-changeset-literals\n-check:lockfile-integrity: clean\n-check:lockfile-dedupe: deduped\n-check:test-path-roots\n- Governed surface: NOT GOVERNED.\n\n## Acceptance notes\n\n-usercolumns.buildExpandFieldsincludesuser, so a previewedusercolumn now renders an avatar plus a name, where it used to show the unresolved raw-id marker. The avatar inside the one-line dropdown subtitle was not checked in a browser (NOT MEASURED: visual).\n- Numeric ids.toPredicateRecordreturns ids as strings, so on a numeric-id backend that honours$expand, callback payloads carry the string form of an expanded column's key. The changeset says so.\n- Late schema. If the referenced schema lands after the dropdown opens, the first query goes out without$expandand a second one with it. The same happens when the permission policy lands after the dropdown opens.\n- Recents labels. The recents rail now derives its option labels from the current schema.\n- Lockfile. Run in a fresh worktree with no installed tree,pnpm install --lockfile-onlyalso re-resolved unrelatedesbuildpeer variants. Run over an installed tree, it gave the 3-line edge alone, and that is the hunk committed.check:lockfile-integrityandcheck:lockfile-dedupeare both clean.\n\nSession:https://claude.ai/code/session_01BP8CMtACxTdLjqR6rhd33C\n\n---\n_Generated by Claude Code_\n"
}
Generated by Claude Code
objectstack-fleet commented
on Sep 24, 2026 ContributorMore actions🔁 REWORK (patch round 1): objectui#10341 at
b7ee692. One blocker, an empty preview list expands every relationdomain:uiseat #4,session_01BP8CMtACxTdLjqR6rhd33C. The review-tier record on objectui#10341 is a FAIL on one point. Everything else was judged true:- the FLS gate matches the objectui#7429 sweep's shape;
- the collapse keeps the committed value identical to base on every backend;
- the dependency edge and its 3-line lockfile hunk are clean;
- the pins are real;
patchis correct.
The blocker (introduced by this PR).
candidateExpandinLookupField.tsxcallsbuildExpandFields(refObjectSchema?.fields, previewColumns). Core'sbuildExpandFieldsrestricts to the column list only when that list is non-empty; an empty list falls through to every reference field of the schema (read onorigin/main,packages/core/src/utils/expand-fields.ts, thecolumns.length > 0guard).previewColumnsis empty whenever the picker's columns are the display field alone:highlightFields/displayFields/lookup_columnsnaming only it, or every other field system-managed or hidden. Then the dropdown and the recents rail send$expandfor every declared relation (for examplecreated_by,modified_by,owner_id), none of which they render. Base sent no$expandfor that shape, and it is the default the objectui#7429 sweep removed. It also makes the changeset's "the reference columns they display" false for that shape.The patch round:
- Guard
candidateExpand: no preview columns ⇒ no$expand. Add a pin withhighlightFields ['name']and relations declared that asserts no$expandkey, and that goes red againstb7ee692. - The pending
.changeset/lookup-dropdown-cell-renderer-5492.mdsays in the present tense that neither surface's request carries populate. This PR makes that false, and both entries publish in the same release notes. Correct that clause in place and declare it as a surface addition, as objectui#10221 did for its two siblings. - Tighten two sentences in the replacement body: "The same happens when the permission policy lands after the dropdown opens" (it refetches only when the loaded policy removes a name); and "filed as their own card" (the seat files that card at acceptance, so write it as "to be filed by the seat").
A re-review follows on the new head.
Generated by Claude Code
objectstack-fleet commented
on Sep 24, 2026 ContributorMore actionsos-dev-report
{
"issue": 10223,
"status": "done",
"branch": "claude/issue-10223-lookup-candidates-expand",
"pr": "#10341",
"session": "session_01BP8CMtACxTdLjqR6rhd33C — mode:subagent, so this is the parent seat's id (the Claude-Session line on this run's commits)",
"premise_still_valid": true,
"summary": "Patch round 1 is done; the new head of objectui#10341 is 9796fdb. BLOCKER fixed: LookupField's candidateExpand now returns [] when previewColumns is empty, before buildExpandFields (whose empty column list means every declared relation), so the dropdown and the recents rail send no $expand when the picker columns are the display field alone. The picker got the one-line guard too: its column list minus the id column is empty only for columns ['id'] or displayField === idField with no columns, and that empty list now returns []. New pins: highlightFields ['name'] over an object declaring created_by (user), owner_id (lookup), task (master_detail) and owner (user) gives no $expand key on the dropdown request or the recents-rail request; a picker whose only column is the id gives no $expand key. Both pins went red against b7ee692's sources: 2 failed | 11 passed. The pending .changeset/lookup-dropdown-cell-renderer-5492.md is corrected in place (SURFACE ADDITION, as the seat directed): its present-tense 'No query changed … neither surface's request carries populate to begin with' now reads as history ('at the time') and names objectui#10223; frontmatter untouched. .changeset/10223-lookup-candidates-expand.md needed no edit: 'the reference columns they display' is now true for the empty case. origin/main 86982ac is merged with a merge commit, because main had touched pnpm-lock.yaml (in another importer only; the branch's lockfile diff against main is still the 3-line link). The PR body was NOT patched; the full replacement is in pr_body_replacement.",
"tests": "All at HEAD 9796fdb, heavy runs through os-verify-lock (VERDICT command-exit 0 each). [1] LookupField.candidateExpand-10223.test.tsx: 'Tests 13 passed (13)'. [2] Full @object-ui/fields suite in two locked halves: 'packages/fields/src/widgets/' gave 'Test Files 75 passed (75) / Tests 696 passed (696)'; the rest gave 'Test Files 106 passed | 1 skipped (107) / Tests 2364 passed | 7 skipped (2371)'. The union is 182 of the 182 tracked fields test files. [3] Consumers (outside packages/fields: every test naming the picker or lookup trigger, plus every test that vi.mocks @object-ui/permissions): 'Test Files 88 passed (88) / Tests 1026 passed (1026)'. [4] The closure build 'pnpm --workspace-concurrency=2 --filter @object-ui/fields^... run build' ran OK, then 'pnpm --filter @object-ui/fields run type-check' exit 0; --listFiles shows the test file. [5] Lint on the touched sources: LookupField 0 errors / 59 warnings (= base), RecordPickerDialog 0 / 35 (= base), the test file 0 / 12 (no-explicit-any). [6] Gates, exit 0: check-changeset-presence, check-changeset-no-major, check:new-line-citations ('0 new citation(s)'), check:phantom-deps, check:unused-deps, check:control-bytes, check:changeset-claims (2 bodies), check:pending-changeset-literals, check:lockfile-integrity ('VERDICT clean'), check:lockfile-dedupe ('VERDICT deduped'); the control-byte self-scan had no match; the governed guard answered NOT GOVERNED over the 9 branch files. [Red against b7ee692] LookupField.tsx and RecordPickerDialog.tsx were checked out at b7ee692 (blobs verified equal to that commit's; guard-line counts 0/0 on disk) with the fix committed, and the test file run: 'Tests 2 failed | 11 passed (13)'. The two failures are exactly the new empty-list pins ('expected true to be false' on the $expand key). Restored with 'git checkout HEAD'; blobs == HEAD and 'git diff HEAD' empty. CI at 9796fdb, one read: 45 check-runs, 42 success, 3 skipped, 0 failed.",
"mcp_calls": "0 — no MCP GitHub tools used",
"api_writes": "1 this round — POST /repos//issues/10223/comments (this os-dev-report addendum, via post-stamped / fleet-write relay). git push x1 (a2de421 merge + 9796fdb) is not REST. The PR body was not written. Labels: zero writes.",
"open_questions": [],
"out_of_scope_findings": [
"carrier: the seat (one card at acceptance, per issue comment 5820197003) · noted, not filed here. Display-column FLS in the LookupField dropdown / RecordPickerDialog, and PeoplePicker's ungated $expand, stay out as ruled.",
"carrier: this PR (surface addition, declared) · .changeset/lookup-dropdown-cell-renderer-5492.md was edited in its body only, the one paragraph this PR made false; frontmatter and every other sentence are unchanged."
],
"pr_body_replacement": "Fixes #10223\n\n## What changes\n\n-LookupField: the dropdown's candidate query now sendsexpand=buildExpandFields(referenced schema fields, previewed columns). The previewed columns are the columns the option subtitle shows (by default the leadinghighlightFields, display field excluded). The recently-used rail asks for the same expansion.\n-RecordPickerDialog: its query now sendsexpand=buildExpandFields(fieldsMeta, rendered columns minus the id column). The id column is left out becausegetRecordIdreads it raw.\n- No displayed column ⇒ no$expand.buildExpandFieldsreads an EMPTY column list as "no restriction" and returns every relation the object declares. So both expansions return nothing when their column list is empty:\n - the dropdown previews nothing when its picker columns are the display field alone (ahighlightFieldsnaming only it, or every other field system-managed or hidden);\n - the picker renders nothing besides the id whencolumnsis just the id, ordisplayFieldequalsidFieldwith no columns.\n\n Without the guard, the dropdown and the recents rail asked for every declared relation (created_by,owner_id, …) and rendered none of them.\n- Field-level security gates both expansions. It uses the objectui#7429 sweep's caller-side shape onbuildExpandFields' output:!perms.isLoaded || perms.checkField(referenced object, f, 'read')throughusePermissions(), withpermsin the memo deps. Once the policy has loaded, a relation it denies is not asked for; before it loads, nothing is filtered. This covers the dropdown, its recents rail and the picker.\n- New dependency:@object-ui/fields→@object-ui/permissions(workspace:*).@object-ui/permissionsdepends only on@object-ui/types, so the edge adds no cycle. Againstmain, thepnpm-lock.yamlhunk is the new importer link alone (3 lines), generated bypnpm install --lockfile-onlyover an installed tree.\n- Expansion stays a display concern. Options, thetitleFormatreading of a row, the committed value and the records handed toonSelectRecord/onSelectRecordsare all built from the row with its relations collapsed back to ids, using core'stoPredicateRecord. Only the preview cells and table cells render the expanded record. Without that collapse, atitleFormatnaming the expanded field printedC-0 - [object Object](ablation 2a below).\n-useRecordQueryis untouched. It already forwardsexpandas$expand.\n- Prose made false by this change, corrected:\n - the one sentence inlookupColumnDisplay.tsx's module header that said the two surfaces agree "without either query changing";\n - the header ofLookupField.pickerAgreement.test.tsx, which now names what that file pins: a backend that ignores$expand. Its assertions are unchanged;\n - the pending.changeset/lookup-dropdown-cell-renderer-5492.md, which publishes into the same@object-ui/fieldsrelease notes. It said in the present tense that neither surface's request carries populate; that clause now reads as history ("at the time") and names this change. Frontmatter is untouched.\n-origin/mainis merged in with two merge commits,4215ed76band86982ace0, which carry the Spec Main Shape Gate fix1dbb9933c. The second merge touchedpnpm-lock.yamlin another importer only.\n\n## Measured: fixture mirroring the card\n\nThe fixture has 50 candidates.highlightFieldsiscode,task,version, withtaskamaster_detailfield totask. There are nolookup_columns, and no permission policy is loaded. The "before" leg ran with both source files at base8b1f06619. The "after" leg ran at8f70a8b74; neither the gate nor the empty-list guard adds a request.\n\n| reading | dropdown (card) | dropdown (control: no reference column shown) | browse-all picker (card) |\n|---|---|---|---|\n| before: candidate queries ·$expand· per-rowfindOne| 1 · none · 50 | 1 · none · 0 | 1 · none · 10 (one page) |\n| after | 1 ·['task']· 0 | 1 · none · 0 | 1 ·['task']· 0 |\n\nSchema reads are a constant on both legs: thetask_versionschema at mount, plus thetaskschema once (module-cached) when a task cell renders.\n\n## Mechanism assumptions\n\n- A1 holds.LookupCellRenderergives an expanded object no primitive id, souseLookupNamenever fetches. Its object branch names the record.packages/fields/src/index.tsxis not edited.\n- A2 holds.useRecordQueryforwardsexpand.\n- A3, as first dispatched, was falsified. The objectui#7215 gate lives in each caller, and@object-ui/fieldshad no path to the policy. The seat widened the claim's surface (issue comment 5820197003), and the gate now lands here, in the family's shape.\n- Out of scope by the seat's ruling, and to be filed by the seat as one card at acceptance:\n - the dropdown / picker display columns are not FLS-filtered (RelatedList's are);\n -PeoplePicker's$expandis ungated.\n\n## Tests (HEAD9796fdbb4)\n\n-LookupField.candidateExpand-10223.test.tsx, 13 tests:\n - the card count, ausercolumn, the control and the picker count;\n - two empty-list pins:\n -highlightFields ['name']over an object declaringcreated_by(user),owner_id(lookup),task(master_detail) andowner(user): neither the dropdown request nor the recents-rail request carries a$expandkey;\n - a picker whose only column is the id: no$expandkey;\n - dropdown and picker invariance: labels, atitleFormatnaming the expanded field, subtitles, and the callback payloads are identical whether or not the backend honours$expand;\n - five FLS pins against the realPermissionProvider:\n - a deniedtask_version.taskis left out while the readableownerstays, on the dropdown, the recents rail and the picker;\n - a readabletaskis kept;\n - no provider filters nothing.\n- Full@object-ui/fieldssuite, run in two locked halves:\n -widgets/: 75 files passed, 696 tests passed;\n - the rest: 106 files passed plus 1 skipped, 2364 tests passed plus 7 skipped.\n- 88 consumer test files: every test outsidepackages/fieldsthat names the picker or lookup trigger, plus every test that mocks@object-ui/permissions. 88 files passed, 1026 tests passed.\n-pnpm --filter @object-ui/fields run type-checkexits 0. It ran afterpnpm --filter '@object-ui/fields^...' run build, a closure that includes@object-ui/permissions. The new test file is in the test program.\n- Lint on the touched sources: 0 errors. Warning counts equal their base:LookupField.tsx59,RecordPickerDialog.tsx35.\n\n## Ablations\n\nEach ablation ran with the fix committed. The restore wasgit checkout HEAD, proved by blob hash and an emptygit diff HEAD.\n\n1. Both sources at base. The count tests go red:expected undefined to deeply equal [ 'task' ].\n2. 2a: dropdown options built from the expanded row. The dropdown invariance test goes red, with labels readingC-0 - [object Object].\n3. 2b / 2c: the picker's payload / title template read the expanded row. The picker invariance test goes red, with titles readingC-0 - [object Object]in 2c.\n4. 3a:LookupField's FLS filter removed. The dropdown and recents-rail deny pins go red.\n5. 3b:RecordPickerDialog's FLS filter removed. The picker deny pin goes red.\n6. 4: both sources at the pre-guardb7ee692eb. Both empty-list pins go red (expected true to be falseon the$expandkey); 2 failed, 11 passed.\n\n## Gates (exit 0 at9796fdbb4)\n\n-check-changeset-presence\n-check-changeset-no-major\n-check:new-line-citations: 0 new\n-check:phantom-deps\n-check:unused-deps\n-check:control-bytes\n-check:changeset-claims\n-check:pending-changeset-literals\n-check:lockfile-integrity: clean\n-check:lockfile-dedupe: deduped\n- Governed surface: NOT GOVERNED.\n\n## Acceptance notes\n\n-usercolumns.buildExpandFieldsincludesuser, so a previewedusercolumn now renders an avatar plus a name, where it used to show the unresolved raw-id marker. The avatar inside the one-line dropdown subtitle was not checked in a browser (NOT MEASURED: visual).\n- Numeric ids.toPredicateRecordreturns ids as strings, so on a numeric-id backend that honours$expand, callback payloads carry the string form of an expanded column's key. The committed value is unaffected. The changeset says so.\n- Late schema or policy. If the referenced schema lands after the dropdown opens, the first query goes out without$expandand a second one with it. A permission policy landing after the dropdown opens triggers a refetch only when it removes a name from the list.\n- Recents labels. The recents rail now derives its option labels from the current schema.\n- Lockfile. Run in a fresh worktree with no installed tree,pnpm install --lockfile-onlyalso re-resolved unrelatedesbuildpeer variants. Run over an installed tree, it gave the 3-line edge alone, and that is the hunk committed.check:lockfile-integrityandcheck:lockfile-dedupeare both clean.\n\nSession:https://claude.ai/code/session_01BP8CMtACxTdLjqR6rhd33C\n\n---\n_Generated by Claude Code_\n"
}
Generated by Claude Code
objectstack-fleet commented
on Sep 24, 2026 ContributorMore actions✅ ACCEPT: objectui#10341 at
9796fdb, landing now (ready → merge queue)domain:uiseat #4,session_01BP8CMtACxTdLjqR6rhd33C. I read the dev report and its patch-round addendum, and checked both against the diff and CI.Implemented-by: claude/issue-10223-lookup-candidates-expand Reviewed-by: session_01BP8CMtACxTdLjqR6rhd33Citem reading the fix The lookup dropdown (and its recently-used rail) and the record picker ask for $expandon the reference columns they display, chosen by core'sbuildExpandFields. On the card's fixture, one dropdown open went from 1 query + 50 per-row reads to 1 query + 0; the picker went from 1 + 10 to 1 + 0FLS Once the permission policy has loaded, a relation it denies is not expanded. This is the objectui#7429 family's caller-side shape, through the new @object-ui/fields→@object-ui/permissionsedge (no cycle; the lockfile hunk is the 3-line link)display-only Options, titles, the committed value and callback payloads read the row with relations collapsed back to ids. Only the preview and table cells render the expanded record patch round 1 The first head expanded EVERY relation when the dropdown previewed no column ( buildExpandFieldsreads an empty column list as "no restriction"). Guarded in the dropdown and the picker, and pinned. The pendinglookup-dropdown-cell-renderer-5492changeset's present-tense "neither request carries populate" was corrected to history in place (declared surface addition)review-tier records FAIL on b7ee692(the empty-list over-expansion), then PASS on9796fdb, both posted on objectui#10341semver patchon@object-ui/fieldsCI 45 check-runs: 42 success, 3 skipped by design, 0 red; Spec Main Shape Gateincludedtrailers Every commit carries the model-free co-author pair only Findings (
out_of_scope_findings, each with its disposition)- The lookup dropdown and the picker never FLS-filter their DISPLAY columns (
RelatedListdoes), andPeoplePicker's auto-derived$expandis ungated ⇒ filed in this same act, as the seat promised in comment 5820197003. They share the dependency edge this PR adds. - A previewed
usercolumn now shows an avatar and name, and callback payloads carry an expanded column's id as a string on a numeric-id backend ⇒ Acceptance notes; both are stated in the changeset.
PR body
The dev's replacement body is applied in this act. The re-review found two corrections in it and both are made: the merge-commit shas (
177e5b5f0,a2de421be), and the lockfile sentence scoped to this PR's files.State in this act
ready+ auto-merge ⇒ merge queue, through the ccr pair.Fixes #10223closes this card on merge.
Generated by Claude Code
- The lookup dropdown and the picker never FLS-filter their DISPLAY columns (
现象
打开一个 lookup 字段的下拉,Network 中出现 55 个请求:1 个候选列表查询,另外 50 个是按 id 逐条查询的 GET。
下拉列表的显示和使用不受影响。这 50 个请求只在后台补全副标题里关联字段的名称,但每打开一次下拉都会产生这些多余的请求,增加服务端负载。
复现场景:排班计划(
production_plan)新建表单 →「任务」字段(引用task_version)→ 打开下拉。触发条件
被引用对象的
highlightFields前几列(下拉默认取前 4 列)包含 lookup 或 master_detail 字段,且该 lookup 字段没有声明lookup_columns。本例:
task_version.highlightFields = ['code', 'task', 'version', ...],其中task是 master_detail 字段(指向task)。50 条候选各自触发一次对task的逐条查询。请求数 ≈ 1 + 候选条数(最多 50)× 关联列数。
定位
packages/fields/src/widgets/LookupField.tsx:545的候选查询(useRecordQuery)没有传expand。RecordPickerDialog.tsx:596也一样。packages/fields/src/index.tsx的useLookupName对每个 id 单独调用一次findOne。ObjectGrid.tsx用buildExpandFields自动为 lookup 字段加$expand,没有这个问题。