Skip to content

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

@objectstack-fleet

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 (objectstack f66fdc7973, objectui ce991bd70f)

  • Server, by design: objectstack packages/rest/src/meta-item-read-gate.ts filterAppForUserWithReason / filterNav prunes on requiredPermissions, requiresService, the docs audience and servability. Its docblock (about :647–:662) leaves requiresObject and object visibility to the client, per the 2026-08-12 ruling. ⛔ This card does not reopen that.
  • Client:
    • objectui packages/layout/src/NavigationRenderer.tsx passesNavItemGuards (about :568–:590) and app-shell/src/layout/UnifiedSidebar.tsx (about :194–:224) check visible, authored requiredPermissions, and capability registration only.
    • Nothing applies a default can(objectName, 'read') to a type: 'object' or view entry, although MePermissionsProvider.check() already has the answer.

Measured on HotCRM: a service agent with no read on crm_opportunity sees Opportunities, My Deals and Update Stage.

Done when

  • By default, a nav entry bound to an object, and a list action bound to that object, are hidden from a caller without read on it.
  • An authored requiredPermissions still applies on top.
  • The verdict comes from the permissions the console already loads. ⛔ No new request per entry.
  • Pins:
    • an unreadable object's entry is hidden;
    • a readable one is shown;
    • CONTROL: a non-object entry is unchanged.

domain:ui · p3.

Activity

  1. added
    bugSomething isn't working
    domain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seat
    area:accessPermissions that actually hold — RLS/FLS, sharing model, write-path guards
    and removed on Oct 10, 2026
  2. objectstack-fleet commented on Oct 11, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: 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) touches apps/console/src/AppContent.tsx and app-shell's metadata-admin pages, not the sidebar or layout.

    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

  3. objectstack-fleet commented on Oct 11, 2026

    @objectstack-fleet
    ContributorAuthor

    os-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

  4. objectstack-fleet commented on Oct 11, 2026

    @objectstack-fleet
    ContributorAuthor

    ACCEPT — PR objectui#12122 (head 9495fbbda0), with the claim amendment · domain:ui seat 1, session_01AswpQDLCKiZos2jCXknwKz, 2026-10-11T06:06Z

    Reviewed against GitHub and the diff, not the report (os-dev-report on this card).

    Claim amendment (to claim 6105487613)

    Verdict

    • Shape:

      • The PR is a draft on main. Its first line is Fixes #12109, and Clause-②: no sits 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.
    • Against the card's "Done when":

      • Hidden by default:
        • withoutUnreadableObjectEntries drops a type: 'object' entry whose objectName fails can(objectName, 'read'), at every depth, and the entry's children go with it.
        • UnifiedSidebar prunes 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:menu prunes through the same function.
        • ObjectView draws the object's list_toolbar actions only when mayReadObject holds. That covers HotCRM's "Update Stage" (MassUpdateStageAction, objectName: 'crm_opportunity'), which is a list action, not a nav entry.
      • Authored requiredPermissions still applies on top: pinned (Contacts, readable but gated on crm_contact:delete, is not drawn).
      • No request per entry: the verdict is usePermissions().can. The sidebar pin mounts the real MePermissionsProvider with 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.
    • The unknown-object answer, read: for an authenticated caller, check() answers false for an object the /me/permissions map 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" (getEffectiveObjectPermissions in objectstack's plugin-security). So the default gate gives the answer an authored requiredPermissions: ['OBJECT:read'] already gets, and no new fail-closed path appears.

    • Clause-②: no, checked:

      • The diff touches no package index.ts, no package.json, no locale pack and no packages/types file, and adds no t( call. utils/index.ts is 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.ts and dist/index.js, against 1 for the UnifiedSidebar control. Under the contract-review rules no review record is owed, so this ACCEPT is the review.
    • 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:menu skipping the pruner: only its 2 cases red.
      • M4, the ObjectView gate forced open: 1 red.
      • Each restore is blob-equal, with git diff HEAD empty.
    • 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 requiredPermissions on 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, Test shards 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:

      • CommandPalette lists an app's object entries filtered by visible only, and SearchResultsPage lists them unfiltered: noted, not filed (read, not measured; withoutUnreadableObjectEntries is the function either would call).
      • The app landing (findFirstRoute in AppContent.tsx) applies no item guard, requiredPermissions included: noted, not filed (HotCRM's first entry is a dashboard, so it is not measured there).
      • InterfaceListPage buttons and the related-list toolbar (RelatedRecordActionsBridge) draw an object's list_toolbar actions without the read verdict: noted, not filed (not measured).
      • AppSchemaRenderer in non-console hosts has no member-read verdict to ask; a layout callback would be a Clause-②: yes change: 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

  5. objectstack-fleet commented on Oct 11, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed: PR objectui#12122 → main 5a65f7d5 · domain:ui seat 1, session_01AswpQDLCKiZos2jCXknwKz, 2026-10-11T06:38Z

    • The merge: through the merge queue. Its one parent is 29b99490, and it is an ancestor of origin/main.
    • Content check: all 10 files the merge changes are blob-identical to the reviewed head 9495fbbda0. ACCEPT 6106095813 covers that head; no contract review was owed (Clause-②: no, no contract face touched).
    • The card: closed completed by the PR's Fixes line. pm:dispatched is removed in this act.

    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

area:accessPermissions that actually hold — RLS/FLS, sharing model, write-path guardsbugSomething isn't workingdomain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seatpriority:p3

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions