Repository navigation
Commit 24d3e65
feat(app-shell,types): cloud:plan-status marks the organization's current plan card (objectui#10919) (#10990)
Fixes #10919
Clause-②: yes
## What this does
The Cloud pricing page is static metadata, so it cannot tell which of
its plan cards the organization is already on (objectstack-ai/cloud#2434
item 5). This PR adds the SDUI widget `cloud:plan-status`. A page places
one node on each plan card and names that card's plan code in
`properties.plan`. The widget reads the org-scoped `GET
/cloud/environment-entitlements` summary and renders a localized
"Current plan" badge when the summary's `plan` equals `properties.plan`.
It renders nothing in every other state: another plan, loading, a failed
request, a body that is not the `{ success, data }` envelope, or a node
that names no plan. It never guesses a plan.
**The snippet cloud places** in each `planCard(...)` column of
`packages/service-tenant/src/pages/pricing.page.ts`, for the cloud#2434
item 5 follow-up:
```ts
{ id: `plan_${opts.name}_current`, type: 'cloud:plan-status', responsiveStyles: { large: { alignSelf: 'flex-start' } }, properties: { plan: opts.name } },
```
The card names `free`, `team` and `enterprise` are the summary's plan
codes for those cards. The endpoint can also report `solo`, `business`,
`starter`, `pro` and `custom`, and an organization on one of those gets
no card marked, because the page lists no such card.
## Routing: the triage answer `5867937466`, quoted
> **Lane: `domain:ui`.**
> - The deliverable is the `cloud:*` widget (`packages/app-shell`). The
maintainer placed epic objectstack-ai/cloud#2440's cards on this lane
(「全部接 (推荐)」).
> - **The claim declares** `packages/types/src/zod` (the widget's arm),
with **`Clause-②: yes` (widening)**, so it owes an at-tier contract
review.
> - **Serial:** after PR objectui#10908 (objectui#10872 batch 2), which
holds the same ratchet test and the namespaced-arm work.
>
> **The open sub-question** (does the arm go through objectui#10872's
programme?): no.
> - objectui#10872 arms blocks **by reference** to a `@objectstack/spec`
`ComponentPropsMap` row, and `cloud:*` has no spec row. ⛔ Don't invent
one.
> - Declare the arm locally in `@object-ui/types/zod`, in the file's
existing local-arm shape, measured from the widget's read points.
> - If PR objectui#10908 changes the arm mechanism, rebase onto it.
## What the accept set gains (Clause-②: yes)
`AnyComponentSchema` gains exactly one `type` literal,
`cloud:plan-status`, through one new arm, `CloudPlanStatusSchema`,
exported by name from `@object-ui/types/zod`. So do `safeValidateSchema`
and `objectui validate`, which judge with it. A document with that type
is accepted only when all of these hold:
- `properties` is present and is exactly `{ plan }`. The bag is strict:
any other key is `unrecognized_keys` at `properties`.
- `plan` is a non-empty string: `invalid_type` or `too_small` at
`properties.plan` otherwise. It is not an enum, because the plan catalog
belongs to the control plane.
- There is no `body` and no `children`. Both are refused by name
(`invalid_type`), because the widget reads neither channel.
- Every other node key is judged as on every arm: `BaseSchema`'s
declared keys by their types; an undeclared key passes the tolerant face
and is refused by the strict face (`StrictAnyComponentSchema`).
Nothing that parsed before is refused now. `NAMESPACED_REFUSED_AT_TYPE`
stays **397** and `REFUSED_AT_TYPE` stays 73: the one new registered key
lands with its arm.
## What changed
- **`packages/app-shell/src/console/home/CloudPlanStatus.tsx`** (new):
the widget and its registration. It reads the plan through the existing
`useEnvironmentEntitlements` hook, which is not edited. The hook's
`authFetch` comes from `useState`, not `useMemo`, because the hook keys
its fetch effect on it (AGENTS.md #10). The node's `className` reaches
the badge, so the page's `responsiveStyles` scope class does too.
- **`packages/types/src/zod/cloud.zod.ts`** (new):
`CloudPlanStatusSchema`, `BaseSchema` plus the `cloud:plan-status`
literal, a required strict `properties: { plan }` bag, and the two
content-channel refusals. The refusal text carries the objectui#10928
parser-tier clause: the registration declares no `children` input, so
`validateTree` warns `not-a-container` for this node.
- **`packages/types/src/zod/index.zod.ts`**: the barrel export, the
import, and the one union line. `origin/main` is merged in (`19fc41a`),
per the claim's note on the draft PR objectui#10962.
- **`packages/types/src/zod/README.md`**: a "Cloud Widgets" group lists
the arm.
- **`packages/cli/src/utils/known-schema-types.ts`**: regenerated by
`node scripts/regenerate-known-schema-types.mjs`. It gains the one line
`cloud:plan-status`.
- **`packages/i18n/src/locales/*.ts`**: `cloudPlanStatus.current`
("Current plan") in all ten packs.
- **Ledgers and pins**: one `EXCLUSIONS` row in
`zod-mirror-parity.test.ts` (no TS declaration in `@object-ui/types`
restates the node), `cloud-plan-status-arm-10919.test.ts`,
`CloudPlanStatus.test.tsx`, and one ratchet row naming
`cloud:plan-status` as armed.
- **`.changeset/10919-cloud-plan-status.md`**: app-shell and types
`minor`, i18n and cli `patch`.
## PM assumptions, measured: three falsified, two confirmed
1. **Falsified: the props shape.** The suggested node was `{ type, plan
}`. The measured node is `{ type, properties: { plan } }`. Cloud's pages
are typed `Page` from `@objectstack/spec/ui`, and `PageComponentSchema`
is a `strictObject` whose only props channel is `properties`, so a
top-level `plan` is refused by the spec parse before objectui sees it.
`cloud:onboarding-next` is authored the same way on the welcome page,
and it reads `properties.*`. The widget reads `properties.plan` and
nothing else, and the arm declares exactly that.
2. **Falsified: the registration spelling.** The precedent
`register('cloud:onboarding-next', …, { namespace: 'app-shell' })` makes
TWO registry keys, `app-shell:cloud:onboarding-next` and the fallback
`cloud:onboarding-next`, and the namespaced ratchet counts both.
Following it would have needed the arm to accept a second literal nobody
authors. So the widget registers as `register('plan-status', …, {
namespace: 'cloud', skipFallback: true })`: one key, one arm literal,
and one generated line.
3. **Falsified: the i18n key name.** It is `cloudPlanStatus.current`,
not `cloud.planStatus.current`, following the sibling blocks
`cloudOnboarding.*` and `aiModelStatus.*`.
4. **Confirmed: the hook reads the plan, org-scoped.**
`useEnvironmentEntitlements` sends `?organizationId=` from
`useAuth().activeOrganization.id`, as the precedent does, and the
endpoint resolves an absent parameter to the session's active
organization. Its row-derived fallback carries no plan, so the widget
marks a card only on `source: 'summary'`.
5. **Confirmed: `plan` values.** The endpoint's `plan` is cloud's
`planKey(...)` over `PLAN_CODES` (`free`, `solo`, `team`, `business`,
`starter`, `pro`, `enterprise`, `custom`). ObjectUI names none of them.
## Surface supplement: two files outside the claim's declared surface
`packages/app-shell/src/index.ts` (one side-effect import line) and
`packages/app-shell/package.json` (two `sideEffects` entries). A
registration cannot ship without both. Without the barrel import, the
console never evaluates the module. Without the array entries,
`scripts/check-side-effects-array.mjs` goes red, because every
registering module in the entry graph must be named there, and a bundler
would be free to drop the registration. Both edits have the same shape
as `CloudOnboardingNext`'s. **Overlap**, read on the open PR list (REST
`pulls/N/files`, 2026-09-28): none of the ten non-release open PRs
touches either file. The changesets release PR rewrites only the
`version` field. **Seat:** please add these two paths to the claim's
file surface.
## Verification, at `19fc41a` (the merge of `origin/main` `42687ba`)
- **Suites** (`pnpm exec vitest run --maxWorkers=2`, through the verify
lock): `packages/types/`, `packages/cli/`, `packages/i18n/`, plus a
declared narrowing of `packages/app-shell/` (below). Result: `Test Files
407 passed (407)`, `Tests 8074 passed | 13 skipped`.
- **The new pins, verbose**: `cloud-plan-status-arm-10919.test.ts`
12/12; `CloudPlanStatus.test.tsx` 10/10; the ratchet file 15/15,
including the new `cloud:plan-status` row.
- **type-check**, after `turbo run build
--filter='@object-ui/app-shell^...'` (28/28): `@object-ui/types`,
`@object-ui/i18n`, `@object-ui/cli` and `@object-ui/app-shell` all exit
0. `--listFilesOnly` shows each package's test program includes the new
test files.
- **eslint**: the 19 touched `.ts`/`.tsx` files, `--format json`, 19
files, 0 errors and 0 warnings. The narrowing is a measurement: the
packages lint under the root `eslint.config.js` alone (no package-level
config), and that config enables no type-aware linting (0 hits for
`projectService`, `parserOptions`, `TypeChecked` or `tsconfigRootDir`),
so this diff cannot move the verdict on any untouched file.
- **Gates, all exit 0**: `regenerate-known-schema-types.mjs --check`,
`check:side-effects-array`, `check:i18n-keys`, `check:i18n-drift`,
`check:i18n-dead-keys` (report), `check:registry-bare-names`,
`check:doc-types`, `check:prompt-keys`, `check:unreferenced-sources`,
`check:vi-mock-specifiers`, `check:vi-mock-inherit`,
`check:vi-mock-override-shape`, `check:test-path-roots`,
`check:new-line-citations` (0 new), `check:control-bytes`,
`check:handler-key-reads`, `check:changeset-claims` (report),
`check:pending-changeset-literals`, `changeset:check`,
`check-changeset-presence` (4 released packages, 1 changeset),
`check-changeset-overwrite`, `check-type-check-coverage`,
`check:phantom-deps`, `check:esm-specifiers`, `check-lint-coverage`, and
`check-governed-queue-guard --test` over the 22 paths: NOT GOVERNED.
- **Reverse verification**, run at `5c4a1d2` (the merge changed neither
mutated file). Each leg was committed first, mutated through
`ablation-replace.mjs` with an EXIT/INT/TERM restore trap, then restored
with `git checkout HEAD --`:
- **Ablate the arm** (drop the union line in `index.zod.ts`). The
ratchet goes red: "refuses 398 registered namespaced key(s) at `type`;
the pin is 397", and the Refused list names `cloud:plan-status`. The arm
pins go red too (14 failed). Restored: blob `6fa23ee31c7c` equals HEAD,
and `git diff HEAD` is empty.
- **Ablate the match** (`entitlements.plan === plan` becomes `true`).
The widget goes red on "renders nothing on a card whose plan is not the
organization's" and "compares the plan code verbatim". Restored: blob
`08d10662137c` equals HEAD, and `git diff HEAD` is empty.
- Both subjects resolve to SOURCE (`vitest.config.mts` aliases
`@object-ui/types/zod` to `src`), so no `dist` rebuild was part of
either leg.
**NOT MEASURED**, each with its reason:
- `packages/app-shell/` full suite (856 files): at `--maxWorkers=2` it
exceeds the foreground cap (exit 124 at 560 s, and exit 124 on shard 1/6
at 270 s). The run was narrowed to the 40 files that read what this diff
touches: the two widget directories (`console/home/__tests__`,
`environment/__tests__`), `src/__tests__/`, and the 13 tests that read
`package.json`, `src/index.ts` or the barrel. Result: 40/40 files, 575
tests. CI runs the whole suite.
- `check:sdui-registration-pins`, `check:eager-closure` and
`check:eager-locale-catalogues` need a built console (PREREQUISITE NOT
MET, exit 2). CI's `performance-budget.yml` runs them on `packages/**`.
- `check:readme-exports` (exit 1) and `check:doc-examples` (exit 2)
could not judge the READMEs of packages not built here (app-shell, cli,
plugin-*). Among what was judged: 0 wrong-path and 0 fabricated.
- No browser run against a live control plane.
## Acceptance notes (not filed; no carrier)
- Each node reads the summary itself, so three cards make three GETs of
the same summary. The hook has no shared cache, and none was added.
- The Free card's CTA ("Get started") still shows to an organization on
Free. This PR marks the card and leaves the CTA alone. Whether the
widget should also stand in for the CTA is an open question in the
report.
- `useEnvironmentEntitlements`' docblock still describes an
environment-list-only hook ("Only fetch when this is the environment
list"), and it now has a second caller. Per the claim, the hook is not
edited.
- The precedent registrations' double-prefixed keys
(`app-shell:cloud:onboarding-next`, `app-shell:cloud:ai-model-status`
and their siblings) stay in the refused set. That family is
objectui#10872's population.
---
_Generated by [Claude
Code](https://claude.ai/code/session_015AUunPkX7UTkCH9e7AdZo1)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 6c57c77 commit 24d3e65
22 files changed
Lines changed: 569 additions & 0 deletions
File tree
- .changeset
- packages
- app-shell
- src
- console/home
- __tests__
- cli/src
- __tests__
- utils
- i18n/src/locales
- types/src
- __tests__
- zod
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
9 | 9 | | |
10 | 10 | | |
11 | 11 | | |
| 12 | + | |
12 | 13 | | |
13 | 14 | | |
14 | 15 | | |
| |||
24 | 25 | | |
25 | 26 | | |
26 | 27 | | |
| 28 | + | |
27 | 29 | | |
28 | 30 | | |
29 | 31 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
Lines changed: 169 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
313 | 313 | | |
314 | 314 | | |
315 | 315 | | |
| 316 | + | |
| 317 | + | |
316 | 318 | | |
317 | 319 | | |
318 | 320 | | |
| |||
Lines changed: 8 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
196 | 196 | | |
197 | 197 | | |
198 | 198 | | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
199 | 207 | | |
200 | 208 | | |
201 | 209 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
86 | 86 | | |
87 | 87 | | |
88 | 88 | | |
| 89 | + | |
89 | 90 | | |
90 | 91 | | |
91 | 92 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
2952 | 2952 | | |
2953 | 2953 | | |
2954 | 2954 | | |
| 2955 | + | |
| 2956 | + | |
| 2957 | + | |
2955 | 2958 | | |
2956 | 2959 | | |
2957 | 2960 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
2922 | 2922 | | |
2923 | 2923 | | |
2924 | 2924 | | |
| 2925 | + | |
| 2926 | + | |
| 2927 | + | |
2925 | 2928 | | |
2926 | 2929 | | |
2927 | 2930 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
3397 | 3397 | | |
3398 | 3398 | | |
3399 | 3399 | | |
| 3400 | + | |
| 3401 | + | |
| 3402 | + | |
| 3403 | + | |
3400 | 3404 | | |
3401 | 3405 | | |
3402 | 3406 | | |
| |||
0 commit comments