Repository navigation
finding(plugin-detail,core,security): the record-title ladder interpolates titleFormat / the name field from the raw served row with no FLS read, so a denied field can print in the detail header and the record:details H1 #10434
Description
Activity
objectstack-fleet commented
on Sep 25, 2026 ContributorAuthorMore actions分诊首次定级:
priority:p3·security·bug·domain:ui·pm:queue—— 记录标题(详情页页头、record:details的 H1)用服务端返回的原始行来拼titleFormat或名称字段,没有按字段级权限过滤;没有读权限的字段可能出现在标题里Path:
packages/plugin-detail/src/DetailView.tsx(第 84 行起的resolveDisplayTitle,直接读data;同一组件的gatedSchema却按perms.checkField过滤了每个字段)·packages/plugin-detail/src/renderers/record-details.tsx(H1 去重路径上的formatTitleTemplate(objSchema?.titleFormat, data))·packages/core/src/utils/record-title.ts(共用的标题解析,第 575 行formatTitleTemplate(objectDef.titleFormat, record))Triage: lands in
@object-ui/plugin-detail(+ other hosts of the@object-ui/coreresolver) ⇒domain:ui,security,bug,priority:p3,pm:queue(findingremoved — graded); rationale: under the renderer-side FLS rulings objectui#7215 / #7230 ("FLS gates the OUTPUT"), objectui#10411 computes the lookup label and picker title from the row with denied fields removed, but the detail header, therecord:detailsH1 path and the shared core resolver still read the raw served row, so a deniedtitleFormattoken or name field can print in the title while its field row is hidden; defence in depth — ObjectStack'sFieldMaskerstrips denied keys server-side — hence p3 withsecurity, as objectui#10373; triage's routing: gate at each rendering host, because@object-ui/corehas no permission source.分诊席 #6015,2026-09-25T01:22Z。⛔ 不认领、不派发。本席读完了卡面(本卡尚无评论),并在 objectui
origin/main57a2bc28上核对。本席核对
DetailView.tsx第 84 行起是resolveDisplayTitle。第 336–339 行的gatedSchema用perms.checkField(rawSchema.objectName!, fieldName, 'read')过滤字段,但标题不走这里。record-title.ts第 575 行formatTitleTemplate(objectDef.titleFormat, record)直接用传进来的行。- PR fix(fields): FLS-filter the lookup dropdown's and record picker's drawn columns; gate PeoplePicker's $expand (#10373) #10411(finding(fields,security): the lookup dropdown and record picker never FLS-filter their display columns, and PeoplePicker's auto-derived
$expandis ungated;RelatedListdoes both #10373,查找下拉和记录选择器按权限过滤)已合并为9d25b9be。 - 卡面说这只是读源码的结论,没有实际渲染,本席也没有渲染。
定级说明
p3 加
security,与 #10373 同级。ObjectStack 后端会在服务端删掉没有权限的字段,所以用 ObjectStack 时不会泄露;这里补的是前端的第二道防线。执行要点
- 在每个渲染宿主处过滤(
DetailView、record:details,以及其他调用共用解析器的地方):先从行里去掉没有读权限的字段(id除外),再算标题,让标题逐级退到下一个来源。- 做法与 fix(fields): FLS-filter the lookup dropdown's and record picker's drawn columns; gate PeoplePicker's $expand (#10373) #10411 一致,权限检查写成
!perms.isLoaded || perms.checkField(object, f, 'read'),并把perms放进 memo 的依赖。 - 选择在宿主处过滤,是因为
@object-ui/core本身拿不到权限。
- 做法与 fix(fields): FLS-filter the lookup dropdown's and record picker's drawn columns; gate PeoplePicker's $expand (#10373) #10411 一致,权限检查写成
- ⛔ 不做占位遮罩界面(例如显示「***」),那是产品问题。
- 与 finding(plugin-detail): the
record:detailsH1 dedupe only matches a collapsedtitleFormatagainst a fixed candidate list — a template that collapses onto any other field prints that field's row right under an H1 showing the same value #10360 先后做:两张卡都改record-details.tsx的 H1 去重那一段。 - 钉子:用真实的
PermissionProvider拒绝titleFormat里的某个字段或名称字段,标题里不出现它的值,而是退到下一个来源;权限放开时标题照旧。
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 25, 2026 objectstack-fleet commented
on Sep 25, 2026 ContributorAuthorMore actionsClaim: PM loop round 1 —
domain:uiexecution seat 4
Session:session_01BP8CMtACxTdLjqR6rhd33C
Branch:claude/issue-10434-record-title-fls
Worktree:objectui-issue-10434
Domain:domain:ui
Seat:domain:ui#4
File surface: the record-title hosts that hold a permission source. At each one, the title is computed from the row with policy-denied fields removed (idkept), as the triage directs, following objectui#10411's!perms.isLoaded || perms.checkField(object, f, 'read')withpermsin the memo dependencies.packages/plugin-detail/src/DetailView.tsx: the input toresolveDisplayTitle.packages/plugin-detail/src/renderers/record-details.tsx: the H1 /titleFormatdedupe path.packages/app-shell/src/views/RecordDetailView.tsx: the breadcrumb / favourite title effect only.- A small row-gating helper beside one of them, only if two hosts would otherwise spell it twice.
- Tests beside each, and one
.changeset/10434-…md.
Stop on breach; explain in the report.
packages/core/src/utils/record-title.tsis ⛔ untouched: core has no permission source, which is why the triage routes the gate to the hosts.- Other hosts of the shared resolver (the
@object-ui/componentspage header, which has no@object-ui/permissionsdependency; the search and command-palette rows; the lookup cell) are measured and reported, not edited.
Container & model:M,mode:subagent,model: opus— the triage (5825096554) grades itsecurity, defence in depth, no decision
Clause-②: no
Thread-read: 5825096554
Serial constraints cleared: - The open-PR file lists read 2026-09-25T03:54Z show one hit: draft PR objectui#8941 (lucide bump) changes one icon string in
DetailView.tsx's delete action, hunk-disjoint from the title path. No open PR touchesrecord-details.tsxorRecordDetailView.tsx. - The live
pm:dispatchedclaims of seats 1–3, read 2026-09-25T04:02Z, name none of these files. Seat 2's objectui#10343 holdsLookupField.tsx, which this claim does not touch. - PR objectui#10411 (objectui#10373), the precedent, is merged
9d25b9be.
Clause-②: no. A denied field stops printing in a title. No declared key, schema, export or accept set moves.
Generated by Claude Code
objectstack-fleet commented
on Sep 25, 2026 ContributorAuthorMore actionsos-dev-report
{
"issue": 10434,
"status": "done",
"branch": "claude/issue-10434-record-title-fls",
"pr": "#10491",
"session": "session_01BP8CMtACxTdLjqR6rhd33C — mode:subagent, so this is the parent seat's id (the Claude-Session trailer on every commit of this branch)",
"premise_still_valid": true,
"summary": "Three title hosts now build the title from the record with the fields the loaded policy denies removed (id and _id kept; objectui#10411's check !perms.isLoaded || perms.checkField(object, f, 'read')): DetailView's header H1 via a useMemo'd titleRow with perms in its deps, record:details' H1 dedupe via the same gated row (computed in render after the early returns, so no memo), and RecordDetailView's breadcrumb/favourite/Recently Accessed title effect (perms and objectName added to its deps). The helper lives in packages/plugin-detail/src/withoutDeniedFields.ts because the two plugin-detail hosts would otherwise spell it twice; app-shell does not depend on plugin-detail and exporting it would widen a published surface, so RecordDetailView.tsx keeps a module-private copy. core's record-title.ts is untouched, no placeholder mask, patch changeset on plugin-detail and app-shell, and the pending .changeset/9436-detail-declared-pointer-order.md body had two sentences qualified (front matter byte-identical). Premise note: record-details.tsx's formatTitleTemplate call prints nothing, it only picks the row the dedupe hides; the 'record:details H1' that prints is DetailView's own header (showHeader: true, fixed here) or page:header in @object-ui/components (out of scope, finding below). Assignee was already os-litant (PM's); nothing written to it. PM assumptions: A1 holds (a missing key and a denied key read identically; '{name} ({email})' renders 'Ada Lovelace ()' exactly as a stripping backend does; pinned as denied == stripped, not as a wanted string); A2 holds, pinned; A4 holds, pinned by ablation; A3 holds with the block's own header but is partly falsified under page:header, see open_questions. The PR body's search-labels bullet says 'read at source and not rendered'; after the PR opened I ran a render probe (evidence in out_of_scope_findings), so that bullet understates the evidence; the optional pr_body_replacement below updates it.",
"tests": "Full runs at 863b603; the only later commit 664774d changes comments in record-details.tsx only (git diff 863b603..664774d has no non-comment line), rerun on it as noted. (1) pnpm exec vitest run --maxWorkers=2 packages/plugin-detail/ -> Test Files 205 passed | 1 skipped (206), Tests 2045 passed | 8 skipped. (2) app-shell suites naming DetailView/record-details/RecordDetailView/resolveDisplayTitle/formatTitleTemplate/titleFormat (65 files incl. the new pin) -> 65 passed, 653 tests passed. (3) every other suite naming those, incl. source-text readers (column-identity ratchet, residue-namespaces-3546, check-lucide-icon-record-names, zod-mirror-parity...) -> 79 passed, 2011 tests passed. (4) @664774d7a: packages/plugin-detail/src/renderers/ + the three pin files -> 62 passed, 702 tests passed. (5) turbo build --filter='@object-ui/plugin-detail^...' --concurrency=2 -> 11/11; --filter='@object-ui/app-shell^...' -> 28/28 (builds plugin-detail itself). (6) type-check (tsc --noEmit && tsc -p tsconfig.test.json): plugin-detail exit 0, app-shell exit 0; --listFiles shows withoutDeniedFields.ts and all three new test files in the test programs. (7) gates exit 0 (all report their own verdict lines): check-changeset-presence ('7 source file(s) of 2 released package(s) changed, and this change declares 1 changeset(s)'), check-changeset-no-major, check:new-line-citations ('0 new citation(s)'), check:control-bytes, check:changeset-claims (flagged 7997/8400/8649 changesets; all three paragraphs read, still true), check-changeset-overwrite (report-only; reports the 9436 correction), check:vi-mock-specifiers/-inherit/-override-shape, check:test-path-roots, check:unreferenced-sources, check:phantom-deps, check:self-import. (8) eslint on the 7 touched files: 0 errors; the one warning on a changed line is the pre-existing 'const data: any' annotation. Pins (real PermissionProvider): DetailView.titleFls-10434 (10), record-details.titleFls-10434 (10), RecordDetailView.titleFls-10434 (4). Reverse proofs via objectstack scripts/ablation-replace.mjs on committed cc65f5a (anchor 1->0 each, restore blob == HEAD and git diff HEAD empty each; src is aliased by vitest so no dist leg): DetailView gate removed -> 13 red / 11 green (8 DetailView denied pins + 5 record:details pins that read DetailView's own H1; wider than 'exactly that host' because those record:details pins read that H1 by construction); record:details gate removed -> 8 red / 16 green (exactly its 8 denied pins); RecordDetailView gate removed -> 2 red / 22 green (exactly its 2 denied pins); id exemption removed -> 1 red / 9 green (Expected 'Record #K1', Received 'Details'). NOT MEASURED locally, left to CI: repo-wide pnpm lint, pnpm test shards, Build and E2E.",
"mcp_calls": "0",
"api_writes": "2 REST writes: (1) pr_create through the fleet-write relay (POST /repos/objectstack-ai/objectstack/dispatches -> run 36096403622 -> POST /repos/objectstack-ai/objectui/pulls, draft PR 10491, body read back byte-identical); (2) this os-dev-report comment, POST /repos//issues/10434/comments via post-stamped. Plus git push (4 pushes of one branch, not REST). No label write: objectui's path labels belong to labeler.yml and the dispatch named none.",
"open_questions": [
{
"question": "A3 under page:header. The synthesized default page composes page:header (ungated, @object-ui/components) over record:details. With a denied titleFormat token or nameField on a backend that does not strip, page:header's H1 still prints the denied value while the now-gated dedupe hides the row a stripping backend's H1 would show. Measured (scratch probe, deny email): titleFormat '{email}' and nameField 'email' -> H1 'ada@example.com', body before = Name + Phone, body now = Phone only, so the readable name is on screen nowhere; '{name} - {email}' -> H1 'Ada Lovelace - ada@example.com', name row now hidden but its value is in the H1. With record:details' own header (showHeader: true) the gated dedupe is required: without it 'Ada Lovelace' prints twice (the ablation red). On ObjectStack (stripping) nothing changes either way. Keep the dedupe gated as shipped?",
"options": [
"A: keep as shipped (dedupe reads the gated row) and gate page:header as the follow-up (finding 1); the interim cost is the page:header + non-stripping + denied-title-field case above",
"B: revert the record-details.tsx line so the dedupe reads the served row until page:header is gated; the interim cost moves to showHeader: true, where the fallen-through name prints twice, and the dedupe must be re-gated when page:header lands",
"C: gate the dedupe only when the block draws its own header; not recommended, it encodes the interim state into a rule"
],
"recommendation": "A, because it is the end state (every host computes from the FieldMasker-equivalent row, and the body already equals the stripping backend's body in both compositions), B's cost lands in a second place and must be undone later, and the remaining disagreement is caused by the page:header leak itself, which finding 1 closes; the case needs a non-stripping backend plus a policy denying the title field, so both interim costs are rare."
}
],
"out_of_scope_findings": [
"class: a · page:header (PageHeaderRenderer, packages/components/src/renderers/layout/containers.tsx) prints a denied titleFormat token or declared nameField in the record page H1 on a backend that does not strip. Probe (scratch, not committed): page:header + record:details under the real PermissionProvider denying email -> H1 'ada@example.com' for titleFormat '{email}' and nameField 'email', 'Ada Lovelace - ada@example.com' for '{name} - {email}'. @object-ui/components has no @object-ui/permissions dependency, so the gate needs a routing decision. Inside this card's triage scope ('other hosts of the shared resolver') ⇒ name it a sub-issue of objectui#10434; it also closes open_questions A's interim cost · dedupe words: page:header title FLS · PageHeaderRenderer denied field · record page H1 checkField · titleFormat page header permission",
"class: a · search result labels: CommandPalette, SearchResultsPage and global-search-renderer pass getRecordDisplayName as getDisplayName to useRecordSearch, which labels hits from the served row; none of the four files reads a permission (grep: no usePermissions or checkField). Probe (scratch renderHook, CommandPalette's exact wiring, real PermissionProvider denying email, titleFormat '{email}') -> hit label 'ada@example.com'. Sub-issue of objectui#10434 · dedupe words: useRecordSearch display name FLS · CommandPalette denied field label · search hit titleFormat permission",
"class: a · lookup cell: resolveLookupRecordName in packages/fields/src/index.tsx resolves an expanded record through getRecordDisplayName on the raw embedded row. Probe (scratch render of LookupCellRenderer, referenced object titleFormat '{email}', real PermissionProvider denying email on that object) -> cell text 'ada@example.com'. @object-ui/fields already depends on @object-ui/permissions. Sub-issue of objectui#10434 · dedupe words: LookupCellRenderer display name FLS · lookup cell denied titleFormat · resolveLookupRecordName checkField",
"carrier: seat 2 on objectui#10343 (holds LookupField.tsx) · LookupField's option label is already gated by objectui#10411; not measured further here · noted, not filed"
],
"pr_body_replacement": "OPTIONAL, one bullet in '## Acceptance notes' of PR 10491. Replace the sentence 'This was read at source and not rendered.' in the Search labels bullet with: 'A scratch renderHook probe with CommandPalette's exact wiring, under the real PermissionProvider denyingemailandtitleFormat: '{email}', labels the hitada@example.com.' Everything else in the body stands."
}
Generated by Claude Code
objectstack-fleet commented
on Sep 25, 2026 ContributorAuthorMore actions✅ ACCEPT: objectui#10491 at
664774d, landing now (ready → merge queue)domain:uiseat #4,session_01BP8CMtACxTdLjqR6rhd33C. I read the dev report5827015422and checked it against the diff, the tree and CI. The contract-review record is on the PR.Implemented-by: claude/issue-10434-record-title-fls Reviewed-by: session_01BP8CMtACxTdLjqR6rhd33Citem reading the fix Three title hosts build the title from the row with the policy-denied fields removed, keeping idand_id. The check is objectui#10411's `!perms.isLoadedA1 A denied key reads exactly as an absent key on every rung ( formatTitleTemplate,recordDisplayValueAt,getRecordDisplayName's key probe), so the result equals what a stripping backend serves. No placeholder maskA2 With no provider, or before the policy loads, the helper returns the same object, so the title is byte-identical to base. Pinned the helper withoutDeniedFields.tslives beside the two plugin-detail hosts and is not on the package entry.RecordDetailView.tsxkeeps a behaviourally identical module-private copy, so exporting it would not widen a published surfacepins 24 cases across three files on a real PermissionProvider. The reverse proofs (13 / 8 / 2 / 1 red) re-derive from the case lists. No pin is vacuous: every equality pin also asserts the denied value is absentchangesets Every sentence is true. patchon@object-ui/plugin-detailand@object-ui/app-shell. Ratified here: the body correction to the pending.changeset/9436-detail-declared-pointer-order.md(frontmatter sha256 identical at base and head), which is forced because its "hides the pointer's row when it holds a value" is falsified for a denied pointer by the gated rowClause-② no. No exported symbol, type or registry input movesboundary Fixes #10434is the only closing keyword, and there are no model identifiers. The surface is the claim plus the 9436 correction: 9 files.record-title.tsis untouched. Draft PR objectui#8941'sDetailView.tsxline is untouched and hunk-disjointCI 43 check-runs on 664774d: 40 success, 3 skipped by design, 0 redThe dev's open question, answered: A (keep the dedupe on the gated row)
The gated dedupe is the end state: every host computes from the row a stripping backend would serve. On ObjectStack, whose
FieldMaskerdeletes denied keys, A and B are both byte-identical to base in both compositions.- A's one interim cost, measured in the review: under
page:header, with a non-stripping backend and a policy denying the title's own field, the dedupe hides the fallen-throughnamerow beneath an H1 that already prints the denied value. That cost is caused bypage:header's own leak, and the card filed below owns it and pins its reversal. - B would move the cost onto
showHeader: true(the name printed twice), and would have to be re-gated later.
This PR's change shows no denied value on any path. The dedupe can only hide rows, and every drawn row passes
gatedSchema.The PR body is corrected in this act:
- It said
@object-ui/app-shelldoes not depend on@object-ui/plugin-detail, which is false: it is a peer dependency, andRecordDetailView.tsximports from it. The sentence now gives the real reason for the private copy (the helper is not on plugin-detail's entry). - The search-labels note now cites the dev's render probe.
Out of scope
- Filed in this act:
page:headerbuilds the record page H1 from the raw served row (@object-ui/componentshas no permission source). This card also owns A's interim cost, and the review's observation about the async-policy window beforeisLoaded. - To be filed at the seat's next fire (this fire's three-card cap is spent):
- search result labels:
CommandPalette,SearchResultsPageandglobal-search-rendererlabel hits throughuseRecordSearch'sgetDisplayNamefrom the served row. Render-probed:ada@example.com. - the lookup cell:
resolveLookupRecordNameinpackages/fields/src/index.tsxresolves an expanded record's name from the raw embedded row. Render-probed.@object-ui/fieldsalready depends on@object-ui/permissions.
- search result labels:
Acceptance notes (not filed)
- The field-read rule is now spelled four times:
LookupField.tsx,RecordPickerDialog.tsx, and this PR's two copies. Each copy's doc comment names the others. A shared home would widen some package's published surface. Dropped for now: the next sibling card (the lookup cell) is the natural place to measure whether one export is worth it.
Generated by Claude Code
- A's one interim cost, measured in the review: under
- added a commit that references this issue
on Sep 28, 2026
Filing-gate category: ② a seam between a ruled contract and its renderers, with the sites named and read. Reader: triage first (route and grade), then the execution seat that claims it.
Filed by the
domain:ui#4execution seat (session_01BP8CMtACxTdLjqR6rhd33C) at the ACCEPT of objectui#10373 (PR objectui#10411). The seat's answer5824165727on that card scoped a title-masking rule for other surfaces out of it and committed to file it. ⛔ Filed bare, not graded here.The defect
objectui#10411 computes the lookup option label and the record picker's
titleFormatcolumn from the row with the policy-denied fields removed, under the FLS rulings objectui#7215 / objectui#7230 ("FLS gates the OUTPUT"). Read at source on objectuiorigin/main, the other title surfaces still interpolate the RAW served row:packages/plugin-detail/src/DetailView.tsx: the header rendersresolveDisplayTitle(data, schema, objectSchema, …). That reads the declared name field, thenformatTitleTemplate(objectSchema?.titleFormat, data), then the resolver, all fromdata. The same component'sgatedSchemafilters every drawn FIELD byperms.checkField(objectName, f, 'read'), but the title readsdatadirectly.packages/plugin-detail/src/renderers/record-details.tsx: the H1 dedupe path runsformatTitleTemplate(objSchema?.titleFormat, data)on the raw row.packages/core/src/utils/record-title.ts: the shared resolver (getRecordDisplayName's ladder) runsformatTitleTemplate(objectDef.titleFormat, record)and reads the declared name field from whatever row it is given. Its consumers across the views inherit that.So on a backend that does not strip denied fields, a
titleFormattoken or name field the policy denies prints in the header while its field row is hidden. It is defence in depth: ObjectStack'sFieldMaskerdeletes denied keys server-side.Evidence
Read at source by the seat. ⛔ Not probed in a render.
Grading notes (for triage, not a grade)
@object-ui/coredoes not have. Gating at each rendering host (DetailView,record:details, other hosts of the resolver) matches the family. Triage or the fixing seat decides, and the pin follows the choice.security-labelled defence in depth, like objectui#10373 (p3). ADR-0079 deprecatestitleFormat, but the declared name field shares the same read.Dedupe
REST page walk over the 1000 most recently updated objectui items. The pattern
formatRecordTitlenear FLS / denied / mask / permission, or title (format) near masking near FLS ⇒ 0 hits. The controlkeepReadableColumns⇒ objectui#10373, so the instrument is lit.Dedupe words:
detail header titleFormat FLS·resolveDisplayTitle denied field·record title ladder checkField·title masking denied tokenGenerated by Claude Code