Repository navigation
fix(plugin-detail): record:details stops reading requiredPermissions - #10279
objectstack-fleet[bot] wants to merge 4 commits into
Conversation
The contract's strict RecordDetailsProps deliberately does not declare requiredPermissions, so a record:details document carrying it is refused at publish, yet the renderer read it and hid the whole block behind it. Per the maintainer ruling on the card, the read and the block-level gate it drove are removed from record-details.tsx; everything else the block does is unchanged. Pins: a record:details node carrying the key renders its body under a real MePermissionsProvider that reports the capability unheld, with record:highlights in the same tree still refused as the lit control, and a spy proving the capability path is never asked. The 8649 routed-key ledger and the 9965 honest-cast ledger drop the retired record-details entry; the 8649 ledger pins its absence instead. record:highlights and record:related_list are untouched. Claude-Session: https://claude.ai/code/session_01BP8CMtACxTdLjqR6rhd33C Co-authored-by: Claude <noreply@anthropic.com>
…ge narrows Claude-Session: https://claude.ai/code/session_01BP8CMtACxTdLjqR6rhd33C Co-authored-by: Claude <noreply@anthropic.com>
|
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
|
…cord:details stops reading requiredPermissions The objectui#10155 and objectui#8649 changesets are unreleased and publish verbatim into the same release as this change. Narrow their requiredPermissions claims to record:highlights and record:related_list, with one clause each that record:details no longer reads the key. Their frontmatter is untouched. This change's own changeset drops the supersession sentence, which pointed at the text now corrected. Claude-Session: https://claude.ai/code/session_01BP8CMtACxTdLjqR6rhd33C Co-authored-by: Claude <noreply@anthropic.com>
…cord-details-stop-reading-required-permissions
✅ 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
|
|
Closed unmerged, per ruling A on objectui#10281 (comment 5823922380, maintainer 「19913 同意A」, recorded by the The ruling holds that the protocol declaration wins: objectstack-ai/objectstack#18159 declares ⛔ Not merged; the branch is left as is. No follow-up is owed from this PR.
Generated by Claude Code |
…ormat in every record-title reader (ADR-0079 order) (objectstack-ai#10358) Fixes objectstack-ai#9436 Clause-②: yes This PR executes ruling C1 on this card: the ADR-0079 declared name pointer outranks `titleFormat` in every record-title reader. The authority is class-1 ruling comment 5657441402, ratified in comment 5814246926. The scope is the re-scoped claim, comment 5820165439: one ruled order, three readers, one PR. LookupField is left out (see Acceptance notes). ## The new public export (why `Clause-②: yes`) `@object-ui/core` now exports **`declaredNameField(objectDef)`**. The function already existed privately in `record-title.ts`, and its behaviour is unchanged. It returns the object's declared record-title pointer exactly as `getRecordDisplayName` reads it at steps 1+2: `nameField`, then the deprecated `displayNameField` and `NAME_FIELD_KEY` aliases. It returns `undefined` when none is declared, and it **never derives**. Never deriving is the difference from `resolveNameField`, and it is what lets a caller put the type-aware derivation on a lower rung than its own `titleFormat` rendering. All three readers value it the same way, `recordDisplayValueAt(record, declaredNameField(objectDef))`. None of them re-types the `??` chain. The claim marks this export `Clause-②: yes`, so a review-tier contract review is owed before enqueue. ## Authority, as read - `@objectstack/spec` 17.4.0 (installed), `titleFormat` describe: "[DEPRECATED → nameField (ADR-0079)] Render-only title template; the server cannot return or query it, and an explicit nameField now takes precedence." - `@object-ui/core` `getRecordDisplayName` docblock, step 3: "`objectDef.titleFormat` (rendered template) — LEGACY, render-only, kept for back-compat only; an explicitly-declared field (1/2) now wins over it." ## `PageHeaderRenderer` title ladder, before and after The new rung is inserted directly above the template. Nothing else moves, and the template keeps the header's own interpolation (i18n option labels, separator cleanup). | # | Before (`main`) | After (this PR) | |---|---|---| | 1 | explicit `schema.title` | explicit `schema.title` | | 2 | `titleFormat`, via the header's interpolation | **declared pointer**: `nameField`, then `displayNameField`, when it holds a value on the record. This is a new rung, filtered by the same floor test as rung 4. | | 3 | unified resolver: `nameField`, `displayNameField`, core `formatTitleTemplate`, type-aware derivation | `titleFormat`, via the header's interpolation (unchanged) | | 4 | record-key rung, only for an object naming no title field | unified resolver (unchanged) | | 5 | `${objectLabel} ${id}` placeholder | record-key rung (unchanged) | | 6 | | `${objectLabel} ${id}` placeholder (unchanged) | On an object without a `titleFormat`, rung 2 answers exactly what rung 3 answered before. A pointer that is blank on the record falls through to the template, just as `getRecordDisplayName` walks from a blank steps 1+2 to step 3. ## The other two readers of the same order - **`DetailView.resolveDisplayTitle`**: `primaryField`, then **declared pointer (new step 1b)**, then `titleFormat` (still step 2), then the unified resolver, then `schema.title`, then the record-key probe, then the floor. The rung is numbered 1b so that existing citations of "step 2" as the template step stay true. - **`record:details` H1 dedupe**: when the declared pointer holds a value, that field IS the H1. Its row is hidden and the template is not consulted. Otherwise the objectstack-ai#8351 template scan and the value walk run unchanged. Why all three move together was measured on the stop report (comment 5820114435). With only the header moved, the synthesized page (`page:header` + `record:details`) showed a duplicate row under an identical H1 for a composite template. For a single-field template it also hid a row the H1 no longer showed. `DetailView` rendered `HT-2026-001 - Acme Corporation` over `nameField: 'contract_no'`. ## Upgrade effect: whose H1 moves The H1 moves on any object that declares BOTH a `nameField` (or `displayNameField`) and a `titleFormat` whose rendering differs from that field's value. - **objectui `examples/**` and `apps/**`**: zero objects declare `titleFormat` at all. In-repo declarations are test fixtures only. - **ObjectStack platform objects**, measured once at objectstack `61609edf` and not re-derived by any gate here. The instrument was `git grep -l` for a `titleFormat` key over non-test, non-md sources, then a per-file co-occurrence scan for `nameField` / `displayNameField`. - 42 platform objects declare both. - 26 have a single-field template equal to the pointer, so nothing visible changes. - **16 have an H1 that moves.** 11 of them move to another field's value: `sys_import_job`, `sys_job_queue`, `sys_job_run`, `sys_session`, `sys_setting`, `sys_migration_journal`, `sys_activity`, `sys_audit_log`, `sys_comment`, `sys_sharing_rule` and `sys_webhook`. - **The other 5 declare `nameField: 'id'`, so their H1 becomes the raw record id**: `sys_approval_action`, `sys_approval_approver`, `sys_approval_request`, `sys_automation_run` and `sys_http_delivery`. This is a known consequence of the ruled order, and nothing in the renderer works around it. The fix belongs to those objects' metadata in ObjectStack: designate a formula field as `nameField`, as the `titleFormat` describe itself advises for a composite title. The seat is carding that in objectstack. The changesets for `@object-ui/components` and `@object-ui/plugin-detail` state the same effect. The `@object-ui/core` changeset names the export. ## Pins - `packages/components/src/__tests__/page-header-title.test.tsx`: the pin "titleFormat still outranks nameField (legacy header behaviour)" is **rewritten, not deleted**, as "the declared nameField outranks titleFormat (ADR-0079 protocol order, objectstack-ai#9436)". The H1 must equal the pointer's value, and the template must render nowhere. - A second pin covers the `displayNameField` alias. - Four controls: a blank pointer falls through to the template; the template still outranks the type-aware derivation; the template keeps the header's option-label interpolation (a select value renders as `In Progress`, not `in_progress`, which also guards against consulting the whole resolver above the template); an explicit `schema.title` still wins. - `record-details.titleFormatNoDedupe-8351.test.tsx`: the two old-order cases are **rewritten, not deleted**. "HALF 1" now asserts the pointer's row DROPS. "hides the row the DECLARED POINTER names, not the one the template names" is the other. - The objectstack-ai#8351 template outcomes (composite hides nothing, empty walks on, collapse hides that row) now run on an object with no declared pointer, where the template really is the H1. - A scan-not-peek case is added. - `record-details.headerDedupeAgreement-9436.test.tsx`: the stop report's probe, committed. It renders the real `page:header` H1 beside the real `record:details` body, for a composite and a single-field template, plus a no-pointer control. - `DetailView.declaredPointerOutranksTitleFormat-9436.test.tsx`: two pins (`nameField`, `displayNameField`) and three controls: no pointer, a blank pointer, and the view-level `primaryField` still winning. - `record-title.declaredNameField-9436.test.ts`: the export's contract. It covers the alias order, never deriving (with a control showing that `resolveNameField` would derive), and agreement with `getRecordDisplayName` at steps 1+2. ## Ablations (one-shot, run at `96e1b800`, the commit holding implementation and pins) Each ablation used `node ../objectstack/scripts/ablation-replace.mjs`. That tool requires the anchor to hit exactly once, verifies the mutation on disk (anchor count 1 to 0, blob hash changed), then restores and proves the restore (blob equals HEAD, `git diff HEAD` empty). Each run covered the same five files, 38 tests. Vitest aliases these packages to `src`, so no `dist` was involved. | Ablation | Mutation | Red (predicted = observed) | Green | |---|---|---|---| | A1: header back to template-first | `declaredTitle \|\|` becomes `'' \|\|` | both header order pins, and both agreement cases (the H1 assertion) | 34, including every control | | A2: dedupe back to template-first | the declared-pointer branch becomes `if (false)` | both flipped objectstack-ai#8351 cases, and both agreement cases (the body assertion) | 34 | | A3: `DetailView` back to template-first | its declared rung becomes `if (false)` | both `DetailView` pins | 36 | Later commits added the changesets, removed one unused eslint directive from the agreement test (a comment line), and merged `main`. None of them touches the mutated source lines. ## Verification at `d02362e7` (final head) - `turbo run build --filter='@object-ui/plugin-detail^...'`: 11/11 successful. Then `type-check` of `@object-ui/core`, `@object-ui/components` and `@object-ui/plugin-detail`: exit 0. All five test files were confirmed inside the `tsconfig.test.json` programs by `--listFiles`. - From the repo root: `pnpm exec vitest run packages/core/ packages/plugin-detail/` gave 367 files passed and 1 skipped (a pre-existing timezone `skipIf`), 5460 tests passed. `pnpm exec vitest run packages/components/` gave 289 passed and 1 skipped, 2809 tests passed. - `pnpm check:control-bytes` OK. `pnpm check:new-line-citations`: 0 new citations. `node scripts/check-changeset-presence.mjs`: 9 source files of 3 released packages, 3 changesets. `check-changeset-no-major`, `-fixed`, `-overwrite`: OK. `pnpm check:pending-changeset-literals` OK. `pnpm check:test-path-roots` OK. - `pnpm check:changeset-claims`: report-only. It flagged 10 pending changesets naming a touched file. Each paragraph was read, and none is falsified: they concern tab walkers, action ids, name-space `resolveNameField`, `NAME_FIELD_KEY` being read only inside `record-title.ts` (still true: the export lives there), and `interpolate()` rendering `titleFormat` (still true). - ESLint, narrowed to the 9 changed `.ts`/`.tsx` files: 0 errors (warnings only, from rule classes already present in these files). - The population is eslint's own config: `ESLint.isPathIgnored` is false for 9 of 9. - The file count is read from `--format json`: 9. - Invariance: `eslint.config.js` sets no `parserOptions` / `projectService` (no type-aware linting), and no rule under `eslint-rules/` reads other files. So this diff cannot move a verdict on an untouched file. - NOT MEASURED: `pnpm check:readme-exports`. Its prerequisite is every package's `dist`: its only findings were missing `dist` entries and the vacuity floors those trip. This diff edits no README, and the `core` / `components` READMEs had zero findings. CI runs it on a full build. ## Hunk fences - `record-details.tsx`: this PR's hunks are the import block and the dedupe region, far from the `@@ -139` / `@@ -197` hunks of objectstack-ai#10279. - `DetailView.tsx`: this PR's hunks are the import line and `resolveDisplayTitle`, far from the `@@ -934` hunk of objectstack-ai#8941. - `packages/fields/` is untouched. ## Acceptance notes - **LookupField `recordToOption`** has the same defect class: the referenced object's `titleFormat` outranks its declared pointer in lookup option labels. Per the re-scope it is ⛔ not folded here. `packages/fields/` is held by objectstack-ai#10223, and the seat files it as its own card. - **A pre-existing gap in the objectstack-ai#8351 dedupe scan**, not moved by this PR. The scan only matches values against its candidate list: the name pointer, the derived field and six literal names. Take an object with NO declared pointer whose template collapses onto a field outside that list, for example `{contract_no} - {name}` with `name` blank. Its H1 reads the `contract_no` value while the `contract_no` row still prints under it. This PR reproduced it while retargeting "LIT CONTROL B"; the template-scan code that decides it is unchanged from `main`. That control now collapses onto `name`, a candidate. The gap is reported for the seat to card. The session for this change is `https://claude.ai/code/session_01BA3nKVUwKQJf8DBxrSVtNC`. --- _Generated by [Claude Code](https://claude.ai/code/session_01BA3nKVUwKQJf8DBxrSVtNC)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Part of #10200
Clause-②: no
Ruling item 1 of objectui#10200 (maintainer ruling, comment 5815200174):
record:detailsstops readingrequiredPermissions. Ruling item 2 (the@objectstack/specpin bump forenforceFieldSecurity/redactFields) is not in this PR and stays open on the card: no installable spec build declares those two keys yet.packages/typesis untouched (acceptance item 3).What changed
packages/plugin-detail/src/renderers/record-details.tsx: therequiredPermissionsread and the block-levelhasCapabilitiesgate it drove (the "Insufficient permissions to view details." notice) are removed. Nothing else the block does moves: theenforceFieldSecurity/redactFieldsfold, the!ctxplaceholder and the hook sequence are unchanged (no hook added or removed;usePermissions()is still called and still read further down forgetObjectApiOperations). A docblock at the old site says why the key is not read, and names the instrument that goes red the day the contract declares it on this block.packages/plugin-detail/src/renderers/__tests__/:record-blocks.requiredPermissions-gate.test.tsx:record:detailsleaves the gated-block table (record:highlightsandrecord:related_listkeep every existing pin). A newdescribepins the ruling on a realMePermissionsProvider(details in the next section).record-details.test.tsx: the rules-of-hooks permission-flip pin now asserts the body is still rendered after the flip, not hidden.detailRendererUndeclaredKeys-8649.test.ts:record-details.tsxleaves the routed-key ledger forrequiredPermissionsand enters a new retired-read ledger that asserts the read is ABSENT from the file.record-details.hideFieldsUncast-9965.test.ts:requiredPermissionsleaves the honest-cast ledger, whose expiry leg asserts a cast read still exists; that is no longer true..changeset/10200-record-details-stop-reading-required-permissions.md, a patch bump.safeParse table, re-run against the installed pin at the time of the work (acceptance item 2)
@objectstack/spec17.4.0, resolved from this worktree'snode_modules(frompackages/plugin-detail), withRecordDetailsProps.safeParseexecuted:{ requiredPermissions: ['a'] }unrecognized_keys{ enforceFieldSecurity: true }unrecognized_keys{ redactFields: ['a'] }unrecognized_keys{ hideFields: ['a'] }{ zzzNonsense: 1 }unrecognized_keysOther readings from the same run:
ComponentPropsMap['record:details'] === RecordDetailsPropsis true.RecordDetailsProps.shapehas norequiredPermissions.RecordQuickActionsProps.safeParse({ requiredPermissions: ['a'] })passes.⇒ Item 1's premise still holds on the installed pin, and no row flipped. The two item-2 rows are still pin lag.
The pin, and proof that it can fail
New
describe: "record:details —requiredPermissionsno longer gates the block (objectui#10200)". It mounts the real stockMePermissionsProviderwith a reported-empty capability set, so the provider genuinely does not holdcrm.manage:record:detailsandrecord:highlightswith the samerequiredPermissions: ['crm.manage']under the same provider, in the same tree. The highlights block is the lit control: it is still refused, which proves the provider says no. The details body renders and shows no refusal notice. One pin uses a normal record context; the other uses an emptyobjectName, where the old gate also fired.hasCapabilitieswrapper records that the details renderer never asks the capability path about the authored names, and never asks the object-action path either.Ablation. The fix was committed first, at b0451c5. The mutation went through
ablation-replace.mjs, which proves on disk both the plant and the restore:record:detailsinverse pins, the rules-of-hooks flip pin, and the retired-read ledger leg.git diff HEADis empty.dist/sits in the resolution path: the tests import the renderer by relative path fromsrc.Verification (final head ed52015)
pnpm exec vitest runover 28 test filespnpm --filter @object-ui/plugin-detail run type-checktsc --noEmit && tsc -p tsconfig.test.jsoneslint --format jsonon the 5 touched TS files, with the package configno-explicit-any/react-refresh)node scripts/check-changeset-presence.mjsnode scripts/check-changeset-no-major.mjsmajorpnpm check:vi-mock-override-shape/check:vi-mock-inherit/check:vi-mock-specifiersvi.mockfactory gained ahasCapabilitiesoverridepnpm check:test-path-roots/check:control-bytes/check:new-line-citations/check:pending-changeset-literals/check:changeset-claimsnode scripts/check-governed-queue-guard.mjs --testover the 6 pathsNotes on the table:
git grep. They include readers inapp-shell,core,types,plugin-formandplugin-kanban.turbo run build --filter='@object-ui/plugin-detail^...'(11/11 tasks, cache-restored).--listFilesOnlyontsconfig.test.jsonshows all 5 touched TS files are in the program.Declared narrowings (the full farm is CI's):
--format jsonoutput. Invariance:eslint.config.jsconfigures no type-aware linting (noparserOptions.project/projectService), and noeslint-rules/*rule reads the filesystem, so this diff cannot move a verdict on an untouched file.plugin-detailsuite was not run locally. Behaviour changes only when arecord:detailsnode authors a non-emptyrequiredPermissions, and the hook sequence is unchanged. The only tests in the tree that author that key onrecord:detailsare the ones edited here (git grep).Acceptance notes
Sibling blocks, same class, NOT changed here. The ruling scopes item 1 to
record:details.record:highlightsandrecord:related_listreadrequiredPermissionsexactly asrecord:detailsdid, and the installed pin refuses the key on both:RecordHighlightsProps.safeParse({ fields: ['a'], requiredPermissions: ['a'] })is refused withunrecognized_keys. The control without the key passes.RecordRelatedListPropsbehaves the same way, again with a passing control.The spec's own docblock on
RecordDetailsPropsnames the key as deliberately undeclared on all three blocks. Removing a security gate is the maintainer floor, so this PR leaves both untouched and reports them to the seat.Pending changesets whose prose this change narrows. Two unreleased changesets describe the old
record:detailsbehaviour. Neither is yet inpackages/plugin-detail/CHANGELOG.md:.changeset/10155-record-blocks-capability-gate.md: its headline namesrecord:details..changeset/8649-detail-renderer-undeclared-keys.md: it says the key is "read by all three renderers".Both files are outside this claim's file surface, so this PR's own changeset states the supersession instead. Amending either body is the seat's call.
Behaviour change for reviewers. A
record:detailsnode carryingrequiredPermissionsused to be hidden from a viewer without the capability. It now renders. Such a document is refused at publish by the pinned spec. No in-tree producer (examples, apps, app-shell synth) authors the key on this block (git grep), so the only reachable population is raw nodes that bypass the contract. Server-side record and field access is unaffected: this gate was a browser-side hide.Generated by Claude Code