Skip to content

fix(app-shell): report a faulting nav/area/field visible predicate in both builds - #6486

Merged
os-support-ai merged 2 commits into
mainfrom
claude/issue-6443-nav-visible-fault
Aug 26, 2026
Merged

fix(app-shell): report a faulting nav/area/field visible predicate in both builds#6486
os-support-ai merged 2 commits into
mainfrom
claude/issue-6443-nav-visible-fault

Conversation

@os-support-ai

Copy link
Copy Markdown
Collaborator

Fixes#6443

ExpressionProvider.evaluateVisibility is the gate behind a navigation item's visible, an
area's derived visibility, and the field list RecordFormPage renders. It is fail-open, so a
predicate that cannot be evaluated returns true and the item renders for everyone —
including the role it was written to exclude
— looking exactly like an entry the author meant
to show.

The fault was swallowed one layer down. evaluateCondition is fail-soft: it answers an
unevaluable predicate with true from its owncatch and does not throw, so this site's
try/catch never saw a predicate fault at all. EvaluationOptions.onFault (the seam #6038
landed) is the only channel that reaches it, and it costs no extra engine call.

The decision this card left to the dev: what stands in for node type/id

The reporter dedupes on (type, key, predicate source)id rides along in the printed line
but is not in the key. A nav item is not a schema node, so type had to be chosen, and it
sets the rate limit. The choice is the constant app-shell:visible, id omitted.

Why a constant rather than the item's identity, against the three cases that can occur:

caseconstant typeper-item type
two entries, two different broken predicates2 lines2 lines — no difference, the keys already differ on source
two entries sharing one broken predicate (the copy-pasted role gate)1 line — one typo, one string, one editN lines, same text and same reason repeated
one entry, re-evaluated many times1 line1 line

A per-item key is never better and sometimes much worse. Measured rather than asserted: the
rate-limit pin drives the real AppSidebar composition — areas.filter(a => hasVisibleNavigationItems(a.navigation, …)), the derivation #3311 added — and counts 6
evaluations of one faulting predicate
across three areas over two passes, all collapsing to
one line. The count is asserted, so the cell cannot pass on a site that is entered once.

A per-item key is also unreachable without widening VisibilityEvaluator (@object-ui/layout),
whose whole signature is (expression) => boolean — a cross-package public type change a
diagnostics-only card does not get to make. The predicate source is the locator instead:
it is in both the key and the line, and it is the string an author greps their metadata for.

