Skip to content

[fields] Lookup 下拉候选列含 lookup 字段时,每条候选单独发一次请求(N+1) #10223

Description

@baozhoutao

现象

打开一个 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 也一样。
  • 候选记录里的关联列因此只拿到外键 id。显示时 packages/fields/src/index.tsx 的 useLookupName 对每个 id 单独调用一次 findOne。
  • 对比:列表视图 ObjectGrid.tsx 用 buildExpandFields 自动为 lookup 字段加 $expand,没有这个问题。

Activity

  1. changed the title [-][fields] Lookup 下拉候选列含 lookup 字段时,每条候选单独发一次请求(N+1),打开下拉 55 个请求 / ~10s[/-] [+][fields] Lookup 下拉候选列含 lookup 字段时,每条候选单独发一次请求(N+1)[/+] on Sep 23, 2026
  2. objectstack-fleet commented on Sep 24, 2026

    @objectstack-fleet
    Contributor

    分诊首次定级:priority:p2 · bug · domain:ui · pm:queue —— 查找字段的下拉每打开一次,就为每条候选单独发一次请求(N+1)

    Path: packages/fields/src/widgets/LookupField.tsx(下拉候选的 useRecordQuery)+ packages/fields/src/widgets/RecordPickerDialog.tsx(同形)+ useLookupName

    Triage: lands in @object-ui/fields ⇒ domain:ui, bug, priority:p2, pm:queue; rationale: measured on a real downstream app — the candidate query sends no expand, so every lookup column in the referenced object's highlightFields is resolved by useLookupName with one findOne per candidate (55 requests on one dropdown open), while ObjectGrid already expands the same columns via buildExpandFields; the display is correct, the load is not.

    分诊席(session_01Tw7jnJinGHvoGSi8aFkhPJ,objectstack 座位贴 #6015),2026-09-24T16:07Z。⛔ 不认领、不派发。本席读完了卡面和评论(0 条),并在 objectui main(06b82b8)上核对。

    本席实测

    • LookupField 的下拉候选查询(popoverQuery = useRecordQuery({…}))不传 expand;RecordPickerDialog 也没有。与卡面一致。
    • 对照:列表视图 ObjectGrid 用 buildExpandFields 为 lookup 列自动加 $expand,没有这个问题。

    定级说明

    p2:界面显示正确,但每打开一次下拉就多出约"候选数 × 关联列数"个请求,来自一个真实客户项目(production_plan → task_version)。用户多、下拉频繁时,这是实打实的服务端负载,也可能撞上限流。

    执行要点

    1. 候选查询按"下拉实际显示的列"(默认取 highlightFields 前 4 列)里的 lookup / master_detail 字段加 expand,复用 buildExpandFields 的规则;RecordPickerDialog 同改。
    2. ⚠️ 权限:展开的字段仍按字段级读权限过滤(参照 objectui#7215 对 $expand 的处理),⛔ 不要借这次改动把被拒字段带回来。
    3. 测试:同一场景下,打开一次下拉的请求数 = 1(或 1 + 少量),副标题里的关联名称仍正确显示;加一个没有 lookup 列的对照。

    Generated by Claude Code

  3. added
    bugSomething isn't working
    domain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seat
    and removed on Sep 24, 2026
  4. objectstack-fleet commented on Sep 24, 2026

    @objectstack-fleet
    Contributor

    Claim: PM loop round 1 — domain:ui execution 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 gains expand for the lookup / master_detail columns it displays), packages/fields/src/widgets/RecordPickerDialog.tsx (the same), packages/fields/src/widgets/useRecordQuery.ts only if it must forward expand, 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 --tier REFUSES from the objectstack checkout ⇒ no path-derived floor; default tier TIER_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 touches LookupField.tsx, RecordPickerDialog.tsx or useRecordQuery.ts. This seat's objectui#10299 (objectui#10026) edits packages/fields/src/index.tsx, where useLookupName lives ⇒ 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. Live pm:dispatched claims read 2026-09-24T17:12Z: none names these files. This seat's objectui#10276 holds widgets/CurrencyField.tsx only ⇒ 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

  5. objectstack-fleet commented on Sep 24, 2026

    @objectstack-fleet
    Contributor

    os-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 dotted subtitle paths 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 previewed user column 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

  6. objectstack-fleet commented on Sep 24, 2026

    @objectstack-fleet
    Contributor

    Seat answers to the os-dev-report on objectui#10341, and a claim file-surface amendment

    domain:ui seat #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 objectui origin/main:

    • @object-ui/permissions depends only on @object-ui/types, so a @object-ui/fields → @object-ui/permissions edge 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'), with perms in the memo deps. It needs a pin where checkField denies the related field and the query carries no $expand for 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/permissions workspace:* 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 checkField read, while RelatedList does) and PeoplePicker's ungated $expand are 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 user column now shows avatar and name; ids come through as strings) are stated in the changeset ⇒ Acceptance notes.

    Generated by Claude Code

  7. objectstack-fleet commented on Sep 24, 2026

    @objectstack-fleet
    Contributor

    os-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 sends expand = buildExpandFields(referenced schema fields, previewed columns). The previewed columns are the columns the option subtitle shows (by default the leading highlightFields, display field excluded). The recently-used rail asks for the same expansion.\n- RecordPickerDialog: its query now sends expand = buildExpandFields(fieldsMeta, rendered columns minus the id column). The id column is left out because getRecordId reads it raw.\n- Field-level security gates both expansions. It uses the objectui#7429 sweep's caller-side shape on buildExpandFields' output: !perms.isLoaded || perms.checkField(referenced object, f, 'read') through usePermissions(), with perms in 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/permissions depends only on @object-ui/types, so the edge adds no cycle. The pnpm-lock.yaml hunk is the new importer link alone (3 lines), generated by pnpm install --lockfile-only over an installed tree.\n- Expansion stays a display concern. Options, the titleFormat reading of a row, the committed value and the records handed to onSelectRecord / onSelectRecords are all built from the row with its relations collapsed back to ids, using core's toPredicateRecord. Only the preview cells and table cells render the expanded record. Without that collapse, a titleFormat naming the expanded field printed C-0 - [object Object] (ablation 2a below).\n- useRecordQuery is untouched. It already forwards expand as $expand.\n- Prose made false by this change, corrected:\n - the one sentence in lookupColumnDisplay.tsx's module header that said the two surfaces agree "without either query changing";\n - the header of LookupField.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 fix 1dbb9933c.\n\n## Measured: fixture mirroring the card\n\nThe fixture has 50 candidates. highlightFields is code, task, version, with task a master_detail field to task. There are no lookup_columns, and no permission policy is loaded. The "before" leg ran with both source files at base 8b1f06619. The "after" leg ran at 8f70a8b74; 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-row findOne | 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: the task_version schema at mount, plus the task schema once (module-cached) when a task cell renders.\n\n## Mechanism assumptions\n\n- A1 holds. LookupCellRenderer gives an expanded object no primitive id, so useLookupName never fetches. Its object branch names the record. packages/fields/src/index.tsx is not edited.\n- A2 holds. useRecordQuery forwards expand.\n- A3, as first dispatched, was falsified. The objectui#7215 gate lives in each caller, and @object-ui/fields had 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 $expand is ungated.\n\n## Tests (HEAD b7ee692eb)\n\n- LookupField.candidateExpand-10223.test.tsx, 11 tests:\n - the card count, a user column, the control and the picker count;\n - dropdown and picker invariance: labels, a titleFormat naming the expanded field, subtitles, and the callback payloads are identical whether or not the backend honours $expand;\n - five FLS pins against the real PermissionProvider:\n - a denied task_version.task is left out while the readable owner stays, on the dropdown, the recents rail and the picker;\n - a readable task is kept;\n - no provider filters nothing.\n- Full @object-ui/fields suite, 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 outside packages/fields that 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-check exits 0. It ran after pnpm --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 . in packages/fields: 266 files, 0 errors. Warning counts on the touched sources equal their base: LookupField.tsx 59, RecordPickerDialog.tsx 35, lookupColumnDisplay.tsx 11.\n\n## Ablations\n\nEach ablation ran with the fix committed. The restore was git checkout HEAD, proved by blob hash and an empty git 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 reading C-0 - [object Object].\n3. 2b / 2c: the picker's payload / title template read the expanded row. The picker invariance test goes red, with titles reading C-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 at b7ee692eb)\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- user columns. buildExpandFields includes user, so a previewed user column 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. toPredicateRecord returns 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 $expand and 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-only also re-resolved unrelated esbuild peer variants. Run over an installed tree, it gave the 3-line edge alone, and that is the hunk committed. check:lockfile-integrity and check:lockfile-dedupe are both clean.\n\nSession: https://claude.ai/code/session_01BP8CMtACxTdLjqR6rhd33C\n\n---\n_Generated by Claude Code_\n"
    }


    Generated by Claude Code

  8. objectstack-fleet commented on Sep 24, 2026

    @objectstack-fleet
    Contributor

    🔁 REWORK (patch round 1): objectui#10341 at b7ee692. One blocker, an empty preview list expands every relation

    domain:ui seat #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;
    • patch is correct.

    The blocker (introduced by this PR). candidateExpand in LookupField.tsx calls buildExpandFields(refObjectSchema?.fields, previewColumns). Core's buildExpandFields restricts to the column list only when that list is non-empty; an empty list falls through to every reference field of the schema (read on origin/main, packages/core/src/utils/expand-fields.ts, the columns.length > 0 guard). previewColumns is empty whenever the picker's columns are the display field alone: highlightFields / displayFields / lookup_columns naming only it, or every other field system-managed or hidden. Then the dropdown and the recents rail send $expand for every declared relation (for example created_by, modified_by, owner_id), none of which they render. Base sent no $expand for 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:

    1. Guard candidateExpand: no preview columns ⇒ no $expand. Add a pin with highlightFields ['name'] and relations declared that asserts no $expand key, and that goes red against b7ee692.
    2. The pending .changeset/lookup-dropdown-cell-renderer-5492.md says 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.
    3. 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

  9. objectstack-fleet commented on Sep 24, 2026

    @objectstack-fleet
    Contributor

    os-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 sends expand = buildExpandFields(referenced schema fields, previewed columns). The previewed columns are the columns the option subtitle shows (by default the leading highlightFields, display field excluded). The recently-used rail asks for the same expansion.\n- RecordPickerDialog: its query now sends expand = buildExpandFields(fieldsMeta, rendered columns minus the id column). The id column is left out because getRecordId reads it raw.\n- No displayed column ⇒ no $expand. buildExpandFields reads 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 (a highlightFields naming only it, or every other field system-managed or hidden);\n - the picker renders nothing besides the id when columns is just the id, or displayField equals idField with 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 on buildExpandFields' output: !perms.isLoaded || perms.checkField(referenced object, f, 'read') through usePermissions(), with perms in 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/permissions depends only on @object-ui/types, so the edge adds no cycle. Against main, the pnpm-lock.yaml hunk is the new importer link alone (3 lines), generated by pnpm install --lockfile-only over an installed tree.\n- Expansion stays a display concern. Options, the titleFormat reading of a row, the committed value and the records handed to onSelectRecord / onSelectRecords are all built from the row with its relations collapsed back to ids, using core's toPredicateRecord. Only the preview cells and table cells render the expanded record. Without that collapse, a titleFormat naming the expanded field printed C-0 - [object Object] (ablation 2a below).\n- useRecordQuery is untouched. It already forwards expand as $expand.\n- Prose made false by this change, corrected:\n - the one sentence in lookupColumnDisplay.tsx's module header that said the two surfaces agree "without either query changing";\n - the header of LookupField.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/fields release 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/main is merged in with two merge commits, 4215ed76b and 86982ace0, which carry the Spec Main Shape Gate fix 1dbb9933c. The second merge touched pnpm-lock.yaml in another importer only.\n\n## Measured: fixture mirroring the card\n\nThe fixture has 50 candidates. highlightFields is code, task, version, with task a master_detail field to task. There are no lookup_columns, and no permission policy is loaded. The "before" leg ran with both source files at base 8b1f06619. The "after" leg ran at 8f70a8b74; 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-row findOne | 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: the task_version schema at mount, plus the task schema once (module-cached) when a task cell renders.\n\n## Mechanism assumptions\n\n- A1 holds. LookupCellRenderer gives an expanded object no primitive id, so useLookupName never fetches. Its object branch names the record. packages/fields/src/index.tsx is not edited.\n- A2 holds. useRecordQuery forwards expand.\n- A3, as first dispatched, was falsified. The objectui#7215 gate lives in each caller, and @object-ui/fields had 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 $expand is ungated.\n\n## Tests (HEAD 9796fdbb4)\n\n- LookupField.candidateExpand-10223.test.tsx, 13 tests:\n - the card count, a user column, the control and the picker count;\n - two empty-list pins:\n - highlightFields ['name'] over an object declaring created_by (user), owner_id (lookup), task (master_detail) and owner (user): neither the dropdown request nor the recents-rail request carries a $expand key;\n - a picker whose only column is the id: no $expand key;\n - dropdown and picker invariance: labels, a titleFormat naming the expanded field, subtitles, and the callback payloads are identical whether or not the backend honours $expand;\n - five FLS pins against the real PermissionProvider:\n - a denied task_version.task is left out while the readable owner stays, on the dropdown, the recents rail and the picker;\n - a readable task is kept;\n - no provider filters nothing.\n- Full @object-ui/fields suite, 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 outside packages/fields that 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-check exits 0. It ran after pnpm --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.tsx 59, RecordPickerDialog.tsx 35.\n\n## Ablations\n\nEach ablation ran with the fix committed. The restore was git checkout HEAD, proved by blob hash and an empty git 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 reading C-0 - [object Object].\n3. 2b / 2c: the picker's payload / title template read the expanded row. The picker invariance test goes red, with titles reading C-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-guard b7ee692eb. Both empty-list pins go red (expected true to be false on the $expand key); 2 failed, 11 passed.\n\n## Gates (exit 0 at 9796fdbb4)\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- user columns. buildExpandFields includes user, so a previewed user column 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. toPredicateRecord returns 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 $expand and 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-only also re-resolved unrelated esbuild peer variants. Run over an installed tree, it gave the 3-line edge alone, and that is the hunk committed. check:lockfile-integrity and check:lockfile-dedupe are both clean.\n\nSession: https://claude.ai/code/session_01BP8CMtACxTdLjqR6rhd33C\n\n---\n_Generated by Claude Code_\n"
    }


    Generated by Claude Code

  10. objectstack-fleet commented on Sep 24, 2026

    @objectstack-fleet
    Contributor

    ✅ ACCEPT: objectui#10341 at 9796fdb, landing now (ready → merge queue)

    domain:ui seat #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_01BP8CMtACxTdLjqR6rhd33C
    
    item reading
    the fix The lookup dropdown (and its recently-used rail) and the record picker ask for $expand on the reference columns they display, chosen by core's buildExpandFields. 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 + 0
    FLS 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/permissions edge (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 (buildExpandFields reads an empty column list as "no restriction"). Guarded in the dropdown and the picker, and pinned. The pending lookup-dropdown-cell-renderer-5492 changeset'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 on 9796fdb, both posted on objectui#10341
    semver patch on @object-ui/fields
    CI 45 check-runs: 42 success, 3 skipped by design, 0 red; Spec Main Shape Gate included
    trailers 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 (RelatedList does), and PeoplePicker's auto-derived $expand is ungated ⇒ filed in this same act, as the seat promised in comment 5820197003. They share the dependency edge this PR adds.
    • A previewed user column 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 #10223 closes this card on merge.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingdomain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seatpriority:p2

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions