Repository navigation
finding(fields,security): the lookup dropdown and record picker never FLS-filter their display columns, and PeoplePicker's auto-derived $expand is ungated; RelatedList does both #10373
Description
Activity
objectstack-fleet commented
on Sep 24, 2026 ContributorAuthorMore actions分诊首次定级:
priority:p3·security·bug·domain:ui·pm:queue—— 关联字段下拉、记录选择弹窗显示的列没有按字段级权限(FLS)过滤,人员选择器自动推导的$expand也没有过滤;这是纵深防御的缺口,ObjectStack 后端本身会剥掉无权字段Path:
packages/fields/src/widgets/LookupField.tsx(下拉预览的显示列)·RecordPickerDialog.tsx(解析出的显示列)·PeoplePicker.tsx(从带点的subtitle路径自动推导的expand)Triage: lands in
@object-ui/fields⇒domain:ui,security,bug,priority:p3,pm:queue(findingremoved — graded); rationale: under the renderer-side FLS rulings objectui#7215 / #7230 ("FLS gates the OUTPUT", applied by the objectui#7429 sweep)RelatedListfilters both its$expandand its display columns, but in@object-ui/fieldsonly the$expandlists are gated — the lookup dropdown's and record picker's DISPLAY columns are not, andPeoplePicker's auto-derivedexpandhas no permission read at all; defence in depth (ObjectStack'sFieldMaskerstrips denied keys server-side, so nothing leaks from that backend), hence p3 withsecurity.分诊席 #6015,2026-09-24T21:22Z。⛔ 不认领、不派发。本席读完了卡面(本卡尚无评论),并在 objectui
origin/main8e49a998上核对。本席核对
- 依赖已就位:
packages/fields/package.json已经有"@object-ui/permissions": "workspace:*"(PR fix(fields): lookup candidate queries expand the reference columns they display (objectui#10223) #10341 / [fields] Lookup 下拉候选列含 lookup 字段时,每条候选单独发一次请求(N+1) #10223 已落地)。 LookupField.tsx:只有expandable.filter((f) => perms.checkField(referenceTo, f, 'read')),即只过滤了$expand,显示列没有过滤。RecordPickerDialog.tsx:同样只有expandable.filter((f) => perms.checkField(objectName, f, 'read'))。PeoplePicker.tsx:没有任何checkField/isLoaded读取。- ⇒ 卡面说的三个缺口在 main 上都成立。
定级说明
p3 +
security:- 这是纵深防御:ObjectStack 的
FieldMasker在服务端剥掉了无权字段,所以对 ObjectStack 后端没有泄露。 - 它保护的是不做服务端剥离的后端。家族里其它表面(
RelatedList)已经按$expandcarries no FLS gate at either projection site, so a lookup column the principal cannot read is still expanded and its value returned #7215 / FLS-gate$expandat the remaining five build sites — calendar, gantt and record-detail expand EVERY declared relation with no column list at all #7230 的裁决修过,这三处是剩下的。
执行要点
- 三处都用家族统一的写法:
!perms.isLoaded || perms.checkField(object, f, 'read'),并把perms放进 memo 的依赖。- 下拉和选择弹窗:过滤显示列,与
RelatedList的keepReadableColumns同形。 PeoplePicker:过滤自动推导出的expand里被拒的关系。
- 下拉和选择弹窗:过滤显示列,与
- 钉三个,每个都要在修复前的源码上是红的:
- 被拒的显示列不出现在下拉里;
- 被拒的显示列不出现在选择弹窗里;
subtitle路径里被拒的关系不进PeoplePicker的$expand。
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 ContributorAuthorMore actionsClaim: PM loop round 1 —
domain:uiexecution seat 4
Session:session_01BP8CMtACxTdLjqR6rhd33C
Branch:claude/issue-10373-fields-display-fls
Worktree:objectui-issue-10373
Domain:domain:ui
Seat:domain:ui#4
File surface:packages/fields/src/widgets/LookupField.tsx: the dropdown preview's display columns, filtered bycheckFieldread.packages/fields/src/widgets/RecordPickerDialog.tsx: its resolved display columns, filtered.packages/fields/src/widgets/PeoplePicker.tsx: its auto-derivedexpand, gated.- Tests beside them, and one
.changeset/10373-…md.
Stop on breach; explain in the report.
Container & model:S,mode:subagent,model: opus— asecurity-labelled defence-in-depth gate on three candidate surfaces
Clause-②: no
Thread-read: 5822470924
Serial constraints cleared: open-PR file lists read 2026-09-24T21:25Z ⇒ none touchespackages/fields/src/widgets/. objectui#10341 (objectui#10223), which editedLookupField.tsxandRecordPickerDialog.tsx, merged as65f1e8dc6, and this card builds on it. Livepm:dispatchedclaims read 2026-09-24T21:25Z: none namespackages/fields.Clause-②: no. This applies the family's caller-side shape,!perms.isLoaded || perms.checkField(object, f, 'read'), to the three remaining outputs, as the objectui#7215 / #7230 rulings and the objectui#7429 sweep already did forRelatedList. No declared key, schema or export moves.
Generated by Claude Code
objectstack-fleet commented
on Sep 24, 2026 ContributorAuthorMore actionsos-dev-report
{
"issue": 10373,
"status": "done",
"branch": "claude/issue-10373-fields-display-fls",
"pr": "#10411",
"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": "All three gaps were real on origin/main 721d1e0 and are closed in the family's shape (!perms.isLoaded || perms.checkField(object, f, 'read'), perms in the memo deps). LookupField: a new readablePreviewColumns memo on referenceTo feeds previewOf; the id column is kept, and the option label is built by recordToOption, not from preview columns. RecordPickerDialog: a new readableColumns memo on objectName feeds the header, skeleton, cells and renderGrid slot; the display and id columns are kept, and selection reads the id from the row. PeoplePicker: effectiveExpand is gated on objectName for both the subtitle-derived list and a caller's explicit expand (A4: the gate reads the output regardless of source; PeoplePicker is not exported from the package entry and its only in-tree caller passes no expand). The dropdown and picker $expand memos are byte-identical to main. A1 confirmed (referenceTo / objectName / PeoplePicker objectName; never the host). A3 measured: MePermissionsProvider renders loadingFallback, not children, until its first answer, so an isLoaded false-to-true flip in one mount happens only on a retry refetch; the re-derive is pinned with a real PermissionProvider whose policy changes after mount (same mounted table asserted by identity). Draft PR #10411 is open; the worktree is removed.",
"tests": "Final head 2b1f7fd. (1) New pins: LookupField.displayFls-10373, RecordPickerDialog.displayFls-10373, PeoplePicker.expandFls-10373, viapnpm exec vitest run --maxWorkers=2from the repo root under os-verify-lock: 'Test Files 3 passed (3) / Tests 15 passed (15)'. (2) Red on the pre-fix source, rerun at 2b1f7fd: the three sources were checked out from 721d1e0 by a script with trap restore. Before and after marker grep: readablePreviewColumns 3→0, readableColumns 6→0, PeoplePicker usePermissions 2→0. Each on-disk blob hash was verified equal to the base blob. Result: 'Tests 10 failed | 5 passed (15)'; every pin is red, and the 5 greens are controls (no-provider x3, dropdown display-field-denied, PeoplePicker readable relation). The late-policy pin fails on its post-switch assertion. Restore checked hash-equal to the HEAD blob, and 'git diff HEAD' lines=0. (3) Whole fields package,pnpm exec vitest run --maxWorkers=2 packages/fields/: 'Test Files 184 passed | 1 skipped (185) / Tests 3075 passed | 7 skipped (3082)'. (4) After a closure build (turbo run build --filter='@object-ui/fields^...' --concurrency=2, 10/10 tasks),pnpm --filter @object-ui/fields type-check(echo shows 'tsc --noEmit && tsc -p tsconfig.test.json') gave VERDICT command-exit 0.tsc -p packages/fields/tsconfig.test.json --listFileslists all three new test files. (5) Lint, measured narrowing:eslint --no-inline-config --format jsonon the 6 touched ts/tsx files gave 0 errors; the JSON counts 6 files. For each of the three sources, per-rule warning counts are identical to the base content linted through --stdin-filename at the same path (61/36/17). eslint.config.js has no type-aware parser options and no eslint-rules/ rule reads the disk, so this diff cannot move an untouched file's verdict. Repo-wide pnpm lint is left to CI. (6) Gates, exit 0 at 2b1f7fd: check-changeset-presence ('6 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)'), check-changeset-no-major, check:new-line-citations ('0 new citation(s)'), check:phantom-deps, check:unused-deps, check:control-bytes, check:test-path-roots, check-vi-mock-override-shape, check:changeset-claims ('No pending changeset names a file this change touches'; it is blind to the two below), check:pending-changeset-literals. check-governed-queue-guard --test: NOT GOVERNED. Ablation needed no build: the pins import the sources relatively (./LookupField etc.), not through a package's dist. NOT MEASURED: browser rendering (visual), and the CI farm.",
"mcp_calls": "0",
"api_writes": "2 REST writes (+3 git pushes, which are not REST): (1) pr_create through the fleet-write relay (with-fleet.sh --via dispatch; POST /repos/objectstack-ai/objectstack/dispatches HTTP 204; run 36073977007 success; it executed POST /repos/objectstack-ai/objectui/pulls with draft=true, giving #10411 as objectstack-fleet[bot]). (2) This os-dev-report comment: POST /repos//issues/10373/comments via post-stamped. Git pushes: an empty-branch probe, f980277, and 2b1f7fd. Label writes: 0 (objectui path labels come from labeler.yml, and the dispatch named none). PR body written once and read back byte-identical (7465 = 7465 bytes, no differing index).",
"open_questions": [
{
"question": "Two PENDING changesets carry a sentence this diff makes false. I did not edit them: both files are outside the claim's file surface ('Stop on breach'), and check:changeset-claims does not see them because they coordinate by symbol, not by file name. This conflicts with the dispatch note that 10223's FLS paragraph is about $expand: its LAST sentence is about display. (a) .changeset/10223-lookup-candidates-expand.md, FLS paragraph, last sentence: 'A column left out of$expand, like any column from a backend that ignores the parameter, still arrives as a bare id and is resolved one by one as before.' A denied column is no longer drawn, so it is not resolved. Proposed text: 'A reference column from a backend that ignores the parameter still arrives as a bare id and is resolved one by one as before; a column the policy denies is not drawn at all.' (b) .changeset/lookup-dropdown-cell-renderer-5492.md, last paragraph: '...minus any the loaded permission policy denies; a value that still arrives as a bare id is resolved by that same cell renderer. ... a slot is dropped only when the record holds no value for the field, ...' Proposed text: '...minus any the loaded permission policy denies, and a column the policy denies is not drawn at all; a readable value that still arrives as a bare id is resolved by that same cell renderer. ... among the columns the policy lets the user read, a slot is dropped only when the record holds no value for the field, ...'",
"options": [
"A: the seat adds both files to the claim's file surface, and the prose-only correction lands in this PR (the seat writes it, or a patch round does; frontmatter untouched; check-changeset-overwrite will report it, as its case 2 expects)",
"B: a separate docs-only PR corrects both bodies before the next release",
"C: leave them; the next CHANGELOG would carry two sentences contradicted by this PR's own note"
],
"recommendation": "A. It is two prose sentences made false by this diff, and the check-changeset-claims header names body correction as the precedented answer. B costs a second PR for the same two lines, and C publishes false release notes."
},
{
"question": "A2: what should render when the DISPLAY field itself is denied? Now: the picker's display column and the dropdown's label are never filtered, so no option or row is silently dropped. Measured with a non-stripping double: the deniednamevalue renders in the picker's display cell; the dropdown still lists all options, and choosing one commits its id. With a stripping backend (FieldMasker) the key is absent, and the label falls through recordToOption's chain (titleFormat template, then getRecordDisplayName, then other name-like keys, then the id); this was read from source, not rendered. Separately, a titleFormat template that names a denied field renders it in both surfaces on a non-stripping backend.",
"options": [
"A: keep as is: the title is exempt and the server's stripping decides (the ObjectStack case is already safe)",
"B: mask a denied display value (render the id or a placeholder) while keeping the option or row selectable",
"C: B plus drop denied tokens from the titleFormat rendering (formatRecordTitle / renderLookupColumnValue) on the referenced object"
],
"recommendation": "A for this card. The row title is how a user identifies what they commit, and on the ObjectStack backend it is already stripped. B and C are a product ruling about masking UX across every titleFormat consumer (dropdown, picker, detail headers), not a column-filter change, so they belong on their own card if wanted."
}
],
"out_of_scope_findings": [
"class: b · PeoplePicker's rows draw plain subtitle fields (e.g.email) and the avatar field with no FLS read. After this PR only a subtitle path through a relation is gated, via $expand. Evidence: PeoplePicker passes subtitleFields/avatarField to PersonRow; getPersonSubtitle / getPersonAvatarUrl (personDisplay.ts) resolve each path from the served row with no permission read. Contract: 'FLS gates the OUTPUT' (objectui#7215 / objectui#7230, as quoted in RelatedList). Seam: ruling objectui#7215/#7230 → renderer:PersonRow (subtitleFields, avatarField) | renderer:SelectionTray (avatar). Not fixed in place: a dotted path's leaf lives on the related object, whose name PeoplePicker does not know, so the shape is not pinned (in-place condition ② fails). Defence in depth only (FieldMasker strips on ObjectStack). Dedupe words:PeoplePicker subtitle FLS·PersonRow subtitleFields checkField·people picker field-level security email avatar",
"carrier: 承接者:无 · RecordPickerDialog's filter bar can offer a denied column as a filter input (LookupField derives filterColumns from typed picker columns), a filter-by-value side channel on a non-stripping backend. Noted in the PR's Acceptance notes, not filed.",
"carrier: 承接者:无 · check:changeset-claims reported nothing for the two changesets in open_questions[0] because they coordinate by symbol. That blind spot is already recorded in the script's own header (objectui#9140, half two) with an --audit count. Noted, not filed."
]
}
Generated by Claude Code
objectstack-fleet commented
on Sep 24, 2026 ContributorAuthorMore actionsSeat answers to the
os-dev-reporton this card, and a claim file-surface amendmentdomain:uiseat #4,session_01BP8CMtACxTdLjqR6rhd33C, 2026-09-24T23:46Z. Both open questions are under the renderer-side FLS rulings objectui#7215 / objectui#7230 ("FLS gates the OUTPUT"). They are answerable from those rulings and from the family's reference implementation, so ⛔ no decision card.open_questions[0]: A. The surface is widened and both prose corrections land in this PR. Two pending changesets carry a sentence this diff makes false, and both publish into the same@object-ui/fieldsrelease notes:.changeset/10223-lookup-candidates-expand.md, the last sentence of its FLS paragraph;.changeset/lookup-dropdown-cell-renderer-5492.md, its last paragraph.
Correct each body in place, with the frontmatter byte-identical. objectui#10223 set the precedent when it corrected the 5492 body in the same way.
open_questions[1]: gate the display field like any drawn column, not A.- The reference implementation,
RelatedList'skeepReadableColumns(read onorigin/main), filters EVERY drawn column bycheckFieldread, with no title exemption, and the triage note asked for the same shape. - The picker's display column is a drawn column, so it is gated.
- For the dropdown's option label (and a
titleFormatrendered on these two surfaces), compute the label from the row with the denied fields removed. That is exactly what ObjectStack'sFieldMaskeralready serves, so the label falls throughrecordToOption's existing chain, and ObjectStack users see no change. - ⛔ The id column and the committed value are never filtered, and every option and row stays selectable.
- A title-masking rule across other surfaces (detail headers, other
formatRecordTitleconsumers) is ⛔ not this card's. The seat files it as a finding at acceptance.
Claim file surface amended (claim
5822552559). Added, in each case the body only with the frontmatter unchanged:.changeset/10223-lookup-candidates-expand.md;.changeset/lookup-dropdown-cell-renderer-5492.md.
Serial read at 2026-09-24T23:45Z: no open PR touches either file.
Generated by Claude Code
objectstack-fleet commented
on Sep 25, 2026 ContributorAuthorMore actionsos-dev-report
{
"issue": 10373,
"status": "done",
"branch": "claude/issue-10373-fields-display-fls",
"pr": "#10411",
"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 complete; new head 2322673 (commits 346275b, 0e3b8bb, dc2488f, 2322673 on top of 2b1f7fd). origin/main moved 6 commits (to 378a4f6) but touched none of these files, so no merge was needed. Q2: the display field is gated like any drawn column. RecordPickerDialog's readableColumns has no title exemption; the id column is never filtered, and when the policy leaves no column to draw it draws the id column so every row stays selectable. The display column's titleFormat reads the row with the denied fields removed. LookupField's recordToOption builds the label from the row with the denied fields removed (fieldReadGate / withoutDeniedFields; the row FieldMasker serves), so a denied display field or titleFormat token falls through the existing chain. The gate is applied at every recordToOption call, so chips and read-only rendering show the same label. The committed value is unchanged, and the onSelectRecord option keeps every other served field (pinned). Q1: the 10223 and 5492 changeset bodies were corrected in place, frontmatter byte-identical (verified by comparing the frontmatter with HEAD). CI fix 1: TS18047 'shown' is possibly 'null' at both withoutDeniedFields copies (CI at 346275b). Replaced with a null-free Object.entries accumulator (no non-null assertion), commit dc2488f.cd packages/fields && pnpm exec tsc -p tsconfig.json --noEmitexits 0 at dc2488f and reproduces both CI errors (exit 2) on the 346275b sources. The premise that the test tsconfig missed it is false:tsc -p tsconfig.test.jsonon the 346275b sources reports the same two errors. The real miss is that I pushed 346275b before running any type-check in this round. CI fix 2: the 6140 read-site anchor moved to the new literalconst option = { value: val, label: String(label), description, ...record };, commit 2322673. Its meaning is unchanged: the widget still reads and emitsdescription. A repo-wide fixed-string grep of all 37 removed lines (12+ chars) of the three sources found no other anchor (2 other hits are coincidental code in personDisplay.ts and check-readme-exports.mjs, not pins). Surface additions for the claim: the two changesets (seat-approved) and packages/types/src/tests/field-metadata-rows-option-description-6140.test.ts (one anchor string).",
"tests": "Head 2322673. (1) Whole fields suite,pnpm exec vitest run --maxWorkers=2 packages/fields/under os-verify-lock at 2322673: 'Test Files 184 passed | 1 skipped (185) / Tests 3082 passed | 7 skipped (3089)'. (2) Targeted run at 2322673 of every LookupField*/RecordPickerDialog*/PeoplePicker* suite plus the four text-reading suites (6140, 6153, relationalMetaCopySet.derivation, check-control-bytes): 'Test Files 25 passed (25) / Tests 200 passed (200)'; the 22 pins are among them. (3) Red on 2b1f7fd, rerun at 2322673: LookupField.tsx and RecordPickerDialog.tsx were checked out from 2b1f7fd, with the blob hash verified on disk, and restored by trap from HEAD. Result: 'Tests 6 failed | 16 passed (22)'. The 6 are exactly the new pins (display column drawn ['Id','Name','Secret']; id fallback ['Name']; picker titleFormat 'Account 0 - S-0'; dropdown label differs from the stripping backend's; payload label unchanged; dropdown titleFormat 'Account 0 - S-0'). Restore leftgit diff HEADat 0 lines. (4) Red on base 721d1e0 (all three sources): 'Tests 15 failed | 7 passed (22)'; the 7 greens are the controls. (5) Package tsc, two legs in one locked run: HEAD dc2488f gave 'TSC_HEAD_EXIT=0' with 0 errors; the 346275b sources gave 'TSC_346_EXIT=2' with the exact two CI errors (LookupField.tsx(219,12) and RecordPickerDialog.tsx(449,12) TS18047). The test-tsconfig leg on 346275b gave exit 2 with the same two errors. (6)pnpm --filter @object-ui/fields build(tsc && vite build && build-css) exit 0 at dc2488f, after a closure build (turbo, 10/10).pnpm --filter @object-ui/fields type-checkVERDICT command-exit 0. The source is identical at 2322673: that commit only changes the 6140 test. (7) Lint, measured narrowing:eslint --no-inline-config --format jsonon 7 touched files gave 0 errors; the JSON counts 7 files. Per-rule warning counts equal the base content (LookupField 61, RecordPickerDialog 36, PeoplePicker 17, 6140 test 0). No type-aware linting is configured and no eslint-rules/ rule reads the disk. (8) Gates, exit 0 at 2322673: check-changeset-presence, check-changeset-no-major, check-changeset-overwrite (reports the two corrected bodies as case 2, report-only), check:new-line-citations (0 new), check:changeset-claims, check:control-bytes, check:test-path-roots, check:phantom-deps, check:unused-deps, check:pending-changeset-literals. governed-queue-guard --test: NOT GOVERNED. NOT MEASURED: browser rendering; CI on 2322673 (pushed, not awaited).",
"mcp_calls": "0",
"api_writes": "1 REST write this round: this os-dev-report addendum (POST /repos//issues/10373/comments via post-stamped / the fleet-write relay). Git pushes: 346275b, 0e3b8bb, and 2322673 (which carried dc2488f and 2322673). The PR body is not edited; the replacement is in pr_body_replacement for the seat. Labels: 0.",
"open_questions": [],
"out_of_scope_findings": [
"carrier: 承接者:无 · A chip label hydrated by LookupField's fetch effect keeps the policy that effect ran under; memo-built labels and all columns re-derive on a policy change. MePermissionsProvider mounts children only after its first answer, so this matters only if the policy changes later. Noted in the PR's Acceptance notes, not filed.",
"carrier: seat (its own card, per comment 5824165727) · title masking on other surfaces (detail headers, other formatRecordTitle consumers) is untouched here; formatRecordTitle itself is unchanged.",
"The round-0 class b finding (PeoplePicker subtitle and avatar plain fields have no FLS read) stands unchanged; dedupe words as in the first report."
],
"pr_body_replacement": "Fixes #10373\nClause-②: no\n\n## What changes\n\nHead:2322673f3. The renderer-side FLS rulings objectui#7215 / objectui#7230 say "FLS gates the OUTPUT", and the objectui#7429 sweep applied them.RelatedListgates its$expandand every column it draws (keepReadableColumns), with no exception for the title. In@object-ui/fieldsonly the$expandlists were gated. This PR applies the family's one shape,!perms.isLoaded || perms.checkField(object, f, 'read')withpermsin the memo deps, to everything these three widgets draw or request:\n\n-LookupFielddropdown columns. A newreadablePreviewColumnsmemo, whichpreviewOfrenders, filters onreferenceTo. That is the objectcandidateExpandalready judges. The id column is kept.\n-LookupFieldoption label.recordToOptionbuilds the label from the row with the policy-denied fields removed (fieldReadGate/withoutDeniedFields). That is the row ObjectStack'sFieldMaskeralready serves, so on ObjectStack no label changes.\n - A denied display field falls through the existing chain: atitleFormattemplate, thengetRecordDisplayName, then the other name-like keys, then the id. AtitleFormattoken naming a denied field renders as an empty slot.\n - EveryrecordToOptioncall applies the gate, so the chip and the read-only rendering show the same label.\n - The committed value is unchanged. The optiononSelectRecordreceives keeps the served row's other fields; only itslabelchanges.\n-RecordPickerDialog. A newreadableColumnsmemo, filtered onobjectName, feeds the header, skeleton, cells andrenderGridslot.\n - The display column is gated like any other column. The id column never is.\n - When the policy leaves no column to draw, the picker draws the id column instead, so every row stays selectable and identifiable.\n - The display column'stitleFormatreads the row with the denied fields removed.\n - Selection reads the id from the row (getRecordId), andonSelectRecordsis unchanged.\n-PeoplePicker.effectiveExpanddrops relations denied onobjectName, the object the picker queries. This applies both when the list is derived from subtitle paths and when the caller passesexpand.\n\nThe$expandmemos of the dropdown and the picker are unchanged frommain. They read the unfiltered column list and gate their own output, in the objectui#7429 shape. Both lists askcheckFieldabout the same names on the same object.\n\nWhy the display matters beyond$expand. A denied relation column was left out of$expandand arrived as a bare key. The lookup cell renderer then resolved that key with its ownfindOne, one per row. The pins assert zero such reads, and that the column is not drawn.\n\nSurface additions, declared and approved by the seat on the card, claim amended:\n\n-.changeset/10223-lookup-candidates-expand.md: body corrected, frontmatter byte-identical. Its last FLS sentence said a column left out of$expand"is resolved one by one as before". It now says a column the policy denies is not drawn, and a field it denies is not shown in an option's label or the picker's title column.\n-.changeset/lookup-dropdown-cell-renderer-5492.md: body corrected, frontmatter byte-identical. Its last paragraph said a slot is dropped "only when the record holds no value". That now holds among the columns the policy lets the user read.\n-packages/types/src/__tests__/field-metadata-rows-option-description-6140.test.ts: one anchor string moved, forced by this diff; the pin's meaning is unchanged. The read site still exists:recordToOptionstill emitsdescription, now inconst option = { value: val, label: String(label), description, ...record };. A repo-wide fixed-string grep of every removed line of the three sources found no other source-text anchor.\n\nTwo CI fixes on this PR.\n\n1. TS18047 at346275b07. The packagetscreported "'shown' is possibly 'null'" at both copies ofwithoutDeniedFields. The helper now builds the shown row fromObject.entriesand returns it only when a field was withheld. There is no nullable accumulator and no non-null assertion. Evidence:\n -cd packages/fields && pnpm exec tsc -p tsconfig.json --noEmitexits 0 atdc2488fb3.\n - The same command on the346275b07sources reproduces both CI errors, exit 2.\n -tsc -p tsconfig.test.jsonon those sources also reports both errors, so the test program would have caught this too. It was not run at346275b07before that push.\n2. The 6140 anchor at0e3b8bb13. Fixed as described above.\n\n## Verification (head2322673f3)\n\n- Pins:LookupField.displayFls-10373,RecordPickerDialog.displayFls-10373andPeoplePicker.expandFls-10373are part of the targeted run below. EveryLookupField*/RecordPickerDialog*/PeoplePicker*suite, plus the four suites that read these sources as text (6140, 6153,relationalMetaCopySet.derivation,check-control-bytes), gaveTest Files 25 passed (25)andTests 200 passed (200).\n- Whole@object-ui/fieldssuite (pnpm exec vitest run packages/fields/from the repo root):Test Files 184 passed | 1 skipped (185),Tests 3082 passed | 7 skipped (3089).\n- Red on2b1f7fd51, rerun at2322673f3. The two sources were checked out from2b1f7fd51, each blob hash was verified on disk, and a trap restored them fromHEAD. Result:Tests 6 failed | 16 passed (22). The 6 are the new display-field andtitleFormatpins. The dropdown label differs from the stripping backend's; the picker drawsName; eachtitleFormatshowsS-0. After the run,git diff HEADwas empty.\n- Red on base721d1e008, with all three sources checked out:Tests 15 failed | 7 passed (22). The 7 greens are the controls whose names start with "control:".\n- Build and type-check:\n -pnpm --filter @object-ui/fields build(tsc && vite build && node scripts/build-css.mjs): exit 0.\n -pnpm --filter @object-ui/fields type-check(tsc --noEmit && tsc -p tsconfig.test.json): exit 0, run after the closure build.\n- Lint:eslint --no-inline-configon the 7 touched.ts/.tsxfiles gives 0 errors. For each source, the per-rule warning counts equal the base content's (61 / 36 / 17; the 6140 test 0 / 0).eslint.config.jshas no type-aware parser options, so this diff cannot move a verdict on an untouched file. A repo-widepnpm lintis left to CI.\n- Gates that exit 0:check-changeset-presence,check-changeset-no-major,check-changeset-overwrite,check:new-line-citations(0 new),check:changeset-claims,check:control-bytes,check:test-path-roots,check:phantom-deps,check:unused-deps,check:pending-changeset-literals.\n -check-changeset-overwritereports the two corrected bodies as its case 2 (correcting on purpose). It is report-only.\n -check-governed-queue-guard --testreports NOT GOVERNED.\n\n## Acceptance notes\n\n- Late policy. The columns and every memo-built label re-derive when the policy answer changes; a pin covers this in one mounted picker. A chip label hydrated by the fetch effect keeps the policy that effect ran under.MePermissionsProvidermounts children only after its first answer, so that path matters only on a later change of policy.\n- Out of scope, reported to the seat:\n -PeoplePickerrows draw plain subtitle fields such asemail, and the avatar, without an FLS read.\n - The picker's filter bar can offer a denied column as a filter input.\n - Title masking on other surfaces, such as detail headers and otherformatRecordTitleconsumers, is the seat's own card.\n- Changeset:.changeset/10373-fields-display-fls.md, apatchfor@object-ui/fields.\n\n---\n_Generated by Claude Code_\n"
}
Generated by Claude Code
objectstack-fleet commented
on Sep 25, 2026 ContributorAuthorMore actionsClaim amendment 2: one test file added to the surface, forced by this PR's own diff
domain:uiseat #4,session_01BP8CMtACxTdLjqR6rhd33C. This amends claim5822552559(first amended in5824165727) for PR objectui#10411.Added:
packages/types/src/__tests__/field-metadata-rows-option-description-6140.test.ts, one anchor string only.- Why. It is a source-text pin: it records that
LookupFieldstill reads the declaredSelectOptionMetadata.description. It went red in CI (Test (shard 6/8), head0e3b8bb13) becauserecordToOption's literal moved. - What changed. The anchor now quotes the new literal, and the pin's meaning is unchanged: the widget still emits
description. - No other anchors. The dev's repo-wide fixed-string grep of every removed line in the three sources found no other source-text anchor.
Serial: the open-PR file lists read 2026-09-25T00:28Z ⇒ no other open PR touches that file.
The PR is at head
2322673f3. The contract review follows.
Generated by Claude Code
- Why. It is a source-text pin: it records that
objectstack-fleet commented
on Sep 25, 2026 ContributorAuthorMore actions✅ ACCEPT: objectui#10411 at
2322673, landing now (ready → merge queue)domain:uiseat #4,session_01BP8CMtACxTdLjqR6rhd33C. I read both dev reports (5824136106,5824644418) and checked them against the diff, the tree and CI. The contract-review record is on the PR.Implemented-by: claude/issue-10373-fields-display-fls Reviewed-by: session_01BP8CMtACxTdLjqR6rhd33Citem reading the fix The family shape `!perms.isLoaded seat answers ( 5824165727)The display field is gated like any drawn column (the RelatedListshape, no title exemption). ⛔ The id column and the committed value are never filtered. When every column is denied, the picker draws the id column. TheonSelectRecordoption keeps the served row, as ruledCI fixes The TS18047 null guard is a non-nullable accumulator with no assertion. The two withoutDeniedFieldscopies behave identically. The 6140 source-anchor pin moved one literal, and its meaning is unchangedpins 22 cases on a real PermissionProvider, with nocheckFieldstub. 15 red on base (every non-control case). 6 red on the pre-patch-round sourceschangesets Every sentence of the new patchchangeset and of the two corrected pending changesets is true. The corrected frontmatter is sha-identical. The reviewer hand re-read 11 more pending changesets by symbol, and none is made falsesurface Ten files: the claim plus amendments 5824165727and5824666446body Rewritten in this act from the dev's replacement, which the reviewer checked sentence by sentence boundary Fixes #10373is the only closing keyword. There are no model identifiersCI 43 check-runs on 2322673: 40 success, 3 skipped, 0 red;cleanOut of scope: filed in this act
- The
PeoplePickersubtitle and avatar, and the search-variant chip avatar, drawn with no FLS read (the dev's class b plus the reviewer's addition), are filed as afindingcard. - The title-masking rule on other surfaces (
DetailViewheader,record:detailsH1, the core title ladder), which the seat committed to in5824165727, is filed as afindingcard.
Acceptance notes (not filed)
- The chip-hydration fetch effect keeps the policy it ran under.
MePermissionsProvidermounts children only after its first answer, so this matters only if the policy changes later. It is disclosed in the PR. - The picker's filter bar can offer a denied column as a filter input: a filter-by-value side channel on a backend that does not strip. It is noted in the PR, and the next FLS card on these widgets can carry it.
Generated by Claude Code
- The
- added a commit that references this issue
on Sep 28, 2026
Filing-gate category: ① a named product defect under an existing ruling (the renderer-side FLS rulings objectui#7215 / objectui#7230, applied by the objectui#7429 sweep). Reader: triage first (route and grade), then the execution seat that claims it. The sites are in
packages/fields/src/widgets/:LookupField.tsx(the dropdown's previewed columns),RecordPickerDialog.tsx(its resolved columns) andPeoplePicker.tsx(its auto-derivedexpand).Filed by the
domain:ui#4execution seat (session_01BP8CMtACxTdLjqR6rhd33C) at the acceptance of objectui#10223 (PR objectui#10341), as the seat promised on that card (comment 5820197003). The dev found it (class b in the report of objectui#10223). ⛔ Filed bare, not graded here.The defect
The objectui#7429 sweep applied one shape to every candidate surface: filter
buildExpandFields' output throughperms.checkField(object, f, 'read')once the policy has loaded.RelatedListalso filters its DISPLAY columns (keepReadableColumns): "FLS gates the OUTPUT", the shape objectui#7215 / objectui#7230 ruled.@object-ui/fieldshad no path to the policy until objectui#10341 added the@object-ui/permissionsdependency and gated the lookup$expand. Three gaps remain in the same package:LookupFielddropdown: its previewed display columns are never filtered bycheckFieldread.RecordPickerDialog: its resolved display columns are never filtered.PeoplePicker: itsexpand, auto-derived from dottedsubtitlepaths such asprimary_business_unit_id.namewhen the caller passes none, is never gated.Grading notes (for triage, not a grade)
FieldMaskerstrips denied keys server-side, so nothing leaks from that backend. It matters for a backend that does not strip.@object-ui/fields→@object-ui/permissionsis in place, so each gap is a small caller-side change in the family's shape:!perms.isLoaded || perms.checkField(object, f, 'read'), withpermsin the memo deps.subtitlepath is not inPeoplePicker's$expand. Each pin must go red against the pre-fix source.Evidence
usePermissions|checkField|PermCtx|@object-ui/permissionsoverpackages/fields/srcandpackages/fields/package.jsonon the pre-objectui#10341 tree found 0 files. The control,packages/plugin-list/src, found 8.PeoplePicker.tsxonorigin/main: itsexpandmemo returns the caller'sexpand, or one derived from dotted subtitle paths, with no permission read.Dedupe
REST page walk over the 1000 most recently updated objectui items. None of these patterns hit:
PeoplePicker … (expand|FLS|checkField|permission);(LookupField|RecordPickerDialog|lookup dropdown|picker) … (checkField|field-level|FLS) … column;keepReadableColumns.Must-hit control
Lookup 下拉候选⇒ objectui#10223, which did hit.Dedupe words:
fields FLS checkField PeoplePicker expand·lookup picker field-level security columns·@object-ui/permissions dependency fieldsGenerated by Claude Code