Skip to content

Console Home honours features.aiStudio for the authoring front door (#5521) - #5575

Merged
os-sales merged 1 commit into
mainfrom
claude/issue-5521-console-home-feature-flags
Aug 21, 2026
Merged

Console Home honours features.aiStudio for the authoring front door (#5521)#5575
os-sales merged 1 commit into
mainfrom
claude/issue-5521-console-home-feature-flags

Conversation

@claude

@claudeclaudeBot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Part of #5521

Verified at a84d94d8e — every gate verdict below was read from a run on that
exact tree, after the final commit.

Part of, not Fixes: this card names four cards and I close one. Two were
already closed by work that landed after the card was filed, and the fourth is
carved out by the card itself pending a product ruling on cloud#1452. Details
below — the card should stay open for that fourth row.

⛔ Read this first — the declared file fence names a directory that does not contain the console home page

The dispatch fenced this card to apps/console/**. The console home page
component is not there. It is:

packages/app-shell/src/console/home/HomePage.tsx

re-exported to the console as DefaultHomePage (packages/app-shell/src/index.ts:216),
which apps/console/src/App.tsx:125 renders. There is no home page component
anywhere under apps/console/**, so the fence as literally written is
unsatisfiable — no one could close this card inside it.

I proceeded rather than stopping, and the reasons are checkable rather than a
preference:

  • The fence's noun is "the console home page component + its tests + a
    changeset". I changed exactly that: one component, one new test file, one
    changeset
    . Nothing widened; the shape of the declared surface is honoured
    exactly, only the path differs.
  • The dispatcher pre-announced the path as unverified — "I did not pin the
    home page file, deliberately … Locate the home component yourself and
    re-derive; do not trust a path from me here."
  • The fence's other job — collision avoidance — was re-derived for the real
    path, because the dispatcher's serial-constraint clearance covered
    apps/console/** and is void here. No open PR touches
    packages/app-shell/src/console/home/
    : the only open PRs are Pin @objectstack/* to 17.1.0 #5529 (the
    17.1.0 pin, draft), chore: release packages #5400 (the changesets release PR) and two dependabot
    bumps. The prior change to this exact file, Console: OS_CLOUD_URL=off is a disabled state, not a red load failure #5517 (objectui#5504), is merged.

Flagging rather than silently absorbing: @object-ui/app-shell is a published
library
, not an app, so this is a minor on a package the whole workspace
consumes — a materially different risk class from the apps/console diff the
fence anticipated. #5517 made the identical class of change in the identical
file, so this is the normal route, but the reviewer should know the fence moved.

Premise re-verification: 2 of the card's 4 rows are already fixed

Checked against origin/main at ac73c24b0, not relayed from the card. The
card's headline claim — "two of them contradict a flag the server already sends
as false"
, called "the sharpest form of this bug" — is no longer true. Those
two were closed by #5517 (objectui#5504), which landed after this card was filed
and which the card itself named as an in-flight sibling to sequence behind.

cardcard's claimon ac73c24b0
Browse App Marketplacefeatures.marketplace unread — "a missed read"already read — the strip shortcut is withheld on marketplaceEnabled
Start with a templatefeatures.installLocal/marketplace unreadalready readdisabled={!canAuthorMetadata || !marketplaceEnabled} + a visible localized reason
Build an appfeatures.aiStudio and/or studio.access unreadlive — gated only on manage_metadata, which fails open
Setupneeds a product ruling firstuntouched, per the card's own carve-out

So one row survives, and this PR closes it.

The fix

HomePage now reads features.aiStudio — a flag the server was already
sending
and this page was not consuming. No new authorable config key (the
ruling forbids minting one, and none was needed), no new server surface, no new
i18n copy, and no locale table touched.

Why HIDDEN rather than dimmed

This was left to my judgment with a stated reason required. The reason is the
flag's own declared meaning, on both sides of the wire, rather than a
presentation preference:

  • RuntimeFeatures.aiStudio (packages/app-shell/src/runtime-config.ts:28):
    "When false, the SPA hides the AI authoring affordances."
  • the serving plugin, quoted in the card: "set false to force-hide the
    authoring UI."

Honouring what the producer declares its flag to mean is the whole point of
reading it. Substituting a dimmed card would be re-deciding the contract at the
consumer.

It is also the honest answer here, and the distinction is the one #5533/#5557
are about — reporting the wrong kind of answer:

  • !canAuthorMetadata is a fact about this principal. A dimmed card plus a
    reason line tells an admin something actionable about their own account. That
    case correctly stays disabled-with-reason; I did not touch it.
  • aiStudio: false is a fact about the deployment. Authoring exists for
    nobody there — no permission to acquire, no admin to ask. A permanently greyed
    front door advertises a room that was never built, which is exactly Marketplace catalog page tells a NON-ADMIN "access denied" on a runtime that has no marketplace at all #5557's
    "access denied for a surface that exists for nobody", applied to a card
    instead of a page.

Consistent with that, no reason line is rendered in its place.
home.build.noCapability says the account lacks "Manage Metadata"; printing
it to an admin who holds it, on a runtime that has no authoring at all, would be
the same misdirection.

What was deliberately NOT swept in

  • "Start with a template" — installing a marketplace package is not AI
    metadata authoring, it answers to its own flags, and Console: OS_CLOUD_URL=off renders the marketplace as a red load-failure with wrong guidance, and the home page still promotes the marketplace entry #5504 already ruled
    disable-and-explain for it. Its flag means reachability; this one means
    force-hide. Two flags, two declared meanings, two presentations. A test pins
    that the sibling still stands.
  • "Setup" — the card carves it out explicitly, pending a ruling recorded on
    cloud#1452. Not folded in.
  • HomeAiActions' "Build with AI" — already gated on the live agent
    catalog (buildAvailable), and surfaceAgent.ts already downgrades build→ask
    on aiStudioEnabled: false. Correct as-is.
  • The backend. The 403 on /api/v1/meta/* and the 404s are the other half
    of the criterion and are not softened by anything here.

studio.access — the half I did NOT do, and why it is a separate card

The dispatch asked me to reuse holdsStudioAccess from
apps/console/src/components/studioEntry.ts, per #5563's precedent. That
import is impossible in the direction required
: HomePage lives in
@object-ui/app-shell, and apps/consoledepends on app-shell, not the
reverse. app-shell's manifest lists no console dependency and could not without
a cycle.

The three ways out are all worse than filing it:

  1. re-derive the predicate in app-shell — precisely the drift Approvals Inbox: the raw payload panel is a platform-operator affordance, not the approver's read path #5563 argued
    against ("one definition of 'is this principal a platform operator'");
  2. lift it into @object-ui/permissions and re-export from studioEntry
    correct, but touches three packages and is nobody's declared surface here;
  3. leave it, which is what I did.

It is also less urgent than it looks: the card's table offers features.aiStudio
and/orstudio.access, /studio is already hard-gated fail-closed by
StudioRoute + useStudioEntry, and coupling a library's home page to the
console's route policy is a design question, not a mechanical edit. Filed
with the dependency evidence; the residual it leaves is narrow — a runtime with
aiStudio: true whose permissions endpoint fails open, where the card stays live
and the click bounces at StudioRoute.

Tests, and how the vacuum is guarded

HomePage.aiStudioDisabled.test.tsx, 7 cases. The acceptance condition is that
something does not render, which an empty page reproduces perfectly, so:

  • every denial case also asserts the furniture it is denying around — the app
    tile the strip renders (app-tile-crm) and the sibling template cover;
  • the counter-probes drive the same fixture through the same helper with the
    flag flipped and find the card;
  • the fixture's principal holdsmanage_metadata, so the principal half of
    the gate is wide open and only the deployment flag can move the verdict —
    without that, every denial would pass for the wrong reason.

An empty render fails the denials' counter-assertions; a gate stuck open fails
the denials; a gate that swept the whole cover fails the sibling assertion. None
of the three can pass alone.

Every case boots the genuine initRuntimeConfig() over a stubbed
GET /api/v1/runtime/config rather than mocking an accessor — mocking it would
pass against a page that never asks the server anything. The features fixture
is a spread partial, not named booleans, because the fail-open case turns on
absent vs false and a aiStudio: boolean signature could not express it.

A real defect the counter-probes caught

The first draft read getRuntimeConfig().features.aiStudio. That crashed 29
tests across 4 neighbouring suites
: four sibling suites mock the module as
getRuntimeConfig: () => ({ branding: … }) with no features key at all, so
features is genuinely undefined on real code paths. The shipped read is
features?.aiStudio !== false, copied from isMarketplaceEnabled()'s own body
rather than invented — both the optional chain and the !== false are
load-bearing, and an absent flag now fails open.

Ablation

Pure source — and the reason is checkable, not assumed: the root
vitest.config.mtsresolve.alias maps every @object-ui/* specifier at that
package's src (including @object-ui/app-shell), and the suite imports
../HomePage relatively. No dist artifact sits between the edit and the run, so
this cannot go falsely green on a stale build.

The prediction was written to disk before the run, naming both the count and
the individual cases, so it was falsifiable in both directions: 2 red / 61
green; more than 2 means the gate sweeps something it should not, fewer than 2
means a case passes vacuously.

The mutation ablates the read itself
getRuntimeConfig().features?.aiStudio !== falsetrue, i.e. exactly the
missed read this card is about. It was proven on disk before the measurement,
anchored on the text in both directions: the real expression 1 → 0, the mutant
0 → 1, with git diff --stat showing the single-line change. An editor exit
code proves nothing — a zero-hit replace exits 0 with the file untouched — so the
script aborts with readings VOID unless both counts land.

Observed: exactly 2 red / 61 green, and the two are the two named cases
withholds the "Build an app" cover entirely and withholds authoring while keeping objectui#5504's explained marketplace state. Prediction held in both
direction and count. The script carried trap … EXIT INT TERM; the tree came
back byte-identical (git status --short and git diff --stat both empty).

Gates run locally at a84d94d8e

Exit codes captured before any pipe, verdicts quoted from each gate's own
output line.

GateVerdict
packages/app-shell/src/console/home vitestTest Files 10 passed (10) · Tests 63 passed (63)
@object-ui/app-shell type-checkexit 0 (tsc --noEmit && tsc -p tsconfig.test.json echoed — not a zero-match pass)
eslint, changed files0 errors (7 pre-existing no-explicit-any warnings)
check-control-bytes✅ OK (scanned 4630 tracked text file(s); skipped 85 binary)
check-changeset-presence✅ 2 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)
check-changeset-no-major✅ No changeset declares a major bump.
check-changeset-fixed✅ All workspace packages are in the changeset fixed group.
check-i18n-call-site-keysexit 0 — 2505 call sites compared
check-i18n-en-driftexit 0 — No en value changed in this range.
check-i18n-dead-keysexit 0 (a report, not a gate; its one hit is a pre-existing unrelated file)

The i18n gates are green and vacuous by construction: this diff adds no
t() call site and removes none — the card is gated at the JSX, so
home.build.title/.subtitle remain live call sites for every runtime that has
AI Studio. No locale pack is touched.

The test run is a declared narrowing, stated so it is checkable rather than
buried. I ran the console/home directory (10 files / 63 tests), not the whole
@object-ui/app-shell package. Two reasons, one of them a live incident:

  • Containment is measured, not assumed.git grep for
    DefaultHomePage|from '.*HomePage' across packages/ and apps/ returns
    exactly four hits outside console/home/: apps/console/src/App.tsx (the
    render site) and two console suites that stub it
    (DefaultHomePage: stub('home-page')). Nothing else mounts this component, so
    no suite outside the directory can observe the change.
  • I started the full-package run and stopped it deliberately. It held the
    shared container's verify lock for 650 s without emitting a single test
    marker
    (still transforming) and had a sibling agent's job queued behind it.
    That is the long-holder pathology os-verify-lock.sh exists to surface, for a
    farm CI runs on every push regardless. I stopped it by task id — never by
    process name, which would have taken the sibling's vitest with it — and the
    lock passed straight to the queued job.

Lint is a declared narrowing, and it is sound rather than merely cheap: the
population came from ESLint's own config resolution over the changed files (2
files, count read from --format json), and eslint.config.js declares no
projectService and noparserOptions.project — grep for
projectService|parserOptions|project: returns no match — so linting is not
type-aware and a 2-file diff cannot move the verdict of a file it does not
contain. All 6 HomePage.tsx warnings sit at lines 185–311, ahead of my first
added block at line 382; the one warning in the new test is the (app: any)
mock signature copied verbatim from the sibling suite. The repo-wide pnpm lint
and pnpm type-check farms are CI's run and are not duplicated here.

Scope

Exactly 3 files: HomePage.tsx, its new test, and a changeset — the declared
shape, at the path the component actually lives at (see the fence note above).

runtime-config.ts was left read-only. Reading the flag inline rather than
adding an isAiStudioEnabled() sibling to isMarketplaceEnabled() is a
deliberate trade recorded in a code comment: the accessor is the better
factoring, but adding it would mean editing four neighbouring suites' module
mocks to teach them the new export. Filed as a follow-up rather than ridden in
here.


Generated by Claude Code

…ing front door (objectui#5521)
"Build an app" is withheld when the runtime reports features.aiStudio: false.
Hidden rather than dimmed, because that is the flag's own declared meaning on
both sides of the wire ("the SPA hides the AI authoring affordances" /
"set false to force-hide the authoring UI"). Unknown fails OPEN.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012u2pRjcqAYtoEjgr3wwhnK
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 52 chunks)3784.8 KB3867.2 KB
Main entry chunk (gzip)151.2 KB350 KB
Entry fileindex-enO6u1Ji.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 (index.js)10.04KB3.72KB
app-shell (runtime-config.js)8.91KB2.99KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)1.17KB0.53KB
auth (AuthProvider.js)29.34KB7.05KB
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)6.35KB2.43KB
auth (index.js)2.77KB1.22KB
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.02KB0.89KB
auth (useIsWorkspaceAdmin.js)3.04KB1.45KB
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)506.94KB113.63KB
core (index.js)4.51KB1.80KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)159.80KB44.33KB
fields (index.js)237.61KB59.63KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.22KB3.08KB
i18n (provider.js)23.13KB7.63KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)30.51KB7.57KB
i18n (useSafeTranslation.js)7.77KB3.13KB
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.35KB3.31KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)4.42KB1.42KB
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.81KB0.83KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.62KB12.83KB
plugin-charts (index.js)64.72KB18.35KB
plugin-chatbot (index.js)181.21KB43.14KB
plugin-dashboard (index.js)128.51KB32.96KB
plugin-designer (index.js)212.39KB42.83KB
plugin-detail (index.js)242.15KB60.89KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)125.07KB30.43KB
plugin-gantt (index.js)164.10KB39.87KB
plugin-grid (index.js)200.79KB54.26KB
plugin-kanban (index.js)52.93KB14.60KB
plugin-list (index.js)111.70KB27.17KB
plugin-map (index.js)20.06KB6.62KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.49KB11.93KB
plugin-timeline (index.js)26.68KB7.66KB
plugin-tree (index.js)8.50KB2.88KB
plugin-view (index.js)84.48KB20.67KB
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)3.77KB1.33KB
react (SchemaRenderer.js)43.66KB14.77KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.33KB0.69KB
react (schema-input.js)1.45KB0.83KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (index.js)4.77KB2.16KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)10.76KB3.17KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.29KB0.24KB
sdui-parser (validate.js)6.92KB2.40KB
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)0.20KB0.18KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)0.20KB0.18KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.87KB0.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-retry.js)4.32KB2.02KB
types (index.js)3.08KB1.53KB
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 (system-fields.js)3.33KB1.54KB
types (theme.js)0.20KB0.18KB
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

@os-sales
os-sales marked this pull request as ready for review August 21, 2026 14:43
@os-sales
os-sales added this pull request to the merge queueAug 21, 2026
Merged via the queue into main with commit f4becceAug 21, 2026
23 checks passed
@os-sales
os-sales deleted the claude/issue-5521-console-home-feature-flags branch August 21, 2026 14:44
os-sales added a commit that referenced this pull request Aug 21, 2026
… two inline spellings
`features.marketplace` had a documented accessor carrying the fail-open doctrine;
`features.aiStudio` was read inline at two call sites in two different spellings —
`ChatDock` un-chained, `HomePage` optional-chained — so neither reader could cite the
doctrine and the un-chained one is a TypeError, not a fail-open, against a snapshot
whose `features` is absent. That is the shape PR #5575 measured crashing 29 tests.
- `isAiStudioEnabled()` sibling of `isMarketplaceEnabled()`, same docblock treatment.
- Both call sites moved onto it; no inline `features.aiStudio` read remains.
- The four Home suites' module mocks taught the new export (an explicit factory
replaces the whole module, so an unlisted export is `undefined` at the call site).
- New coverage: the accessor's fail-open doctrine, and the dock's default body under
the partial snapshot the un-chained read could not survive.
Part of #5577
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.

1 participant

@os-sales