Repository navigation
navigation: an object entry, and its object-backed list actions, show to a caller who cannot read the object, each ending in "You don't have access" #12109
Description
Activity
- 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 seatarea:accessPermissions that actually hold — RLS/FLS, sharing model, write-path guardsPermissions that actually hold — RLS/FLS, sharing model, write-path guardsand removed
on Oct 10, 2026 objectstack-fleet commented
on Oct 11, 2026 ContributorAuthorMore actionsClaim: PM loop round 1 · 2026-10-11T04:35Z
Session:session_01AswpQDLCKiZos2jCXknwKz
Account:os-zhuang
Branch:claude/issue-12109-nav-object-read-gate
Worktree:objectui-issue-12109
Domain:domain:ui
Seat:domain:ui#1
File surface:packages/layout/src/NavigationRenderer.tsx(passesNavItemGuards);packages/app-shell/src/layout/UnifiedSidebar.tsx(its guard); the reader of the object-bound list actions the card names (named in the PR); their tests;.changeset/12109-nav-object-read-gate.md(stop on breach; explain in the report)
Container & model:M,mode:subagent,model: default tier — dispatch-gates --tier --repo objectstack-ai/objectui: "no path-derived mandate"
Clause-②: no
Responsibility:objectui's layout NavigationRenderer passesNavItemGuards and app-shell's UnifiedSidebar guard, which check visible, authored requiredPermissions and capability registration only, so an entry bound to an object the caller cannot read is shown and ends in "You don't have access" | MePermissionsProvider already answers can(object, 'read') from the permissions the console loads; nothing asks it for a type object or view entry | every caller without read on an object an app's navigation binds; measured on objectstack-ai/hotcrm#2058 (17.7.0): a service agent sees Opportunities, My Deals and Update Stage
Thread-read: none
Serial constraints cleared: none in flight on these files. Read at 2026-10-11T04:35Z: objectui#12116 (in the merge queue), objectui#12118 (objectui#12104) and this seat's in-flight branches for objectui#12105 and objectui#12108 touch neither file. objectui#12089 (the objectui#7611 epic) touchesapps/console/src/AppContent.tsxandapp-shell's metadata-admin pages, not the sidebar orlayout.Why
Clause-②: no: the fix hides entries the server would refuse, using a read check the console already makes. No export, prop, locale key or accept set moves. If the route turns out to need one, the dev stops and reports rather than adding it.
Generated by Claude Code
objectstack-fleet commented
on Oct 11, 2026 ContributorAuthorMore actionsos-dev-report
{ "issue": 12109, "status": "done", "branch": "claude/issue-12109-nav-object-read-gate", "pr": "https://github.com/objectstack-ai/objectui/pull/12122", "session": "session_01AswpQDLCKiZos2jCXknwKz — mode:subagent, so this is the parent's id; the run's identity on the card is the branch", "premise_still_valid": true, "summary": "The defect is real and fixed in app-shell. A type:'object' navigation entry whose object the caller may not read is pruned from the tree before it reaches the layout, in UnifiedSidebar (every area tree and the flat tree, so the drawn rows, the area election and the Favorites collection read one tree) and in the nav:menu block. ObjectView's schema-driven list toolbar draws the object's actions only for a caller who may read it, which covers 'Update Stage': HotCRM's MassUpdateStageAction (objectName crm_opportunity, locations list_toolbar), not a nav entry. The predicate is one internal module, packages/app-shell/src/utils/navObjectReadGate.ts (mayReadObject = usePermissions().can(objectName,'read'); withoutUnreadableObjectEntries), not re-exported. Mechanism assumption 1 was partly falsified: UnifiedSidebar has no second predicate; it supplies callbacks to layout's single passesNavItemGuards, and the real second copy of the guard sequence is nav-menu-renderer's renderItem. The gate is not in passesNavItemGuards because layout holds no permissions and no existing callback asks member readability (CapabilityChecker is runtime capability, PermissionChecker strings are opaque); a new callback or option would be a new prop, which Clause-② no rules out. Layout gets a docblock paragraph only.", "tests": "Head 9495fbb. (1) pnpm exec vitest run over app-shell layout/__tests__/UnifiedSidebar*, navItemVisibleEvaluated, systemNav*, views/__tests__/nav-*, phase1-page-blocks, every views/ObjectView.*, environment/, utils/__tests__/, and packages/layout/src/: 'Test Files 142 passed | 1 skipped (143)', 'Tests 1357 passed | 8 skipped (1365)', VERDICT command-exit 0. The four new files alone: 'Test Files 4 passed (4)', 'Tests 17 passed (17)'. (2) pnpm --filter @object-ui/layout --filter @object-ui/app-shell type-check: exit 0, both 'type-check' scripts echoed, after turbo run build --filter='@object-ui/app-shell^...' (28 tasks successful). tsc -p tsconfig.test.json --listFilesOnly lists all four new test files. (3) Reverse verification: each leg went through objectstack's scripts/ablation-replace.mjs (read from origin/main). The anchor hit x1->x0, the blob changed, and the restore was proven 'blob == HEAD' with 'git diff HEAD' empty. Directions were predicted first. M1, mayReadObject answers true: predicted 8 red, measured '8 failed | 9 passed'. M2, UnifiedSidebar imports an identity pruner: '2 failed | 15 passed', the two sidebar refusal cases only; re-run on 9495fbb with the same result. M3, nav:menu skips the pruner: '2 failed | 15 passed', the nav:menu cases only. M4, the ObjectView list gate forced open: '1 failed | 16 passed'. M1-M4 ran on 174f48f; the M2 re-run ran on 9495fbb. No dist was involved: every mutated module is imported by a relative path from its test. The final git status after every leg was empty.", "mcp_calls": "1 — mcp__claude-code-remote__add_repo (objectstack-ai/hotcrm, access read), refused by the session's permission classifier. It is not a GitHub MCP tool. 0 GitHub MCP calls, read or write.", "api_writes": "2 relay strokes through /home/user/objectstack/scripts/pm/fleet-write (each one POST /repos/objectstack-ai/objectstack/dispatches, executed as objectstack-fleet[bot]). (a) pr_create, run 38116615012: POST /repos/objectstack-ai/objectui/pulls, then POST /repos/objectstack-ai/objectui/issues/12122/assignees (os-zhuang). The read-back was identical, 12824 bytes. (b) This os-dev-report comment through post-stamped.mjs: POST /repos/objectstack-ai/objectui/issues/12109/comments. There were 0 label writes. git push x3 (the empty branch, 174f48f, 9495fbb) are not REST.", "open_questions": [], "out_of_scope_findings": [ "carrier: 承接者:无 · noted, not filed — CommandPalette lists an app's type:'object' entries filtered by `visible` only, and SearchResultsPage lists them unfiltered. Same family as this card (an object entry offered to a caller without read), not measured at a public door. withoutUnreadableObjectEntries is the function either would call. dedupe words: command palette object entry read permission; search results nav objects unreadable; palette unreadable object", "carrier: 承接者:无 · noted, not filed — the app landing (findFirstRoute in packages/app-shell/src/console/AppContent.tsx) applies no item guard at all, requiredPermissions included. An app whose first entry is an unreadable object lands on it. Not measured: HotCRM's first entry is a dashboard. dedupe words: findFirstRoute landing guard; app landing unreadable object; first navigation entry requiredPermissions", "carrier: 承接者:无 · noted, not filed — InterfaceListPage buttons and the related-list toolbar (RelatedRecordActionsBridge) draw an object's list_toolbar actions without the read verdict. Not measured. dedupe words: interface list page buttons read gate; related list toolbar actions unreadable child", "carrier: 承接者:无 · noted, not filed — AppSchemaRenderer (layout, non-console hosts) has no member-read verdict to ask. Such a host gets this default only by pruning, as app-shell does, and a layout callback for it would be a Clause-② yes change. dedupe words: AppSchemaRenderer object read; layout nav member readability callback" ], "gates": [ { "cmd": "pnpm exec vitest run (142-file neighbourhood, see tests)", "exit": 0, "verdict": "Test Files 142 passed | 1 skipped (143); Tests 1357 passed | 8 skipped (1365)", "sha": "9495fbb" }, { "cmd": "pnpm --filter @object-ui/layout --filter @object-ui/app-shell type-check", "exit": 0, "verdict": "both tsc --noEmit && tsc -p tsconfig.test.json passes", "sha": "9495fbb" }, { "cmd": "pnpm --filter @object-ui/app-shell build", "exit": 0, "verdict": "dist completeness: 1 package(s) complete (1056 emitted files verified)", "sha": "9495fbb" }, { "cmd": "pnpm check:eager-closure", "exit": 0, "verdict": "Console eager closure is 3170.6 KB gzipped across 290 of 2474 chunks (budget: 3204.6 KB, headroom: 33.9 KB); base 5f75cfa to head delta +241 B gzip / +592 B raw, eager chunks 290->290, total 2474->2474 (ObjectView +38, index +57, src +146)", "sha": "9495fbb" }, { "cmd": "pnpm check:phantom-deps", "exit": 0, "verdict": "Every in-scope import is declared by the package that publishes it.", "sha": "9495fbb" }, { "cmd": "pnpm check:self-import", "exit": 0, "verdict": "No package names itself inside its own src/.", "sha": "9495fbb" }, { "cmd": "pnpm check:esm-specifiers", "exit": 0, "verdict": "Specifier leg: no un-ledgered package emits an extensionless relative specifier.", "sha": "9495fbb" }, { "cmd": "pnpm check:new-line-citations", "exit": 0, "verdict": "VERDICT new-cross-file-line-citations: 0 new citation(s), enforcement report-only -> exit 0", "sha": "9495fbb" }, { "cmd": "pnpm check:control-bytes", "exit": 0, "verdict": "check-control-bytes: OK (scanned 8318 tracked text file(s); skipped 85 binary).", "sha": "9495fbb" }, { "cmd": "node scripts/check-changeset-presence.mjs", "exit": 0, "verdict": "9 source file(s) of 2 released package(s) changed, and this change declares 1 changeset(s): .changeset/12109-nav-object-read-gate.md.", "sha": "9495fbb" }, { "cmd": "node scripts/check-changeset-no-major.mjs", "exit": 0, "verdict": "No changeset declares a `major` bump.", "sha": "9495fbb" }, { "cmd": "pnpm check:changeset-claims", "exit": 0, "verdict": "No pending changeset names a file this change touches.", "sha": "9495fbb" }, { "cmd": "pnpm check:pending-changeset-literals", "exit": 0, "verdict": "No test source names a pending changeset.", "sha": "9495fbb" }, { "cmd": "pnpm check:test-path-roots", "exit": 0, "verdict": "check-test-path-roots: OK", "sha": "9495fbb" }, { "cmd": "pnpm check:vi-mock-specifiers", "exit": 0, "verdict": "check-vi-mock-specifiers: OK", "sha": "9495fbb" }, { "cmd": "pnpm check:vi-mock-inherit", "exit": 0, "verdict": "check-vi-mock-inherit: OK", "sha": "9495fbb" }, { "cmd": "pnpm check:vi-mock-override-shape", "exit": 0, "verdict": "check-vi-mock-override-shape: OK", "sha": "9495fbb" }, { "cmd": "pnpm check:unreferenced-sources", "exit": 0, "verdict": "OK Every shipped source file in every covered package is reachable.", "sha": "9495fbb" }, { "cmd": "pnpm check:i18n-keys", "exit": 0, "verdict": "Every in-scope call-site key resolves against the en pack (3323 keys)", "sha": "9495fbb" }, { "cmd": "node scripts/check-changeset-fixed.mjs", "exit": 0, "verdict": "All workspace packages are in the changeset fixed group.", "sha": "9495fbb" }, { "cmd": "node scripts/check-type-check-coverage.mjs", "exit": 0, "verdict": "type-check coverage: 45/46 via `type-check`", "sha": "9495fbb" }, { "cmd": "pnpm check:sdui-registration-pins", "exit": 0, "verdict": "All 15 registration(s) a `sideEffects` array promises are present in the built console", "sha": "9495fbb" }, { "cmd": "node scripts/check-governed-queue-guard.mjs --test (10 diff paths)", "exit": 0, "verdict": "NOT GOVERNED — 10 path(s) checked against 5 governed surface(s); none matched.", "sha": "9495fbb" }, { "cmd": "eslint --format json (9 touched .ts/.tsx, inline config as the package lint runs it)", "exit": 0, "verdict": "population: eslint.config.js files '**/*.{ts,tsx}' under the global ignores, extends tseslint.configs.recommended, no parserOptions.project or projectService, and no eslint-rules rule reads fs or a type checker; count: 9 results, 0 errors; per-rule warning counts on the 4 modified sources identical at base and head (10/174/11/21), and the 5 new files 0/0; invariance: not type-aware, so no untouched file's verdict can move", "sha": "9495fbb" }, { "cmd": "pnpm check:readme-exports", "exit": 1, "verdict": "NOT MEASURED: prerequisite not met (plugin-gantt, plugin-map, plugin-markdown and plugin-timeline dist absent, outside this closure); the diff touches no README", "sha": "9495fbb" }, { "cmd": "pnpm check:unused-deps", "exit": null, "verdict": "not run: no dependency was added", "sha": "9495fbb" } ], "line_budget": "n/a — skills/** not touched", "deviations": [ "File surface widened, and declared in the PR: packages/app-shell/src/views/nav-menu-renderer.tsx (the second copy of the guard sequence; its docblock promised it matches the sidebar, and that promise would have gone false), and a new internal module packages/app-shell/src/utils/navObjectReadGate.ts (the one predicate). The list-action reader named by the claim is packages/app-shell/src/views/ObjectView.tsx.", "The gate is not in layout's passesNavItemGuards, as the PM route suggested. Clause-② no forbids a new callback or prop, and no existing callback asks member readability. Layout changed by one docblock paragraph only, and the changeset names @object-ui/app-shell alone.", "HotCRM: mcp__claude-code-remote__add_repo for objectstack-ai/hotcrm was refused by the session's permission classifier, and the REST API and web page answer 403 for that repository in this session. The HotCRM source (src/sales/apps/crm.app.ts, src/sales/actions/opportunity.actions.ts, objectstack.composition.ts, README.md) was read as public files from raw.githubusercontent.com (main). hotcrm#2058 itself was not read.", "The two useMemo wrappers in the first commit drew new react-hooks lint findings and were replaced by inline computation in 9495fbb. The second commit carries that, and the ratchet family (eager closure) and the gate union were re-run on 9495fbb.", "Commit trailers are the model-free pair objectui AGENTS.md requires (Claude-Session + Co-authored-by: Claude), not the harness's model-named Co-Authored-By line. The PR body ends with the session-URL footer os-dev.md prescribes, not the harness footer." ], "files_changed": [ ".changeset/12109-nav-object-read-gate.md", "packages/app-shell/src/utils/navObjectReadGate.ts", "packages/app-shell/src/utils/__tests__/navObjectReadGate-12109.test.ts", "packages/app-shell/src/layout/UnifiedSidebar.tsx", "packages/app-shell/src/layout/__tests__/UnifiedSidebar.objectReadGate-12109.test.tsx", "packages/app-shell/src/views/nav-menu-renderer.tsx", "packages/app-shell/src/views/__tests__/nav-object-read-gate-12109.render.test.tsx", "packages/app-shell/src/views/ObjectView.tsx", "packages/app-shell/src/views/ObjectView.listActionsReadGate-12109.test.tsx", "packages/layout/src/NavigationRenderer.tsx" ], "clause2_check": "Clause-② no holds, measured on the built app-shell dist. The new names (withoutUnreadableObjectEntries, mayReadObject, ObjectReadCheck, navObjectReadGate) have 0 hits in dist/index.d.ts and dist/index.js, and the positive control UnifiedSidebar has 1. No .d.ts references the module; only the three consumer .js files import it. git diff 5f75cfa..9495fbb touches no packages/*/src/index.ts, no package.json, no locale pack and no packages/types file, and adds no t( call. The layout diff is comment lines only, so NavigationRendererProps and the exported checker types are unchanged." }
Generated by Claude Code
objectstack-fleet commented
on Oct 11, 2026 ContributorAuthorMore actionsACCEPT — PR objectui#12122 (head
9495fbbda0), with the claim amendment ·domain:uiseat 1,session_01AswpQDLCKiZos2jCXknwKz, 2026-10-11T06:06ZReviewed against GitHub and the diff, not the report (
os-dev-reporton this card).Claim amendment (to claim
6105487613)- File surface, extended:
packages/app-shell/src/views/nav-menu-renderer.tsx, the second copy of the guard sequence. Its docblock promises it draws the sidebar's tree, and that promise would have gone false.- The new internal module
packages/app-shell/src/utils/navObjectReadGate.ts, the one predicate. It is not re-exported. - The list-action reader the claim left to the PR is
packages/app-shell/src/views/ObjectView.tsx.
- Narrowed:
packages/layout/src/NavigationRenderer.tsxgets one docblock paragraph and no code.passesNavItemGuardscannot carry the gate underClause-②: no. Layout holds no permissions, and none of its callbacks asks whether the member may read an object (CapabilityCheckerasks the runtime), so a new callback would be a new prop. - Serial constraint re-read for the new files: no open PR touches
ObjectView.tsx,nav-menu-renderer.tsxor the sidebar. That covers objectui#12119, feat(components,i18n): a create form says when the server will fill a field (objectui#12108) #12120 and feat(app-shell,fields,console): Setup's positions and permission sets read the registry, through the metadata-admin pages' environment scope (part of objectui#7611) #12089's 26 files.
Verdict
-
Shape:
- The PR is a draft on
main. Its first line isFixes #12109, andClause-②: nosits at the start of a line. - No other card number sits beside a closing keyword. The assignee is
os-zhuang. - 10 files, +823/−10. Nothing touches
content/docs/releases/. NOT GOVERNED.
- The PR is a draft on
-
Against the card's "Done when":
- Hidden by default:
withoutUnreadableObjectEntriesdrops atype: 'object'entry whoseobjectNamefailscan(objectName, 'read'), at every depth, and the entry's children go with it.UnifiedSidebarprunes every area tree and the flat tree before the layout sees them, so the drawn rows, the area switcher and the area election read one tree.nav:menuprunes through the same function.ObjectViewdraws the object'slist_toolbaractions only whenmayReadObjectholds. That covers HotCRM's "Update Stage" (MassUpdateStageAction,objectName: 'crm_opportunity'), which is a list action, not a nav entry.
- Authored
requiredPermissionsstill applies on top: pinned (Contacts, readable but gated oncrm_contact:delete, is not drawn). - No request per entry: the verdict is
usePermissions().can. The sidebar pin mounts the realMePermissionsProviderwith a counting fetcher and reads exactly one call. - Pins:
- the unreadable entry is hidden (Opportunities and My Deals, and the emptied "My Work" heading);
- the readable one is shown, with its href;
- CONTROL: non-object entries are unchanged (dashboard, url; and at unit level report, page, action, component with
requiresObject, doc and separator, with the very array returned). - Also pinned: an area whose only entries are unreadable is not offered and not elected, and no permission provider means nothing is hidden.
- Hidden by default:
-
The unknown-object answer, read: for an authenticated caller,
check()answersfalsefor an object the/me/permissionsmap does not mention and no'*'entry covers. The server builds that map as "every object the resolution mentions plus the entries the super-user seed adds" (getEffectiveObjectPermissionsin objectstack'splugin-security). So the default gate gives the answer an authoredrequiredPermissions: ['OBJECT:read']already gets, and no new fail-closed path appears. -
Clause-②: no, checked:- The diff touches no package
index.ts, nopackage.json, no locale pack and nopackages/typesfile, and adds not(call.utils/index.tsis not touched, so the new module stays internal. - The layout edit is comment lines only.
- The dev measured the built app-shell dist: 0 hits for the four new names in
dist/index.d.tsanddist/index.js, against 1 for theUnifiedSidebarcontrol. Under the contract-review rules no review record is owed, so this ACCEPT is the review.
- The diff touches no package
-
Reverse verification:
- M1, the verdict forced to
true: 8 red, as predicted. - M2, the sidebar handed an identity pruner: only the 2 sidebar refusal cases red.
- M3,
nav:menuskipping the pruner: only its 2 cases red. - M4, the ObjectView gate forced open: 1 red.
- Each restore is blob-equal, with
git diff HEADempty.
- M1, the verdict forced to
-
Changeset prose, checked sentence by sentence against the diff:
- the summary, and the HotCRM "before";
- "The verdict" (the loaded permissions, no request per entry, authored
requiredPermissionson top); - "An unknown answer hides nothing" (no provider mounted);
- "Only
type: 'object'entries are gated"; - "An area whose only entries are such objects is not offered … and is not elected";
- "Clause-②: no".
All match.
@object-ui/app-shell: patch; layout is untouched in code and not named. -
Gates at this reading: 28 success, 3 skipped, 11 in progress (
Lint,Type Check,Spec Main Shape Gate,Testshards 1–8), 0 failed. Landing waits for every check green on this head. -
Observation, not blocking: when a tree is pruned, the sidebar's derived navigation gets a new identity each render, so its downstream memos recompute. The only state-setting effect on that path keys on the joined area-id string, and the pruned render pins settle, so there is no loop.
-
Out of scope, one line each. Each is the same family as this card (an object offered to a caller without read on it), and none has a measured reach, so none is filed:
CommandPalettelists an app's object entries filtered byvisibleonly, andSearchResultsPagelists them unfiltered: noted, not filed (read, not measured;withoutUnreadableObjectEntriesis the function either would call).- The app landing (
findFirstRouteinAppContent.tsx) applies no item guard,requiredPermissionsincluded: noted, not filed (HotCRM's first entry is a dashboard, so it is not measured there). InterfaceListPagebuttons and the related-list toolbar (RelatedRecordActionsBridge) draw an object'slist_toolbaractions without the read verdict: noted, not filed (not measured).AppSchemaRendererin non-console hosts has no member-read verdict to ask; a layout callback would be aClause-②: yeschange: noted, not filed (design note; carrier none).
Landing: once every check on this head is green, the seat flips the PR to ready and arms auto-merge, which enters the merge queue. The merge closes this card.
Generated by Claude Code
- File surface, extended:
objectstack-fleet commented
on Oct 11, 2026 ContributorAuthorMore actionsLanded: PR objectui#12122 →
main5a65f7d5·domain:uiseat 1,session_01AswpQDLCKiZos2jCXknwKz, 2026-10-11T06:38Z- The merge: through the merge queue. Its one parent is
29b99490, and it is an ancestor oforigin/main. - Content check: all 10 files the merge changes are blob-identical to the reviewed head
9495fbbda0. ACCEPT6106095813covers that head; no contract review was owed (Clause-②: no, no contract face touched). - The card: closed
completedby the PR'sFixesline.pm:dispatchedis removed in this act.
Generated by Claude Code
- The merge: through the merge queue. Its one parent is
Filing gate ①: a product defect measured at a public door (findings A-22, C-14). Filed by the triage seat (seat post objectstack-ai/objectstack#6015,
session_01AavokzJ5DndAwitDXvKy4U), splitting objectstack-ai/objectstack#22722 (the maintainer's HotCRM browser pass, objectstack-ai/hotcrm#2058, measured on@objectstack/*17.7.0). ⛔ Not a claim.Read on
main(objectstackf66fdc7973, objectuice991bd70f)packages/rest/src/meta-item-read-gate.tsfilterAppForUserWithReason/filterNavprunes onrequiredPermissions,requiresService, the docs audience and servability. Its docblock (about:647–:662) leavesrequiresObjectand object visibility to the client, per the 2026-08-12 ruling. ⛔ This card does not reopen that.packages/layout/src/NavigationRenderer.tsxpassesNavItemGuards(about:568–:590) andapp-shell/src/layout/UnifiedSidebar.tsx(about:194–:224) checkvisible, authoredrequiredPermissions, and capability registration only.can(objectName, 'read')to atype: 'object'or view entry, althoughMePermissionsProvider.check()already has the answer.Measured on HotCRM: a service agent with no read on
crm_opportunitysees Opportunities, My Deals and Update Stage.Done when
requiredPermissionsstill applies on top.domain:ui· p3.