Repository navigation
fix(app-shell): hand the record page the row the viewer may read, so page:header's H1 stops printing denied fields (objectui#10499) - #10602
Conversation
…page:header's H1 stops printing denied fields (objectui#10499) RecordDetailView now hands RecordContext the loaded record with the fields the loaded policy denies removed (id kept), the same row its breadcrumb title already read (objectui#10434). page:header builds its H1 from that row, so a denied titleFormat token or nameField reads as absent and the title ladder falls through. The gated copy is cached on the served row and its kept keys, not on a useMemo identity. Co-Authored-By: Claude <noreply@anthropic.com>
…me gated row (objectui#10499) Co-Authored-By: Claude <noreply@anthropic.com>
…ct, keyed on the served row (objectui#10499) A render-time ref cache drew react-hooks/refs warnings; a module WeakMap keyed on the served row object keeps the same identity guarantee without reading a ref during render. Co-Authored-By: Claude <noreply@anthropic.com>
…ge-header-title-read-gate
|
changeset-claim-re-read
|
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
…on the record page now (objectui#10499) Co-Authored-By: Claude <noreply@anthropic.com>
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
Fixes #10499
Clause-②: no
What changed
page:headerbuilds the record page H1 fromRecordContext.data, and@object-ui/componentshas no permission source. The host now hands that context the row the viewer may read, so the header needs no permission read of its own and no new copy of the field-read rule exists.RecordDetailViewcomputesreadablePageRecord: the loaded record passed through the module-privatewithoutDeniedFieldsit already had (objectui#10434), the objectui#10411 rule!perms.isLoaded || perms.checkField(object, f, 'read'),idand_idkept. That is the row ObjectStack'sFieldMaskeralready serves.RecordContextProvidergetsdata={readablePageRecord}, and the record-title effect (breadcrumb, favourite, "Recently Accessed") reads the same value instead of calling the helper itself.readableRowcaches the gated copy in a moduleWeakMapkeyed on the served row object and its kept keys. It is not auseMemoidentity (AGENTS.md [WIP] Enhance every detail of the designer #10), so a re-render never republishes an equal row to every block as a new object.containers.tsxis untouched. No export, schema,RecordContextmember or accept set changes, which is whyClause-②: no.Route measured (PM hypothesis 1 held)
The three routes the triage named:
RecordContextValuemember in@object-ui/reactplus a gate inside@object-ui/components. That gate would be another copy of the rule, or a new shared export while objectui#10501 may decide one. Not taken.@object-ui/permissions. The same second-copy problem applies, andpermissionsexports no row gate today. Not taken.Blast radius (measured, not assumed)
The row feeds every block of the record page, not only the header. Every consumer of
RecordContext.datanow reads a denied field as absent:page:header(title,title/subtitleinterpolation, action predicates),record:details,record:highlights,record:path,record:alert,record:quick_actions,LineItemsPanel, andSchemaRenderer'srecord.*binding for node predicates.This only moves anything when all three hold: a policy has loaded, it denies a field, and the backend served that field anyway. On a stripping backend, the row is the same object.
Measured on the real page, as
document.body.textContenton a non-stripping backend withemaildenied andtitleFormat: '{email}':ada@example.comappeared twice, in the H1 and in the defaultrecord:highlightsstrip. That strip gates by field only withenforceFieldSecurity: true.Ada Lovelaceappeared 0 times.ada@example.comappears 0 times.Ada Lovelaceappears once, as the H1.The ActionProvider record,
InlineEditSaveBar,RelatedRecordActionsBridgeand the header Edit/Delete predicates still read the servedpageRecord. They are not part of the page hand-off and are unchanged.Pins
New file:
packages/app-shell/src/views/RecordDetailView.pageHeaderTitleFls-10499.test.tsx. It renders the realRecordDetailView(default composition,page:headeroverrecord:details) with a realPermissionProviderdenyingemail, on a non-strippingfindOne.titleFormat: '{email}': the H1 readsAda Lovelace, and the email is nowhere on the page.nameField: 'email': the H1 readsAda Lovelace. The resolver walks past the withheld pointer to its derivation, which is the stripping-backend answer.titleFormat: '{name} - {email}': the H1 readsAda Lovelace, and the email is nowhere on the page.Ada Lovelaceappears exactly once, in the H1.namerow returns underrecord:details". Measured, it does not return. The dedupe keeps hidingnamebecause the H1 now is the name, which is the stripping-backend state. The readable name, which was on screen nowhere before, is on screen once.email, the default highlight strip no longer prints it.phoneleaves the H1 asada@example.com.isLoadedfalse), the H1 isada@example.com.Reverse-verify
Run at head
b0dbc2af7. The fix was committed first. Thendata={readablePageRecord}was reverted todata={pageRecord}, with the on-disk counts checked (readable 1 to 0, raw 1 to 2), and the pin file re-run: 5 failed / 2 passed. The five pins went red and both controls stayed green. The file was restored withgit checkout HEAD --under a trap,git diff HEADwas 0 bytes, and the disk hash equalled the HEAD blobbff7a81c. One earlier ablation invocation was refused byvitest-invocation-guard(a directory filter was appended by mistake), so it never ran. That run counts as NOT MEASURED and is not a result.Gates (at head
b0dbc2af7, after mergingorigin/main)pnpm exec vitest runon the new pin file andRecordDetailView.titleFls-10434.test.tsx: 11/11 passed.RecordDetailView, run in two batches: 30 files / 307 tests and 30 files / 229 tests, all passed, atf8324d4a7. The merge fromorigin/mainbrought no change to app-shell, plugin-detail, permissions or the components layout renderers.timeout 570expired under the container's 10-minute foreground cap (exit 124). This is a declared narrowing; CI's four test shards run the whole package.pnpm --filter @object-ui/app-shell run type-check(bothtscpasses): exit 0. The new test is in thetsconfig.test.jsonfile list (--listFiles, 1 hit). It ran afterturbo run build --filter='@object-ui/app-shell^...'(28/28).eslinton the two touched source files: 0 errors.RecordDetailView.tsxhas 111 warnings, equal toorigin/main's 111. The test file has 4no-explicit-anywarnings, the same shape as the objectui#10434 test. The workflow sets no--max-warnings.check-changeset-presence0 ·check-changeset-no-major0 ·check:changeset-claims0 (report-only) ·check:pending-changeset-literals0 ·check:new-line-citations0 (0 new) ·check:control-bytes0 ·check:test-path-roots0 ·check:vi-mock-specifiers/-inherit/-override-shape0 ·check:unreferenced-sources0.Acceptance notes
.changeset/10434-record-title-fls.mdstill says apage:header"still builds its heading from the record as served". On theRecordDetailViewhost, this change makes that sentence false. The file is outside this card's surface, so it is not edited here; the seat may want to reword it before release.record-details.tsxcomment abovewithoutDeniedFields(ctx.data …)says the same thing ("page:headerstill builds its H1 from the row as served"). It stays true for any other host that hands a raw row. On this host it is now stale.page:headeritself still has no read gate. Any other host that mountsRecordContextProviderwith a raw row and a denying policy would print a denied title. Which other hosts, if any, do that was not surveyed here. This is an observation, not a filed finding.readableRow(the same gated object for the same served row) is argued in its docstring and is not pinned by a test.Generated by Claude Code