Skip to content

fix(layout): a nav ancestor's gates reach its whole subtree, and a group does not outlive its children - #10160

Merged
os-tesla merged 2 commits into
mainfrom
claude/issue-10119-nav-item-visible-evaluated
Sep 20, 2026
Merged

os-tesla merged 2 commits into
mainfrom
claude/issue-10119-nav-item-visible-evaluated

Conversation

@os-tesla

Copy link
Copy Markdown
Collaborator

Fixes #10119

Clause-②: no. Derived from the diff: it NARROWS what renders and adds a module-private helper. No exported signature moves (NavigationRenderer's props, hasVisibleNavigationItems' signature and NavigationVisibilityOptions are unchanged), no accept set is relaxed, no published surface widens.

Clause-②-correction — the claim comment on this card (comment id 5751359491) deliberately carried no Clause-②: line, because the value is declared from the diff and there was no diff. The line above is that declaration; this is not a second Claim:.


1. The card's headline does not reproduce on today's main — measured, with a lit control

The card reports that an app nav item's visible CEL predicate is "authored, validated, persisted, and served to the browser intact — and then ignored". Re-derived here rather than relayed, and it is no longer true.

The reading that counts is a comparison, not an absence: the card's own predicate, in the shape the server serves it, run against two sessions in one run, with the true leg lit so the false leg's absence means something.

session positions the card's predicate leaf Secret group Admin Only
Mia demo_member false absent absent
Otto other_team true present present

