diff --git a/.changeset/10919-cloud-plan-status.md b/.changeset/10919-cloud-plan-status.md
new file mode 100644
index 0000000000..7c35e41098
--- /dev/null
+++ b/.changeset/10919-cloud-plan-status.md
@@ -0,0 +1,24 @@
+---
+'@object-ui/app-shell': minor
+'@object-ui/types': minor
+'@object-ui/i18n': patch
+'@object-ui/cli': patch
+---
+
+New SDUI widget `cloud:plan-status`: a "Current plan" badge for one plan card on the Cloud pricing page, shown when that card's plan is the organization's plan (objectui#10919).
+
+**Clause-②: yes** — the accept set of `AnyComponentSchema`, and so of `safeValidateSchema` and `objectui validate`, widens by one `type` literal, `cloud:plan-status`, and `@object-ui/types/zod` exports one new schema, `CloudPlanStatusSchema`. Nothing that parsed before is refused now.
+
+**Why.** The pricing page is static metadata, and nothing in a page's expression scope carries the organization's plan, so the page could not tell which of its cards the organization is already on. The plan is available only from the org-scoped `GET /cloud/environment-entitlements` summary.
+
+**What changed, in observable terms.**
+
+- `@object-ui/app-shell` registers `cloud:plan-status`. A page places one node on each plan card and names that card's plan code in `properties.plan`: `{ "type": "cloud:plan-status", "properties": { "plan": "free" } }`. The widget reads the summary through the hook the environment list already uses, and renders the badge when the summary's `plan` equals `properties.plan`. The comparison is exact, so `Free` does not match `free`.
+- The widget renders nothing on every other card, while the summary loads, when the request fails, and when the body is not the `{ success, data }` envelope. It never guesses a plan.
+- The node's `className` and `responsiveStyles` reach the badge. Each node reads the summary itself, so a page with three cards makes three requests.
+- The widget is registered under one key, `cloud:plan-status`. There is no bare `plan-status` fallback and no `app-shell:`-prefixed twin.
+- `@object-ui/types/zod` exports `CloudPlanStatusSchema`, a member of `AnyComponentSchema`. `properties` is required and must be exactly `{ plan }`, with `plan` a non-empty string. A missing bag, a missing or empty `plan`, and any other key in the bag are each refused at that path.
+- `body` and `children` are refused by name on this node, because the widget reads neither.
+- `plan` is not an enum: the plan catalog belongs to the control plane, and ObjectUI does not list its codes.
+- `@object-ui/i18n` adds `cloudPlanStatus.current` ("Current plan") to all ten locale packs.
+- `@object-ui/cli`: `objectui check` knows `cloud:plan-status` as a registered type.
diff --git a/packages/app-shell/package.json b/packages/app-shell/package.json
index 7feb9698f7..8ab08ef694 100644
--- a/packages/app-shell/package.json
+++ b/packages/app-shell/package.json
@@ -9,6 +9,7 @@
"./dist/console/connect/ConnectAgentWidget.js",
"./dist/console/diagnostics/CloudAiModelStatus.js",
"./dist/console/home/CloudOnboardingNext.js",
+ "./dist/console/home/CloudPlanStatus.js",
"./dist/console/marketplace/InstalledListWidget.js",
"./dist/services/builtinComponents.js",
"./dist/views/global-notifications-renderer.js",
@@ -24,6 +25,7 @@
"./src/console/connect/ConnectAgentWidget.tsx",
"./src/console/diagnostics/CloudAiModelStatus.tsx",
"./src/console/home/CloudOnboardingNext.tsx",
+ "./src/console/home/CloudPlanStatus.tsx",
"./src/console/marketplace/InstalledListWidget.tsx",
"./src/services/builtinComponents.tsx",
"./src/views/global-notifications-renderer.tsx",
diff --git a/packages/app-shell/src/console/home/CloudPlanStatus.tsx b/packages/app-shell/src/console/home/CloudPlanStatus.tsx
new file mode 100644
index 0000000000..08d1066213
--- /dev/null
+++ b/packages/app-shell/src/console/home/CloudPlanStatus.tsx
@@ -0,0 +1,96 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * CloudPlanStatus — the "current plan" marker for one plan card on the Cloud
+ * control plane's pricing page, registered as the SDUI widget
+ * `cloud:plan-status` (objectui#10919).
+ *
+ * The pricing page is static metadata: nothing in its expression scope carries
+ * the organization's plan, so a page cannot tell which of its cards is the one
+ * the organization is already on. The plan is only available from the
+ * org-scoped `GET /cloud/environment-entitlements` summary (`data.plan`). This
+ * widget reads that summary and answers one question for the card it is placed
+ * on: is this card's plan the organization's plan?
+ *
+ * • the summary names this card's plan → a localized "Current plan" badge;
+ * • the summary names another plan → nothing;
+ * • loading, failed, or no summary → nothing. The plan is never guessed:
+ * only the authoritative summary (`source: 'summary'`) can mark a card, and
+ * the row-derived fallback carries no plan at all.
+ *
+ * ## One node per card, and the page names the card
+ *
+ * The plan code a card stands for is the page's, not this widget's: the plan
+ * catalog belongs to the control plane, so the page passes its own code in
+ * `properties.plan` (the `properties` bag is the spelling every page component
+ * is authored in) and the widget compares it VERBATIM with `data.plan`. No
+ * code is listed, normalized or defaulted here.
+ *
+ * ## The summary is read through `useEnvironmentEntitlements`
+ *
+ * The same hook the environment list uses — one reading of the endpoint and its
+ * strict `{ success, data }` envelope, not a third copy of the fetch. It is
+ * enabled unconditionally here because this widget exists only to read it.
+ */
+
+import { useState } from 'react';
+import { Badge, cn } from '@object-ui/components';
+import { createAuthenticatedFetch } from '@object-ui/auth';
+import { ComponentRegistry } from '@object-ui/core';
+import { useObjectTranslation } from '@object-ui/i18n';
+import { useAdapter } from '../../providers/AdapterProvider.js';
+import { useEnvironmentEntitlements } from '../../environment/useEnvironmentEntitlements.js';
+
+export interface CloudPlanStatusProps {
+ properties?: {
+ /**
+ * The plan code of the card this node sits on, exactly as the control
+ * plane's entitlements summary spells it (for example `free`).
+ */
+ plan?: string;
+ };
+ /** Carries the scope class of the node's `responsiveStyles`. */
+ className?: string;
+}
+
+export function CloudPlanStatus({ properties, className }: CloudPlanStatusProps) {
+ const { t } = useObjectTranslation();
+ const dataSource = useAdapter();
+ // `useState`, not `useMemo`: the hook keys its fetch effect on this function,
+ // so its identity has to be one React guarantees to keep (AGENTS.md #10).
+ const [authFetch] = useState(() => createAuthenticatedFetch());
+ const entitlements = useEnvironmentEntitlements({
+ enabled: true,
+ dataSource,
+ authFetch,
+ apiBase: (import.meta.env.VITE_SERVER_URL as string | undefined) || '',
+ });
+
+ const plan = properties?.plan;
+ const isCurrent =
+ typeof plan === 'string'
+ && plan !== ''
+ && entitlements?.source === 'summary'
+ && entitlements.plan === plan;
+ if (!isCurrent) return null;
+
+ return (
+
+ {t('cloudPlanStatus.current')}
+
+ );
+}
+
+// SDUI registration — the Cloud pricing page places one node per plan card.
+// Registered under the `cloud` namespace with no bare fallback, so the one key
+// is `cloud:plan-status`: the spelling the page authors, and the one literal
+// its `@object-ui/types` arm claims (`CloudPlanStatusSchema`).
+ComponentRegistry.register('plan-status', (props: CloudPlanStatusProps) => (
+
+), {
+ namespace: 'cloud',
+ skipFallback: true,
+ label: 'Cloud Plan Status',
+ category: 'plugin',
+ inputs: [],
+});
diff --git a/packages/app-shell/src/console/home/__tests__/CloudPlanStatus.test.tsx b/packages/app-shell/src/console/home/__tests__/CloudPlanStatus.test.tsx
new file mode 100644
index 0000000000..4961cf9cb7
--- /dev/null
+++ b/packages/app-shell/src/console/home/__tests__/CloudPlanStatus.test.tsx
@@ -0,0 +1,169 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * CloudPlanStatus (`cloud:plan-status`, objectui#10919) marks ONE plan card on
+ * the Cloud pricing page: a "Current plan" badge when the organization's plan,
+ * read from the authoritative entitlements summary, equals the card's
+ * `properties.plan` — and nothing in every other state, so a plan is never
+ * guessed.
+ */
+
+import { describe, it, expect, vi, afterEach } from 'vitest';
+import { render, screen, waitFor, cleanup } from '@testing-library/react';
+import React from 'react';
+
+let fetchImpl: (url: string, init?: RequestInit) => Promise;
+const fetchedUrls: string[] = [];
+
+vi.mock('@object-ui/auth', async (importOriginal) => ({
+ ...(await importOriginal>()),
+ useAuth: () => ({ activeOrganization: { id: 'org_1' } }),
+ createAuthenticatedFetch: () => (url: string, init?: RequestInit) => {
+ fetchedUrls.push(url);
+ return fetchImpl(url, init);
+ },
+}));
+
+import { I18nProvider } from '@object-ui/i18n';
+import { ComponentRegistry } from '@object-ui/core';
+import { AdapterCtx, SchemaRenderer } from '@object-ui/react';
+import { CloudPlanStatus } from '../CloudPlanStatus';
+
+/**
+ * The badge text resolves from the locale packs, so a real i18n context is
+ * needed — without one `t()` returns the raw key and a text assertion would
+ * pass against nothing. The adapter is the row-derived fallback's data source;
+ * its rows name a production env, so the fallback resolves `derived` for real
+ * rather than failing.
+ */
+const adapter = { find: vi.fn().mockResolvedValue([{ id: 'env_1', environment_type: 'production' }]) };
+
+const renderInShell = (ui: React.ReactElement) =>
+ render(
+
+ {ui}
+ ,
+ );
+
+/** The control plane's `{ success, data }` envelope around a summary naming `plan`. */
+function summary(plan: string) {
+ return {
+ ok: true,
+ status: 200,
+ json: async () => ({ success: true, data: { plan, hasProductionEnv: true } }),
+ };
+}
+
+afterEach(() => {
+ cleanup();
+ fetchedUrls.length = 0;
+ adapter.find.mockClear();
+});
+
+describe('CloudPlanStatus (objectui#10919)', () => {
+ it('marks the card whose plan is the organization\'s plan', async () => {
+ fetchImpl = async () => summary('free');
+ const { container } = renderInShell();
+
+ expect(await screen.findByText('Current plan')).toBeTruthy();
+ expect(container.querySelector('[data-plan-status="current"]')).toBeTruthy();
+ });
+
+ it('reads the org-scoped summary endpoint', async () => {
+ fetchImpl = async () => summary('free');
+ renderInShell();
+
+ await screen.findByText('Current plan');
+ expect(fetchedUrls).toEqual(['/api/v1/cloud/environment-entitlements?organizationId=org_1']);
+ });
+
+ it('renders nothing on a card whose plan is not the organization\'s', async () => {
+ fetchImpl = async () => summary('team');
+ const { container } = renderInShell();
+
+ // Wait until the summary was actually read, so the empty render is a
+ // verdict on it and not the loading state.
+ await waitFor(() => expect(fetchedUrls).toHaveLength(1));
+ await new Promise((r) => setTimeout(r, 0));
+ expect(container.innerHTML).toBe('');
+ });
+
+ it('compares the plan code verbatim — no case folding', async () => {
+ fetchImpl = async () => summary('free');
+ const { container } = renderInShell();
+
+ await waitFor(() => expect(fetchedUrls).toHaveLength(1));
+ await new Promise((r) => setTimeout(r, 0));
+ expect(container.innerHTML).toBe('');
+ });
+
+ it('renders nothing while the summary is loading', () => {
+ fetchImpl = () => new Promise(() => {});
+ const { container } = renderInShell();
+
+ expect(container.innerHTML).toBe('');
+ });
+
+ it('renders nothing when the summary fails — the row-derived fallback carries no plan', async () => {
+ fetchImpl = async () => ({ ok: false, status: 500, json: async () => null });
+ const { container } = renderInShell();
+
+ // The hook fell through to the rows, which resolve (source `derived`) but
+ // name no plan — so the card is left unmarked instead of guessed.
+ await waitFor(() => expect(adapter.find).toHaveBeenCalledTimes(1));
+ await new Promise((r) => setTimeout(r, 0));
+ expect(container.innerHTML).toBe('');
+ });
+
+ it('renders nothing for a bare (un-enveloped) body that names the plan', async () => {
+ // A bare body is a producer contract violation (cloud#1046): it yields no
+ // summary, so even a `plan` that matches must not mark the card.
+ fetchImpl = async () => ({ ok: true, status: 200, json: async () => ({ plan: 'free' }) });
+ const { container } = renderInShell();
+
+ await waitFor(() => expect(adapter.find).toHaveBeenCalledTimes(1));
+ await new Promise((r) => setTimeout(r, 0));
+ expect(container.innerHTML).toBe('');
+ });
+
+ it('renders nothing when the node names no plan', async () => {
+ fetchImpl = async () => summary('free');
+ const { container } = renderInShell();
+
+ await waitFor(() => expect(fetchedUrls).toHaveLength(1));
+ await new Promise((r) => setTimeout(r, 0));
+ expect(container.innerHTML).toBe('');
+ });
+});
+
+describe('`cloud:plan-status` registration (objectui#10919)', () => {
+ it('is registered under the one key the page authors, with no second spelling', () => {
+ expect(ComponentRegistry.has('cloud:plan-status')).toBe(true);
+ // `skipFallback: true` — no bare `plan-status` fallback …
+ expect(ComponentRegistry.has('plan-status')).toBe(false);
+ // … and no `app-shell:`-prefixed twin.
+ expect(ComponentRegistry.has('app-shell:cloud:plan-status')).toBe(false);
+ });
+
+ it('renders from a page node through SchemaRenderer, reading `properties.plan`', async () => {
+ fetchImpl = async () => summary('team');
+ const { container } = renderInShell(
+ ,
+ );
+
+ expect(await screen.findByText('Current plan')).toBeTruthy();
+ // The node's `responsiveStyles` scope class reaches the badge through
+ // `className`, so the page can place it inside its card.
+ const scope = container.querySelector('style[data-os-scope]')?.getAttribute('data-os-scope');
+ expect(scope).toBeTruthy();
+ const badge = container.querySelector('[data-plan-status="current"]');
+ expect(badge?.classList.contains(scope as string)).toBe(true);
+ });
+});
diff --git a/packages/app-shell/src/index.ts b/packages/app-shell/src/index.ts
index 4e15771eb0..235842f3dc 100644
--- a/packages/app-shell/src/index.ts
+++ b/packages/app-shell/src/index.ts
@@ -313,6 +313,8 @@ import './console/marketplace/InstalledListWidget.js';
import './console/connect/ConnectAgentWidget.js';
// SDUI widget for the Cloud Welcome page's state-aware onboarding next-step.
import './console/home/CloudOnboardingNext.js';
+// SDUI widget for the Cloud pricing page's "current plan" marker (objectui#10919).
+import './console/home/CloudPlanStatus.js';
// SDUI widget: read-only admin diagnostic for the env's effective AI model
// (cloud#797) — fetches GET /api/v1/ai/effective-model.
import './console/diagnostics/CloudAiModelStatus.js';
diff --git a/packages/cli/src/__tests__/registered-types-validate-ratchet-10859.test.ts b/packages/cli/src/__tests__/registered-types-validate-ratchet-10859.test.ts
index ca131cb9f8..e3eef9eddd 100644
--- a/packages/cli/src/__tests__/registered-types-validate-ratchet-10859.test.ts
+++ b/packages/cli/src/__tests__/registered-types-validate-ratchet-10859.test.ts
@@ -196,6 +196,14 @@ describe('registered NAMESPACED component types refused at `type` — a ratchet
expect(refusedAtType(key), key).toBe(false);
}
});
+
+ it('counts `cloud:plan-status` armed — it registered WITH its arm (objectui#10919)', () => {
+ // One registry key (`skipFallback: true`, so no bare `plan-status`), armed in
+ // `@object-ui/types/zod` in the same change, so the pin above did not move.
+ expect(NAMESPACED_KEYS).toContain('cloud:plan-status');
+ expect(BARE_KEYS).not.toContain('plan-status');
+ expect(refusedAtType('cloud:plan-status')).toBe(false);
+ });
});
/* ── End to end: the README document through `objectui validate` ─────────── */
diff --git a/packages/cli/src/utils/known-schema-types.ts b/packages/cli/src/utils/known-schema-types.ts
index 3447617c5d..d55df71610 100644
--- a/packages/cli/src/utils/known-schema-types.ts
+++ b/packages/cli/src/utils/known-schema-types.ts
@@ -86,6 +86,7 @@ export const KNOWN_SCHEMA_TYPES: readonly string[] = [
'cloud-connection:panel',
'cloud:ai-model-status',
'cloud:onboarding-next',
+ 'cloud:plan-status',
'code',
'code-editor',
'collapsible',
diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts
index 41dff4e84f..bf66ea43df 100644
--- a/packages/i18n/src/locales/ar.ts
+++ b/packages/i18n/src/locales/ar.ts
@@ -2952,6 +2952,9 @@ const ar = {
openProduction: "فتح بيئة الإنتاج",
manageEnvironments: "إدارة البيئات",
},
+ cloudPlanStatus: {
+ current: "الخطة الحالية",
+ },
ai: {
nlQuery: {
placeholder: "اطرح سؤالًا حول بياناتك…",
diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts
index e5edf2ab46..3860018247 100644
--- a/packages/i18n/src/locales/de.ts
+++ b/packages/i18n/src/locales/de.ts
@@ -2922,6 +2922,9 @@ const de = {
openProduction: "Produktion öffnen",
manageEnvironments: "Umgebungen verwalten",
},
+ cloudPlanStatus: {
+ current: "Aktueller Tarif",
+ },
ai: {
nlQuery: {
placeholder: "Stellen Sie eine Frage zu Ihren Daten…",
diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts
index b52ec0b459..dcc9b4fee0 100644
--- a/packages/i18n/src/locales/en.ts
+++ b/packages/i18n/src/locales/en.ts
@@ -3397,6 +3397,10 @@ const en = {
openProduction: 'Open Production',
manageEnvironments: 'Manage environments',
},
+ // `cloud:plan-status` — the Cloud pricing page's current-plan marker (objectui#10919).
+ cloudPlanStatus: {
+ current: 'Current plan',
+ },
// `@object-ui/plugin-ai` — the `nl-query`, `ai-form-assist` and
// `ai-recommendations` components (objectui#10232). The `*One` rows are this
// repo's two-key plural convention (see `search.itemsAvailableOne`): the
diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts
index 07b50e7abb..87ff7bba89 100644
--- a/packages/i18n/src/locales/es.ts
+++ b/packages/i18n/src/locales/es.ts
@@ -2926,6 +2926,9 @@ const es = {
openProduction: "Abrir producción",
manageEnvironments: "Gestionar entornos",
},
+ cloudPlanStatus: {
+ current: "Plan actual",
+ },
ai: {
nlQuery: {
placeholder: "Haga una pregunta sobre sus datos…",
diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts
index d1bfc80d47..c24a18cecb 100644
--- a/packages/i18n/src/locales/fr.ts
+++ b/packages/i18n/src/locales/fr.ts
@@ -2924,6 +2924,9 @@ const fr = {
openProduction: "Ouvrir la production",
manageEnvironments: "Gérer les environnements",
},
+ cloudPlanStatus: {
+ current: "Offre actuelle",
+ },
ai: {
nlQuery: {
placeholder: "Posez une question sur vos données…",
diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts
index bf56a18d3a..781efd1192 100644
--- a/packages/i18n/src/locales/ja.ts
+++ b/packages/i18n/src/locales/ja.ts
@@ -2924,6 +2924,9 @@ const ja = {
openProduction: "本番環境を開く",
manageEnvironments: "環境を管理",
},
+ cloudPlanStatus: {
+ current: "現在のプラン",
+ },
ai: {
nlQuery: {
placeholder: "データについて質問してください…",
diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts
index eb91df0e2b..186a389e52 100644
--- a/packages/i18n/src/locales/ko.ts
+++ b/packages/i18n/src/locales/ko.ts
@@ -2921,6 +2921,9 @@ const ko = {
openProduction: "프로덕션 열기",
manageEnvironments: "환경 관리",
},
+ cloudPlanStatus: {
+ current: "현재 요금제",
+ },
ai: {
nlQuery: {
placeholder: "데이터에 대해 질문하세요…",
diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts
index 8914f3baad..87e9b19cc3 100644
--- a/packages/i18n/src/locales/pt.ts
+++ b/packages/i18n/src/locales/pt.ts
@@ -2921,6 +2921,9 @@ const pt = {
openProduction: "Abrir produção",
manageEnvironments: "Gerenciar ambientes",
},
+ cloudPlanStatus: {
+ current: "Plano atual",
+ },
ai: {
nlQuery: {
placeholder: "Faça uma pergunta sobre seus dados…",
diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts
index 735176af49..7505e43702 100644
--- a/packages/i18n/src/locales/ru.ts
+++ b/packages/i18n/src/locales/ru.ts
@@ -2952,6 +2952,9 @@ const ru = {
openProduction: "Открыть продакшн",
manageEnvironments: "Управление окружениями",
},
+ cloudPlanStatus: {
+ current: "Текущий тариф",
+ },
ai: {
nlQuery: {
placeholder: "Задайте вопрос о своих данных…",
diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts
index 0d4c200fe4..35f55ed4e5 100644
--- a/packages/i18n/src/locales/zh.ts
+++ b/packages/i18n/src/locales/zh.ts
@@ -3059,6 +3059,9 @@ const zh = {
openProduction: '打开生产环境',
manageEnvironments: '管理环境',
},
+ cloudPlanStatus: {
+ current: '当前套餐',
+ },
ai: {
nlQuery: {
placeholder: '就您的数据提问…',
diff --git a/packages/types/src/__tests__/cloud-plan-status-arm-10919.test.ts b/packages/types/src/__tests__/cloud-plan-status-arm-10919.test.ts
new file mode 100644
index 0000000000..eda09738eb
--- /dev/null
+++ b/packages/types/src/__tests__/cloud-plan-status-arm-10919.test.ts
@@ -0,0 +1,137 @@
+/**
+ * ObjectUI
+ * Copyright (c) 2024-present ObjectStack Inc.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+/**
+ * objectui#10919 — the `cloud:plan-status` arm (`../zod/cloud.zod.ts`).
+ *
+ * `@object-ui/app-shell` registers `cloud:plan-status` for the Cloud pricing
+ * page, and this arm lands with it so `AnyComponentSchema` claims the type
+ * instead of refusing it at `type`. The arm is declared from the widget's read
+ * points: a required `properties` bag holding exactly `plan`, a non-empty
+ * string, and both content channels refused by name.
+ *
+ * Refusals are asserted by issue `code` and `path` — the envelope a consumer
+ * reads — and not by message wording.
+ */
+
+import { describe, expect, it } from 'vitest';
+
+import {
+ AnyComponentSchema,
+ CloudPlanStatusSchema,
+ StrictAnyComponentSchema,
+ safeValidateSchema,
+} from '../zod/index.zod.js';
+
+const TYPE = 'cloud:plan-status';
+
+/** The issues of a refused parse, as `code` + dotted `path`. */
+function issuesOf(document: unknown): { code: string; path: string; message: string }[] {
+ const result = safeValidateSchema(document);
+ expect(result.success, 'expected a refusal').toBe(false);
+ if (result.success) return [];
+ return result.error.issues.map((issue) => ({
+ code: issue.code,
+ path: issue.path.join('.'),
+ message: issue.message,
+ }));
+}
+
+describe('`cloud:plan-status` — the node the pricing page authors (objectui#10919)', () => {
+ it('accepts one plan-card marker on every face', () => {
+ const node = { type: TYPE, properties: { plan: 'free' } };
+ expect(CloudPlanStatusSchema.safeParse(node).success).toBe(true);
+ expect(safeValidateSchema(node).success).toBe(true);
+ expect(StrictAnyComponentSchema.safeParse(node).success).toBe(true);
+ });
+
+ it('accepts the node inside a page, with the spec page-component keys cloud writes beside it', () => {
+ const page = {
+ type: 'page',
+ children: [
+ {
+ type: TYPE,
+ id: 'plan_free_current',
+ className: 'self-start',
+ properties: { plan: 'free' },
+ },
+ ],
+ };
+ expect(safeValidateSchema(page).success).toBe(true);
+ });
+
+ it('is claimed at `type` — the union no longer refuses the literal', () => {
+ // The ratchet's reading (`registered-types-validate-ratchet-10859.test.ts`):
+ // a bare `{ type }` fails on the missing bag, NOT with `invalid_union` at `type`.
+ const issues = issuesOf({ type: TYPE });
+ expect(issues.some((i) => i.code === 'invalid_union' && i.path === 'type')).toBe(false);
+ // Control: an unclaimed `cloud:` literal IS refused there.
+ const unclaimed = issuesOf({ type: 'cloud:no-such-widget-10919', properties: { plan: 'free' } });
+ expect(unclaimed.some((i) => i.code === 'invalid_union' && i.path === 'type')).toBe(true);
+ });
+});
+
+describe('`cloud:plan-status` — what the arm refuses (objectui#10919)', () => {
+ it('refuses a node with no `properties` bag', () => {
+ expect(issuesOf({ type: TYPE })).toEqual([
+ expect.objectContaining({ code: 'invalid_type', path: 'properties' }),
+ ]);
+ });
+
+ it('refuses a bag that names no plan', () => {
+ expect(issuesOf({ type: TYPE, properties: {} })).toEqual([
+ expect.objectContaining({ code: 'invalid_type', path: 'properties.plan' }),
+ ]);
+ });
+
+ it('refuses an empty plan', () => {
+ expect(issuesOf({ type: TYPE, properties: { plan: '' } })).toEqual([
+ expect.objectContaining({ code: 'too_small', path: 'properties.plan' }),
+ ]);
+ });
+
+ it('refuses a plan that is not a string', () => {
+ expect(issuesOf({ type: TYPE, properties: { plan: 1 } })).toEqual([
+ expect.objectContaining({ code: 'invalid_type', path: 'properties.plan' }),
+ ]);
+ });
+
+ it('refuses a key the widget does not read, inside the bag', () => {
+ expect(issuesOf({ type: TYPE, properties: { plan: 'free', label: 'Current' } })).toEqual([
+ expect.objectContaining({ code: 'unrecognized_keys', path: 'properties' }),
+ ]);
+ });
+
+ it('refuses a flat `plan` — the widget reads the bag only', () => {
+ // Tolerant face: the bag is missing, so the node is refused there.
+ expect(issuesOf({ type: TYPE, plan: 'free' })).toEqual([
+ expect.objectContaining({ code: 'invalid_type', path: 'properties' }),
+ ]);
+ // Strict face: a flat `plan` beside a valid bag is an unknown node key.
+ const strict = StrictAnyComponentSchema.safeParse({ type: TYPE, plan: 'free', properties: { plan: 'free' } });
+ expect(strict.success).toBe(false);
+ expect(strict.error?.issues.map((i) => i.code)).toContain('unrecognized_keys');
+ });
+
+ it.each(['children', 'body'])('refuses `%s` by name — the widget reads neither content channel', (key) => {
+ const issues = issuesOf({ type: TYPE, properties: { plan: 'free' }, [key]: [{ type: 'text', content: 'x' }] });
+ expect(issues).toEqual([expect.objectContaining({ code: 'invalid_type', path: key })]);
+ expect(issues[0].message).toContain(TYPE);
+ });
+
+ it('reaches the arm through the union, not a second schema', () => {
+ // The union dispatches on the literal to this arm: the same refusal either way.
+ const bare = CloudPlanStatusSchema.safeParse({ type: TYPE, properties: { plan: '' } });
+ const viaUnion = AnyComponentSchema.safeParse({ type: TYPE, properties: { plan: '' } });
+ expect(bare.success).toBe(false);
+ expect(viaUnion.success).toBe(false);
+ expect(viaUnion.error?.issues.map((i) => [i.code, i.path.join('.')])).toEqual(
+ bare.error?.issues.map((i) => [i.code, i.path.join('.')]),
+ );
+ });
+});
diff --git a/packages/types/src/__tests__/zod-mirror-parity.test.ts b/packages/types/src/__tests__/zod-mirror-parity.test.ts
index 73c04e052c..89defffc2d 100644
--- a/packages/types/src/__tests__/zod-mirror-parity.test.ts
+++ b/packages/types/src/__tests__/zod-mirror-parity.test.ts
@@ -4042,6 +4042,12 @@ const EXCLUSIONS: Readonly> = {
"spec-owned, RESTATED — `BaseSchema` + the `element:divider` literal + a member-less strict `properties`, because `@objectstack/spec/ui` exports `ComponentPropsMap['element:divider']` under no name to read by reference; its key set and accept set are pinned to the row's in `./public-block-arms-10872.test.ts`, and no TS declaration in this package restates the node",
'public-blocks.zod.ts#PublicBlockComponentSchema':
"a union OVER the public-block arms, not an object of its own — its members are accounted for individually above",
+ // objectui#10919 — the `cloud:plan-status` widget's arm, declared locally from
+ // the widget's read points: `@objectstack/spec` has no row for a `cloud:`
+ // widget, and the widget's props type lives beside it in `@object-ui/app-shell`,
+ // not in this package. Pinned by `./cloud-plan-status-arm-10919.test.ts`.
+ 'cloud.zod.ts#CloudPlanStatusSchema':
+ "no TS declaration in this package restates it — `BaseSchema` + the `cloud:plan-status` literal + a strict `properties` bag holding the one key the widget reads (`plan`) + the two content-channel refusals; the widget's own props type is in `@object-ui/app-shell`",
// Renamed from `StylePropsSchema` by objectui#5928. Under the old name the
// like-named `StyleProps` (../base.ts) — the Tailwind-scale vocabulary, sharing
// ZERO keys with this `{ className, style }` object — read as its declaration, and
diff --git a/packages/types/src/zod/README.md b/packages/types/src/zod/README.md
index a3d1de0fd0..5213bdf5a6 100644
--- a/packages/types/src/zod/README.md
+++ b/packages/types/src/zod/README.md
@@ -247,6 +247,10 @@ Each arm's `properties` is the block's `@objectstack/spec` `ComponentPropsMap` r
- `RecordHistoryBlockSchema`, `RecordQuickActionsBlockSchema`, `RecordReferenceRailBlockSchema`
- `RecordAlertBlockSchema`, `ElementTextBlockSchema`, `ElementNumberBlockSchema`, `ElementButtonBlockSchema`, `ElementDividerBlockSchema`
+### Cloud Widgets (1)
+`@object-ui/app-shell`'s `cloud:` SDUI widgets. `@objectstack/spec` has no `ComponentPropsMap` row for them, so each arm is declared here from the keys its widget reads.
+- `CloudPlanStatusSchema` — `cloud:plan-status`, the pricing page's "current plan" marker: `properties` must be exactly `{ plan }`, a non-empty plan code.
+
## Schema Structure
All component schemas follow the @objectstack/spec UI specification format:
diff --git a/packages/types/src/zod/cloud.zod.ts b/packages/types/src/zod/cloud.zod.ts
new file mode 100644
index 0000000000..2b14c511b8
--- /dev/null
+++ b/packages/types/src/zod/cloud.zod.ts
@@ -0,0 +1,82 @@
+/**
+ * ObjectUI
+ * Copyright (c) 2024-present ObjectStack Inc.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+/**
+ * @object-ui/types/zod - Cloud Widget Zod Validators
+ *
+ * The arm for `cloud:plan-status`, the SDUI widget `@object-ui/app-shell`
+ * registers for the Cloud pricing page (`console/home/CloudPlanStatus.tsx`,
+ * objectui#10919).
+ *
+ * ## Why the arm lands with the registration
+ *
+ * `AnyComponentSchema` refuses a registered type it has no arm for, with one
+ * `invalid_union` issue at `type`, so a page placing the widget could not pass
+ * `safeValidateSchema` or `objectui validate`. The count of registered types
+ * still refused there is ratcheted by
+ * `packages/cli/src/__tests__/registered-types-validate-ratchet-10859.test.ts`,
+ * which never rises, so the registration and this arm ship together.
+ *
+ * ## Declared here, measured from the widget's read points
+ *
+ * `@objectstack/spec` has no `ComponentPropsMap` row for a `cloud:` widget, so
+ * nothing is read by reference: the members below are the keys the widget
+ * reads, and no other.
+ *
+ * - `properties.plan` — the plan code of the card the node sits on. The page
+ * authors it in the `properties` bag, the one spelling a page component is
+ * written in, and the widget reads it there alone. Required: a node that
+ * names no plan can never mark anything. It is an opaque, non-empty
+ * string, because the plan catalog belongs to the control plane; ObjectUI
+ * does not list the codes.
+ * - `properties` is closed: the widget reads no other key of the bag.
+ * - `body` / `children` — refused by name. The widget renders a badge and
+ * reads neither content channel (objectui#9256's rule for such nodes).
+ *
+ * ⛔ No `.default()` anywhere in this module — see the "authors no default"
+ * note in `index.zod.ts`.
+ *
+ * @module zod/cloud
+ * @packageDocumentation
+ */
+
+import { z } from 'zod';
+import { BaseSchema } from './base.zod.js';
+import { retirementTombstone } from './tombstone.zod.js';
+
+/** One refusal string for both content channels of `CloudPlanStatusSchema`. */
+const CLOUD_PLAN_STATUS_NEITHER_CHANNEL =
+ 'REFUSED (objectui#10919) — `cloud:plan-status` reads NEITHER content channel: its registration hands '
+ + 'the node to `CloudPlanStatus`, which reads only `properties.plan` and `className`, and '
+ + '`SchemaRenderer` strips both channels out of the props bag it spreads. An authored value would '
+ + 'render NOTHING — no render-time error or warning and no element; only the parser tier\'s '
+ + '`not-a-container` warning (objectui#9910) noticed it, because the registration declares no '
+ + '`children` input. What it renders instead: a "Current plan" badge when `properties.plan` is the '
+ + 'organization\'s plan, and nothing otherwise.';
+
+/**
+ * `cloud:plan-status` — the "current plan" marker for one plan card
+ * (objectui#10919). Pinned by `../__tests__/cloud-plan-status-arm-10919.test.ts`.
+ */
+export const CloudPlanStatusSchema = BaseSchema.extend({
+ type: z.literal('cloud:plan-status'),
+ properties: z
+ .strictObject({
+ plan: z
+ .string()
+ .min(1)
+ .describe(
+ 'The plan code of the card this node marks, spelled exactly as the control plane\'s '
+ + 'entitlements summary reports it (for example `free`). The badge renders only when it equals '
+ + 'the organization\'s plan.',
+ ),
+ })
+ .describe('The `cloud:plan-status` props bag: `plan`, and no other key.'),
+ body: retirementTombstone(CLOUD_PLAN_STATUS_NEITHER_CHANNEL),
+ children: retirementTombstone(CLOUD_PLAN_STATUS_NEITHER_CHANNEL),
+});
diff --git a/packages/types/src/zod/index.zod.ts b/packages/types/src/zod/index.zod.ts
index 2de83be732..268cf107b7 100644
--- a/packages/types/src/zod/index.zod.ts
+++ b/packages/types/src/zod/index.zod.ts
@@ -442,6 +442,11 @@ export {
PublicBlockComponentSchema,
} from './public-blocks.zod.js';
+// ============================================================================
+// Cloud Widgets - `@object-ui/app-shell`'s `cloud:` SDUI widgets (objectui#10919)
+// ============================================================================
+export { CloudPlanStatusSchema } from './cloud.zod.js';
+
// ============================================================================
// Union Types - All Component Schemas
// ============================================================================
@@ -463,6 +468,7 @@ import { ReportUnionSchema } from './reports.zod.js';
import { ViewComponentSchema } from './views.zod.js';
import { AIComponentSchema } from './ai.zod.js';
import { PublicBlockComponentSchema } from './public-blocks.zod.js';
+import { CloudPlanStatusSchema } from './cloud.zod.js';
/**
* Union of all component schemas.
@@ -531,6 +537,7 @@ export const AnyComponentSchema = defineNodeComponentUnion(z.discriminatedUnion(
ViewComponentSchema,
AIComponentSchema,
PublicBlockComponentSchema,
+ CloudPlanStatusSchema,
], {
// Zod's default message for a missed discriminator spells out EVERY accepted
// literal — measured, 1,462 chars naming all 107. That is the "print every