…lds the viewer may read (objectui#10501)
`resolveLookupRecordName` resolved the referenced record's display name
(displayField -> nameField -> titleFormat -> derivation) on the row as
served, so on a backend that does not strip denied keys a `titleFormat` or
`nameField` naming a policy-denied field printed that value in every lookup
cell. It now resolves from the row with the fields the loaded policy denies
on the referenced object removed (`id`/`_id` kept) -- the rule the lookup
editor's option label (objectui#10411) and the record title
(objectui#10434) already apply. `policy` is a required parameter, so every
caller (expanded record, JSON-encoded reference, multi-value chips and the
overflow title, the fetch-on-demand path) gets the gated row.
The fetch-on-demand cache now holds the fetched record and schema instead
of a resolved name, and the name is resolved per render: a policy that
loads or changes after the record arrived relabels the mounted cell with
no second read.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BA3nKVUwKQJf8DBxrSVtNC
Fixes #10501
Clause-②: no
What changes
LookupCellRenderernames the record a lookup points at through the referenced object's schema:displayField, thennameField, thentitleFormat, then the type-aware derivation. It did that on the row as served. On a backend that does not strip policy-denied keys, atitleFormatornameFieldnaming a denied field printed that field in every lookup cell.resolveLookupRecordNamenow resolves from the row with the fields the loaded policy denies on the REFERENCED object removed.idand_idare kept. This is the objectui#10411 / objectui#10434 rule, so the ladder falls through exactly as it does for the row a stripping backend (FieldMasker) serves.resolveLookupRecordName. So every caller gets the gated row, and the compiler refuses a new caller that leaves it out. The callers are the expanded record, the JSON-encoded reference, each multi-value chip and the overflow chip'stitle, and the fetch-on-demand path.withoutDeniedFields(record, policy, objectName), inpackages/fields/src/index.tsx.LookupField.tsx's helpers are not in reach: they are module-private in a file the package entry re-exports whole (export * from './widgets/LookupField.js'). Reaching them would mean editingLookupField.tsxand adding a new internal module, and both are outside the claim's file surface. See the census below for the consolidation question.pnpm check:unreferenced-sourcesandcheck:phantom-depsare green.@object-ui/permissionswas already a declared dependency, and it is already in the entry's static graph throughLookupField.tsx.Route change, measured: "
permsin the effect's deps" would not relabelThe triage and the claim put
permsin the resolving effect's deps. I measured that mechanism before building it, and it does not relabel. The fetch-on-demand hook cached the resolved NAME in a module-level map, and its effect returns early on a settled entry. A policy change re-runs the effect, the effect returns at once, and the stale name stays.So the cache now holds the fetched record and schema, and the name is resolved on every render with the render's policy. That is the shape objectui#10487 (PR objectui#10557) gave the lookup editor's hydrated chip. A policy that loads or changes after the record arrived relabels the mounted cell with no second read. The effect only fetches, so it has no
permsdependency to carry. The ablation M2b below is the evidence: I put the name back in the effect WITHpolicyin its deps, and the relabel pin went red.The cache key still carries
displayField(objectui#2926 ⑧). It is no longer needed now that the entry holds a record, but dropping it would change how many reads a screen makes, and this card does not make that change.Reproduction (before the fix)
I wrote the new pin first and ran it on base
8740e86ce: 7 failed, 2 passed. The 2 that passed are the controls. Each denied-field row failed on the served value, for example:(The tag in the received line is spelled as a word so the text survives this page.)
The pin —
packages/fields/src/__tests__/lookupCell.readGate-10501.test.tsxThe tests use the real
PermissionProviderand a backend that does not strip. Each test uses its own referenced object, because the caches are module-level.titleFormatnames a denied field prints the name, and the text is the same as the text a stripping backend's row gives.nameFieldis denied prints the name.titleprints a denied value.isLoadedfalse), the row is named as served.findOnewas called once: relabelling reads the policy again, not the backend.Ablations: mutated on disk, restored by blob
Each leg ran on committed
HEAD4c1bad224. The mutation went throughablation-replace.mjs(anchor must hit, blob must change), was checked with an on-diskgrep -cmarker, and was restored fromHEAD. The restore was proven by blob9e540c8cffee==HEADblob and an emptygit diff HEAD. The test imports../index(source), so nodistis involved.if (trueor the rest)policyin the effect deps (the triage's mechanism)title="ada@example.com"Verification
All the checks below ran at final
HEADa0885627b, from the repo root. The closure was built first withturbo run build --filter=@object-ui/fields^....pnpm exec vitest run packages/fields/: 201 files passed, 1 skipped; 3315 tests passed, 7 skipped. The pin alone: 9/9.pnpm --filter @object-ui/fields run type-check(tsc --noEmit && tsc -p tsconfig.test.json): exit 0.tsc -p tsconfig.test.json --listFilesincludes the new test file (count 1).--no-inline-config --format json(2 files linted, 0 errors):index.tsx: the per-rule counts are identical to base8740e86ce:react-refresh/only-export-components93,no-explicit-any44,no-unused-vars1.react-hooks/exhaustive-depsis 0 before and 0 after.tseslint.configs.recommended, noparserOptions.project), so this diff cannot move the verdict on any untouched file.check:control-bytes;check:new-line-citations(0 new);check-changeset-presence(1 changeset,@object-ui/fieldspatch);check-changeset-no-major;check-changeset-overwrite;check-type-check-coverage;check:phantom-deps;check:esm-specifiers(specifiers only);check-test-path-roots;check-vi-mock-override-shape;check:self-import;check:unreferenced-sources.check:changeset-claimsflags 4 pending changesets that namepackages/fields/src/index.tsx: 6625, 6694, 6837 and 7166. I read each paragraph. None is falsified: this diff adds no field-meta read and does not touchdecimals.@object-ui/permissionswas already in the entry's static graph throughLookupField.tsx.Census: every copy of "the row as the viewer may read it" (for the seat's decision)
The claim asks for this census and bars an export on this card. Five copies are on
mainafter this PR, and a sixth is in open PR objectui#10570.packages/fields/src/widgets/LookupField.tsxfieldReadGate+withoutDeniedFields(record, readable)export *'d by the entry)id,_id, declaredidFieldusePermissions()packages/fields/src/widgets/RecordPickerDialog.tsxwithoutDeniedFields(record, perms, objectName, idField)export *'d by the entry)id,_id,idFieldusePermissions()packages/fields/src/index.tsx(this PR)withoutDeniedFields(record, policy, objectName)id,_idusePermissions(), typed as the Pick ofisLoaded/checkFieldpackages/plugin-detail/src/withoutDeniedFields.tswithoutDeniedFields(record, perms, objectName)id,_idusePermissions(), the same Pickpackages/app-shell/src/views/RecordDetailView.tsxwithoutDeniedFields(record, perms, objectName)id,_idusePermissions(), the same Pickpackages/react/src/hooks/useRecordSearch.ts(open PR objectui#10570, not onmain)readableRow(record, objectName, policy)id,_idfieldReadPolicy(react does not depend on permissions)The six share one body: return the row unchanged unless the policy is loaded and an object is named. Keep
id,_idand any extra identity key. Drop every key for whichcheckField(object, key, 'read')is false. Return the SAME object when nothing is withheld.One pure export would replace all six. Its signature would be
(record, policy, objectName, extraKeep?), wherepolicyis structural and needs onlyisLoadedandcheckField. The differences it has to absorb are theidFieldextra key in two fields copies, the closure split inLookupField, and the argument order in react.The dependency facts that decide where the export lives:
@object-ui/permissionsis a runtime dependency of app-shell, fields, plugin-calendar, plugin-dashboard, plugin-detail, plugin-form, plugin-gantt, plugin-grid, plugin-kanban, plugin-list, plugin-map, plugin-timeline, plugin-tree and plugin-view.apps/consolelists it only as a devDependency.@object-ui/reactdoes not depend on it.@object-ui/permissionsdepends only on@object-ui/types, so adding it to react would not create a cycle.@object-ui/coreis already a dependency of fields, plugin-detail, app-shell and react. A structural-policy export there would need no new edge anywhere. An export from@object-ui/permissionswould need react to add one edge.A different shape is not covered by a row export: the column/field-LIST filters. These are
PeoplePicker's$expand, subtitle and avatar filters,LookupField'sreadablePreviewColumnsand expand list, and thekeepReadableColumnsfamily inRelatedListand the grids. They filter a list of names, not a row.Acceptance notes
UserCellRenderernames an embedded user fromname/username/imageoff the row as served. It does not go through a schema or a policy. A policy that deniesnameon the user object would still print it. I did not probe this: there is no repro and no claim that such a policy exists. Terms a later search could use:UserCellRendererdenied nameanduser cell FLS.displayField(see above). Dropping it would save one read when two columns point at the same record with different display fields. That is a fetch-count change, not a correctness change.Overlap
MaskedCellRenderer/buildStandardCellRendererMap). This PR touches only the lookup resolver,useLookupNameandLookupCellRenderer, so the hunks are disjoint.ImageCellRenderer): this PR does not touch it.This body was written by
https://claude.ai/code/session_01BA3nKVUwKQJf8DBxrSVtNC(thedomain:uiseat 1 dispatch).Generated by Claude Code