Not nav:item, deliberately, even though the card is written about nav and area items:
evaluateVisibility is also the gate over an object's field visible predicates in
RecordFormPage, and labelling those "nav" would be false. Stated rather than hidden: a nav
item and a form field carrying the identical broken predicate text share one dedupe entry.
The colon keeps the label out of the bare registry namespace — app-shell is deliberately not
a component key (#4841), so a diagnostic must not read as claiming one.

Fail-open is UNCHANGED — this is diagnostics only

The item still renders for everyone on a fault, on every path and dialect. Flipping that to
fail-closed would be a permission-boundary change wearing an observability costume, and it is
not this card's to make. A control cell carries no console assertion at all so it cannot go
red for the reason the discriminating cells do: it asserts the observable outcome — the nav item
survives the real hasVisibleNavigationItems guard for the excluded role — and it is green
against main and after.

Measured per dialect, at this site

dialectbeforeafter
bare string (what a live gate was measured breaking on)nothingone named line
{ dialect: 'cel' } envelopeone generic lineone named line — the generic one is replaced, not added to (total stays 1)
${…} templateone generic line per evaluationone named line, deduped

Reverse verification — direction predicted before the run

Predicted: restoring ExpressionProvider.tsx to the pinned base turns red exactly the 8
discriminating cells (reports() → 0 in each), leaves the 4 controls green, and moves the
${…} cell in a second, independent direction — total console lines 1 → 3.

Observed, exactly: Tests 8 failed | 8 passed (16), the 8 failures being the 8 discriminating
cells and the 8 passes being the 4 controls plus all 4 cells of the pre-existing suite. The
template cell's second direction was measured after reordering its assertions so it is not
short-circuited: AssertionError: expected [ …(3) ] to have a length of 1 but got 3.

Mutation proven on disk by grepping the deleted and the restored text (onFault: report 0,
APP_SHELL_VISIBLE_SURFACE 0, reportUnresolvableVisibilityPredicate 0, old bare call 1) —
never a diffstat, never an editor's exit code. Restore proven by blob hash
(BACK_BLOB == HEAD_BLOB == 1c18358ca…) plus an empty git diff HEAD, under an EXIT/INT/
TERM trap holding absolute paths. Vitest resolves workspace packages to src through the repo
alias map, so no dist staleness is involved on this leg.

Why this pin has to exist, as a reading rather than a claim: the neighbouring suite
ExpressionProvider.evaluateVisibility.test.ts passes green against the defect — all 4 of
its cells passed in the ablation run. Its fails open (visible) on an unevaluable predicate
case asserts the exact verdict the broken site returns. The observable difference was on the
console and nothing was looking there.

Degenerate controls, named: the positive/degenerate console-capture pair and the two
behaviour controls above pass both ways on purpose. They guard the future wrong shapes — a spy
that observes nothing (which would make every toHaveLength(0) here vacuous), a reporter that
fires on a genuine false, and a diagnostic change that also flips fail-open to fail-closed.

Clause ② — published surface, stated in as many words

The surface is NOT widened.git diff of every **/src/index.ts from the pinned base is
empty, and — because an empty entry diff does not settle it on its own — the whole
packages/ diff contains no added or removed line carrying an export declaration (the
single export match is a word inside a test comment). No member was added to any type the
entry already exports by name: the emitted dist/providers/ExpressionProvider.d.ts still
declares exactly ExpressionContextValue, ExpressionProvider, useExpressionContext and
evaluateVisibility, the last with a byte-identical signature. The one new module-level binding
(APP_SHELL_VISIBLE_SURFACE) is deliberately not exported and does not appear in the
declaration face.

Gates — verdict lines, at 7ada702b8 (the final commit)

Exit codes captured before any pipe; each line below is the gate's own, not a shell $?.

  • pins + pre-existing suite: Test Files 2 passed (2) / Tests 16 passed (16)
  • affected-package tests, narrowed: Test Files 24 passed (24) / Tests 255 passed (255)
  • type-check (tsc --noEmit && tsc -p tsconfig.test.json): exit 0, script name echoed. The
    new pin is in the program — confirmed with --listFiles, not assumed.
  • lint, run in full rather than narrowed: eslint . over the file set eslint's own config
    selected — 3811 files, 0 errors, 11046 pre-existing warnings. The changed source file adds
    zero new warnings (its 10 are pre-existing no-explicit-any on the untouched interface and
    the 2 pre-existing react-refresh/only-export-components); the new test file adds 7
    no-explicit-any, matching its neighbour's idiom.
  • check:control-bytes✅ OK (scanned 5352 tracked text file(s); skipped 85 binary), plus a
    direct grep -naP over exactly the 4 changed files: clean.
  • check:phantom-deps✅ Every in-scope import is declared by the package that publishes it.
  • check:self-import✅ No package names itself inside its own src/.
  • check:doc-fences✅ every TypeScript block in 223 document(s) is fenced ts/tsx/typescript…
  • check:doc-types✅ Every documented component type is registered.
  • check:doc-snippetsSemantic phase: 267 of 267 block(s) judged, 0 failed.
  • check:readme-exports✅ OK (… 378 self-imports judged (378 real, 0 wrong-path, 0 fabricated) … 0 unbuilt …)
  • check:esm-specifiers, check:node-esm-load, check:entry-guard, check:published-dist,
    check:pre-install-import-graph, check:vi-mock-specifiers, check:eager-closure,
    check:docs-route-closure, check:shell-escape-residue, check-doc-links — all exit 0.
  • one-authority-per-exported-name (the KNOWN_COLLISIONS shrink-only baseline): Tests 11 passed (11). No baseline was extended — nothing here adds an exported name, and adding a
    line to a shrink-only baseline is a maintainer decision, not a remedy a dev may take even when
    a gate prints it ready to paste.
  • changeset guard — ✅ No changeset declares a major bump.

Two gates first came back non-zero as prerequisite-not-met, not verdicts, and are recorded
that way: check:doc-snippets exited 2 saying in as many words "This is 'I could not run',
NOT 'I ran and found errors'", and check:readme-exports listed only "type entry … is not on
disk". Both were unbuilt-dist preconditions; after building what they name, both are green
above.

Scope

Only evaluateVisibility's reporting changed. #6444 and #6445 — the other two of the #6038
census trio — are untouched and keep their own cards; #6444 is not addressed here, and the
${…} reading above is that defect being transferred away from at this one site, not fixed.

skills/** is not touched by this diff, so the published-skill budget clause does not apply.


Generated by Claude Code

…in both builds
`ExpressionProvider.evaluateVisibility` is the gate behind a navigation item's
`visible`, an area's derived visibility, and the field list `RecordFormPage`
renders. It is fail-open, so a predicate that cannot be evaluated returns `true`
and the item renders for everyone — including the role it was written to
exclude — looking exactly like an entry the author meant to show.
The fault was swallowed one layer down: `evaluateCondition` is fail-soft and
answers an unevaluable predicate with `true` from its OWN catch without
throwing, so this site's `try/catch` never saw a predicate fault at all.
Measured per dialect at this site, the bare-string form — the one a live gate
was measured breaking on — printed nothing whatsoever, in either build.
Wires `EvaluationOptions.onFault` to `reportUnresolvableVisibilityPredicate`
from `@object-ui/react`: the same reporter, message, severity, dedupe Set and
rate limit the node gate and `page:tabs` already use, at the same single engine
call (no `throwOnError` double evaluation). The reporter's `type` slot — the
dedupe key, with the gate key and the predicate source — is the constant
`app-shell:visible`, so the rate limit is one line per distinct authored
predicate source rather than one per menu entry.
Observability only: fail-open is unchanged on every path and dialect.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SfZeFWrhGLHmfq61xbz4q
The cell that guards "this card does not flip fail-open to fail-closed" carried
a `reports()` assertion, which made it red against `origin/main` for the same
reason every discriminating cell is — so it could not do the job a control
does. The console assertion moves to the bare-string cell, which now pins the
log and the observable outcome together: a fault is reported AND the item is
still visible to the role the predicate was written to exclude.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SfZeFWrhGLHmfq61xbz4q
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 52 chunks)3234.5 KB3266.6 KB
Main entry chunk (gzip)157.4 KB350 KB
Entry fileindex-gSto6XH5.js
StatusPASS

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

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)11.30KB4.28KB
app-shell (runtime-config.js)18.10KB6.51KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)505.99KB114.64KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)173.10KB47.96KB
fields (index.js)238.89KB60.02KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)9.53KB3.38KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)4.64KB1.50KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)1.93KB0.88KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.66KB12.84KB
plugin-charts (index.js)64.66KB18.32KB
plugin-chatbot (index.js)188.60KB44.82KB
plugin-dashboard (index.js)133.48KB34.49KB
plugin-designer (index.js)211.90KB42.74KB
plugin-detail (index.js)245.26KB62.38KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)131.78KB32.19KB
plugin-gantt (index.js)164.14KB39.87KB
plugin-grid (index.js)201.66KB54.57KB
plugin-kanban (index.js)53.16KB14.65KB
plugin-list (index.js)112.74KB27.50KB
plugin-map (index.js)20.09KB6.62KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)26.72KB7.71KB
plugin-tree (index.js)9.26KB3.13KB
plugin-view (index.js)84.82KB20.79KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)56.69KB19.03KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)2.05KB1.04KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)12.13KB3.65KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)7.54KB2.63KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

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

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A nav/area item visible predicate that faults is silent in BOTH builds — ExpressionProvider.evaluateVisibility swallows it with no diagnostic

2 participants

@os-support-ai@claude