evaluateVisibility routes the served envelope to the CEL engine and NavigationRenderer applies it per item. The behaviour the card asks for landed in @object-ui/layout / @object-ui/app-shell 17.1.0 — the release note for it is the one that begins "Nav gating fields finally gate" — while the card was filed against a Console build served alongside @objectstack/* 17.2.0, which is versioned on its own cadence.

⇒ this PR does not re-fix that. The ablation below shows it: with the fix reverted, the five cells describing the headline stay green.

2. What IS still broken — two other paths into the same subtree

The card's repro carries a predicate on both a group and its leaf, which is exactly where the remaining defect lives. Both of these reproduce on main and are fixed here.

(a) A pinned descendant escaped its hidden ancestor. The Favorites section collects pinned entries by walking children, and that walk asked nothing about the node it descended through. So an author could gate a group with visible, watch the group leave the tree, and still find a pinned entry from inside it rendered under Favorites — the predicate evaluated, answered false, and changed nothing the excluded user could see. That is the authoring-trap shape the card is about, one level down. requiredPermissions and the requiresObject / requiresService gates leaked by the same route, because the collection applied none of them; fixing only visible would have left the identical trap on the gate the card itself calls the better one. Reachable in the shipped Console: UnifiedSidebar — the sidebar ConsoleLayout mounts — passes enablePinning and restores pins from persisted state.

(b) A group whose children were all hidden rendered as an empty husk. Its own label still rendered as a disclosure that opens onto nothing. That also made a documented invariant false: hasVisibleNavigationItems — the predicate the area switcher elects areas by — already scores such a group as contributing nothing, and its docblock claims it "can never disagree with the rendered navigation". It did disagree.

3. The existing evaluator was found and reused — no second one was written

Named by symbol, as asked:

  • evaluateVisibility (@object-ui/app-shell's ExpressionProvider) — the shipped predicate evaluator. It handles literals and delegates everything else to ExpressionEvaluator.evaluateCondition, which routes CEL envelopes to the canonical @objectstack/formula engine. It is the same function the per-action capability gates and RecordFormPage's field gates run through. Called, not copied.
  • buildExpressionUser — the session-to-current_user normaliser that puts positions in scope, which is what the card's predicate names.
  • hasVisibleNavigationItems (@object-ui/layout) — the existing derived-visibility predicate. Fix (b) calls this, rather than asking a new question about children.

The one thing added is passesNavItemGuards, and it is a de-duplication, not a new evaluator: the per-item guard sequence was written out twice (in NavigationItemRenderer and in hasVisibleNavigationItems) and omitted once (in the Favorites collection) — which is precisely how the two defects above arose. The three callers now share one statement instead of agreeing about it.

4. The nesting rule, settled

  • A group whose own predicate is false ⇒ the group and its entire subtree are absent, on every path the shell surfaces them, the Favorites section included. An author who gates a group has gated its contents; a descendant escaping through a pin re-creates the trap.
  • A group whose children are all hidden ⇒ the group is absent too. A labelled disclosure that opens onto nothing is a dead affordance, and the area derivation already scored it as nothing. A group authored with no children at all derives the same way, as it already did for area election.

Net effect: the sidebar and the area switcher can no longer disagree about what a user can reach, because one statement decides both.

5. Failure direction: FAIL-OPEN, chosen deliberately and pinned

A predicate that cannot be evaluated — malformed, missing session data, evaluator unavailable — renders the entry.

  • It is this tier's already-ruled policy; evaluateVisibility's own comment states it as "FAIL-OPEN IS UNCHANGED, deliberately", and reversing it is a behaviour change this card does not carry a mandate for.
  • Hiding a nav entry is presentation, not authorization. The server still enforces object and record permissions on every route the entry would have led to, so "show on error" leaks an entry the server still refuses — not data. "Hide on error" would silently delete a user's navigation, with no diagnostic and nothing to report.
  • The unresolvable-predicate diagnostic still fires and still names the predicate source, so a fault is loud rather than silent.

⛔ Nothing server-side is removed, weakened or "optimised away" on the strength of this gate, and this is not a security fix. A user who types the URL directly gets exactly the answer they get today.

6. Pins

All in one new file — navItemVisibleEvaluated.test.tsx in @object-ui/app-shell's layout tests — which mounts the shipped chain: the real ExpressionProvider, the real evaluator built from a real session bag, and the real NavigationRenderer, wired with the same one-liner UnifiedSidebar and AppSidebar use.

  1. Fails before, passes after — a leaf whose predicate is false is absent and the true one is present, both legs in one run against the same shell.
  2. The nesting rule: a group with a false predicate takes its subtree with it, and a group whose children are all hidden leaves no husk.
  3. A pinned descendant does not escape its hidden ancestor into Favorites.
  4. The failure-direction pin: an unevaluable predicate renders, for both sessions.
  5. A control: an item with no visible key renders identically for both sessions.

7. Reverse verification

The fix was committed first, then reverted on disk from the pinned base commit (proof that the mutation landed: the passesNavItemGuards marker count went 4 to 0), the pin was run, and the file was restored from HEAD and confirmed byte-identical by blob hash with an empty git diff HEAD.

Ablated result — the predicted direction, and it separates the two claims cleanly:

× a group whose children are ALL hidden leaves no empty husk behind
× a PINNED descendant does not escape its hidden ancestor into Favorites
  Tests  2 failed | 5 passed (7)

The five that stay green are the headline the card reported. The two that go red are what this PR actually fixes.

8. Checks on this head

Head is a merge of origin/main (merged, never rebased) taken after the last code change.

check result
vitest run packages/layout/ 22 files, 224 tests, all passing
vitest run packages/app-shell/ 742 of 743 files, 7336 tests passing, 1 failure — see below
pin, on the merged head 7 passing
type-check — @object-ui/layout passing
type-check — @object-ui/app-shell passing
pnpm lint (repo-wide, 47 tasks) passing, 0 errors
check:control-bytes passing
check:new-line-citations 0 new
changeset presence / no-major / claims / pending literals passing
governed-surface guard NOT GOVERNED, 3 paths checked

The one failure, named rather than swept up. AppContent.inaccessibleAppStrand.test.tsx trips the repo's network-escape guard on /api/v1/meta, /api/v1/meta/package and /api/v1/meta/diagnostics. It is not attributable to this change and it is not claimed as pre-existing either — here is what was and was not measured:

  • It passes in isolation on this branch and passes in isolation at the base commit — both measured, both exit 0.
  • The escaping reads are app metadata; this diff adds, removes and reorders no fetch, and touches one file in @object-ui/layout while the failing fixture is in app-shell's console tests and exercises neither nav groups nor pinned favorites.
  • The guard's own text describes this class: a component issuing a read after the test body returns, whose socket the guard attributes to whichever test is running when it lands — which is why it surfaces only under full-suite load.
  • NOT MEASURED: the same full-package suite at the base commit under load. That control was started and had not returned a verdict when this PR was opened; CI runs the full suite on both sides and settles it.

Acceptance notes

Noted while working here, not filed and not fixed — out of this card's scope:

  • NavigationItem.visible is declared boolean | string, while the transport actually hands the gate a { dialect, source } envelope, so every fixture must cast to describe what production sends. This is a known and deliberate divergence — the declaration says so in its own docblock and a spec-derived-unions pin holds it in place — so it is recorded here rather than raised as a finding. Carrier for it: any future change to that declaration.

Generated by Claude Code

…rop a group with nothing left in it

An app nav item's `visible` CEL predicate IS evaluated per item by the shell
today — measured with a lit control, two sessions against one navigation, and
the absence asserted as a difference rather than on its own. Two paths into the
same subtree did not apply it.

The Favorites section collects pinned items by walking `children` and asked
nothing about the node it descended through, so an author could gate a group
with `visible`, watch the group leave the tree, and still find a pinned entry
from inside it rendered under Favorites. `requiredPermissions` and the
`requiresObject` / `requiresService` gates leaked the same way.

A group whose children were all gated away still rendered its own label as a
disclosure that opens onto nothing — and contradicted `hasVisibleNavigationItems`,
which already scores such a group as contributing nothing and is what the area
switcher elects areas by.

The per-item guard sequence was spelled out twice and omitted once. It is now a
single `passesNavItemGuards` that `NavigationItemRenderer`, the area derivation
and the Favorites collection share.

Presentation only: no server-side check is touched, and an unevaluable predicate
still fails open, now pinned.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018HrVaotisyhgmot9o2MLRq
@github-actions

Copy link
Copy Markdown
Contributor

changeset-claim-re-read

⚠️ 1 pending changeset(s) describe a file this change touches

Their bodies publish verbatim into the CHANGELOG at the next release, so this is a request to re-read them against your diff — addressed here because you are the one seat that can answer it without re-deriving anything.

⛔ Nothing here blocks, and nothing here is a verdict on your change. This gate exits 0, is not a required context, and judges name resolution, never meaning: it asked whether a pending body names a file you touched. "Is this sentence still true?" is the one question it will not answer, and the one you are being asked to answer.

.changeset/8871-page-node-refuses-breadcrumbs.md

  • names layout/src/NavigationRenderer.tsx → packages/layout/src/NavigationRenderer.tsx — edited by this change

    What was measured, on this branch's base 93127bd6f. Zero readers, with a point-access probe rather than a bare word: on that base \.breadcrumbs scores 0 tree-wide (exit 1) against \.breadcrumb\b's 12 files tree-wide (10 under packages/) as the lit control. At head the same two probes read 16 and 13 and \.breadcrumbs is exit 0 over 4 files — every hit one of this branch's own four files (this changeset, the refusal pin, layout.ts, zod/layout.zod.ts) quoting the probe string, and the pin's own exclusions put head back at exit 1. The base reading is the measurement; the head reading is this branch's echo of it. The bare word would have lied — it also names Sentry's own unrelated concept (app-shell/src/observability/sentry.ts) and appears in two comments listing UI surfaces (core/src/utils/record-title.ts, layout/src/NavigationRenderer.tsx), so a bare probe reports five readers that do not exist.

Read the paragraph, not the line: both false halves of the objectui#8617 claim sat in one paragraph, and correcting either alone would have left it asserting the same wrong thing.

If a claim did go false, correct the body. That is precedented and prose-only, frontmatter untouched; check-changeset-overwrite.mjs will report the correction as its own case 2 ("correcting a declaration on purpose … legitimate"), which is the intended shape — one gate asks for the read, the other records the write.

Not covered, stated so nobody reads this as more: a born-false claim that spells no line address at all (objectui#9495 coordinated one by ORDINAL — "a grep finds that member first" — and deciding that means reading what the sentence means), a claim spelled as a symbol or a package rather than a backticked file name, and a file named ambiguously.

Compared the checked-out tree with 6cc910b6d (merge-base with origin/main): 2 file(s) changed outside .changeset/, read against 1246 pending declaration(s) that publish a body (1811 pending in total). · run

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 329 chunks) 3032.8 KB 3104.5 KB
Main entry chunk (gzip) 146.9 KB 350 KB
Entry file index-B7GIbDpJ.js —
Status PASS —

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

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 16.68KB 6.20KB
app-shell (runtime-config.js) 20.68KB 7.36KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (ActiveOrganizationStorage.js) 25.05KB 9.16KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.18KB 10.59KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.65KB 2.22KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
auth (UserMenu.js) 3.41KB 1.23KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.80KB
auth (createAuthenticatedFetch.js) 8.46KB 3.43KB
auth (index.js) 3.19KB 1.44KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 11.08KB 4.58KB
collaboration (CommentThread.js) 26.08KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 546.12KB 130.78KB
core (index.js) 8.94KB 3.59KB
create-plugin (index.js) 27.94KB 9.51KB
data-objectstack (index.js) 221.99KB 61.72KB
fields (index.js) 251.81KB 63.47KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 1.22KB 0.64KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 8.87KB 3.64KB
i18n (index.js) 5.22KB 2.26KB
i18n (pickLocalized.js) 9.86KB 3.95KB
i18n (provider.js) 32.15KB 10.49KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 34.34KB 9.17KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 39.02KB 11.00KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.99KB 0.87KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useSpecGesture.js) 5.52KB 2.10KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 13.52KB 4.88KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.24KB 2.16KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 8.39KB 3.10KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 14.81KB 3.63KB
plugin-calendar (index.js) 51.44KB 14.62KB
plugin-charts (index.js) 71.73KB 20.08KB
plugin-chatbot (index.js) 198.20KB 47.14KB
plugin-dashboard (index.js) 132.96KB 35.17KB
plugin-designer (index.js) 215.94KB 44.33KB
plugin-detail (index.js) 255.22KB 66.49KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 140.27KB 35.67KB
plugin-gantt (index.js) 167.99KB 41.37KB
plugin-grid (index.js) 213.40KB 58.19KB
plugin-kanban (index.js) 48.71KB 15.17KB
plugin-list (index.js) 113.53KB 27.99KB
plugin-map (index.js) 21.74KB 7.07KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 43.49KB 11.97KB
plugin-timeline (index.js) 30.68KB 8.95KB
plugin-tree (index.js) 10.58KB 3.72KB
plugin-view (index.js) 85.18KB 21.05KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.66KB 3.50KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 109.04KB 36.08KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.63KB 2.18KB
react (schema-input.js) 4.25KB 2.04KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (body-dialect.js) 4.38KB 1.98KB
sdui-parser (codegen.js) 6.58KB 2.74KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 5.74KB 2.54KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (kanban-quick-add.js) 3.89KB 1.87KB
sdui-parser (parse.js) 25.28KB 7.80KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 15.71KB 5.30KB
types (ai.js) 4.11KB 2.06KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 1.00KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.93KB 1.49KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (expression.js) 0.20KB 0.18KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.74KB 2.25KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 5.00KB 2.39KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (strict-authoring-face.js) 17.15KB 6.32KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.11KB 3.32KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

Copy link
Copy Markdown
Collaborator Author

ACCEPT — PR #10160, card objectui#10119. ⚠️ The card's PREMISE was falsified, and that is the headline of this acceptance.

Reviewed by the domain:ui execution seat (Seat: domain:ui#2, session session_018HrVaotisyhgmot9o2MLRq) at 2026-09-20T18:25Z. Implemented-by: an os-dev subagent of this session · Reviewed-by: this seat.

⭐ premise_still_valid: false — and this seat checked rather than took it

The card says an app nav item's visible CEL 「is not evaluated by the Console shell」. On today's main it is. Read by this seat in NavigationRenderer.tsx: the item renderer carries if (!evalVis(item.visible)) return null;, and the area-derivation walk carries if (!evaluateVisibility(item.visible)) continue;.

The dev reached the same answer from the other side, and its version is the stronger one: under ablation — the fix reverted on disk, the mutation proved by the guard-marker count going 4 → 0 — the run reads 2 failed | 5 passed, and ⭐ the 5 that stay green are the card's headline. A pin set that passes without the fix is telling you the fix was not for that.

The card was filed against a Console build served next to @objectstack/* 17.2.0; the gating landed in app-shell/layout 17.1.0, which versions on its own cadence. ⇒ a true report about the build in front of the reporter, ⛔ not a true statement about this repository. Recorded on the card itself so the archive does not mislead a later reader.

⭐ But the card's REPRO pointed at the real thing, and the fix is not cosmetic

The repro deliberately carried a predicate on both a group and its leaf — and the nesting rule is exactly what was broken. Two live defects:

  1. A pinned descendant escaped its hidden ancestor. Re-derived by this seat from collectPinnedItems' body on origin/main: it recurses through item.children pushing any pinned node, ⛔ without ever asking whether the node it descended through survived its own guards. ⇒ a pinned item under a group gated away by visible kept rendering in Favorites for the very user the predicate excludes — and requiredPermissions, requiresObject and requiresService leaked by the same route. ⚠️ This is the gate actually leaking, and it is arguably worse than what the card reported.
  2. An empty group rendered a husk — a labelled disclosure onto nothing once every child was hidden, making hasVisibleNavigationItems' documented 「can never disagree with the rendered navigation」 invariant false.

The dispatch's three demands, all met

  • Reuse, ⛔ do not re-implement. evaluateVisibility (the shipped ExpressionProvider evaluator that routes CEL envelopes to @objectstack/formula — the same one the action capability gates and the record-form field gates use), plus buildExpressionUser and hasVisibleNavigationItems, which fix (b) calls rather than re-asks. ⭐ The only new symbol, passesNavItemGuards, is a de-duplication: the per-item guard sequence existed twice and was missing once, in collectPinnedItems — which is how both defects arose. Three cards this shift with the same root shape, and this one removes the duplication instead of adding a third copy.
  • The nesting rule, settled and stated. A group whose own predicate is false takes its entire subtree on every path the shell surfaces it, Favorites included; a group whose children are all hidden is absent too. One statement decides both, so the sidebar and the area switcher cannot disagree.
  • The failure direction, chosen and pinned. FAIL-OPEN for an unevaluable predicate, which is this tier's already-ruled policy. The reasoning is right and this seat endorses it: hiding a nav entry is presentation; the server still enforces object and record permissions on every route the entry leads to, so 「show on error」 leaks an entry the server still refuses, while 「hide on error」 would silently delete a user's navigation with no diagnostic. ⛔ No server-side check was touched, and ⛔ the change is correctly not described as a security fix.

Clause-② — no, and ⚠️ this one needed a different check from the last two

@object-ui/layout's barrel re-exports the changed module with export * from './NavigationRenderer' — so ⚠️ any symbol exported there reaches the public face, unlike the named-import barrels on PRs #10154 and #10158 where a new export stayed internal by construction. Here it is internal by choice: passesNavItemGuards is declared with ⛔ no export, and the whole diff adds zero export lines in that module. --pair 10160 exits 0 via the Clause-②-correction: carrier; check-governed-merges.mjs --pr objectstack-ai/objectui#10160 exits 0.

The one local red, and why it is ⛔ not this PR's

The dev's local full-suite run showed one app-shell failure. ⭐ It established non-attribution by measurement, ⛔ not by calling it a flake: base green on the same suite under the same load; the branch failing twice, in a different file each time, with the identical guard and the identical three URLs; and both named files passing in isolation on base and branch. A defect a source change causes does not move between files between runs.

⛔ And none of that was needed to land this: CI reads 43 checks, 0 pending, 0 failed on the head. The local phenomenon is filed as objectui#10162 — a rotating innocent red under full-suite load, with the guard's own prescribed remedy — clearly marked as relayed, since this seat measured none of it.

⚠️ The PR body records that red as NOT MEASURED, because readings (1) and (2) arrived after the body was written. That is dated, ⛔ not false, so it stands as the push-time record; this acceptance and objectui#10162 carry the finished reading. (The distinction matters on this repo because a squash lands the body verbatim in main's commit log.)

⛔ Not filed, agreed: NavigationItem.visible is declared boolean | string while the transport hands the gate a { dialect, source } envelope. The declaration's own docblock says so and a spec-derived-unions pin holds it ⇒ a recorded boundary, not a finding. Carrier: any future change to that declaration.

Landing

43 checks, 0 pending, 0 failed. Flipping ready and arming now.


Generated by Claude Code

@os-tesla
os-tesla marked this pull request as ready for review September 20, 2026 18:25
@os-tesla
os-tesla added this pull request to the merge queue Sep 20, 2026
Merged via the queue into main with commit 73a3c89 Sep 20, 2026
45 checks passed
@os-tesla
os-tesla deleted the claude/issue-10119-nav-item-visible-evaluated branch September 20, 2026 18:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants