feat(plans): tiered plans, feature gates and the AI usage bar - #7116
pedrofrxncx wants to merge 40 commits into
Conversation
Adds an organization-scoped entitlements layer: which features an org's plan includes, and how much of its monthly AI allowance is used, as a percentage. Studio holds no plan definitions. It hardcodes the feature KEYS (`cms`, `chat`, `kanban`, `monitoring`, `model_choice`, `diagnostic`, `diagnostic_enriched` — all public strings) and every value is answered per-org at runtime by the configured AI provider adapter. Deployments without one behave exactly as before: no adapter, no entitlements, every gate open. Server-side enforcement sits at the same chokepoint as the org-block gate: a tool declares `requiresFeature` and is covered, so no handler has to remember a check. Surfaces that are not tools declare their own gate at their own single chokepoint — chat at the Decopilot messages POST, `monitoring` in requireOwnedSite, `cms` in resolveDecofileScope. Every gate FAILS OPEN when the provider has no answer at all. This is product gating, not access control: locking a paying org out of its CMS because one fetch blipped is the worse bug, and a refresh failure serves the stale entry rather than denying. `requiresAiBudget` refuses a call once the usage bar reads exhausted. Dormant behind STUDIO_PLAN_USAGE_ENFORCED, because a hard refusal on the chat and dispatch hot paths must not arrive with a deploy, and it is opt-in per surface so a full bar leaves the CMS and monitoring working. Model selection can be delegated to the provider: a provider-supplied model pin now outranks the per-user override and the org's own slot in resolveTier, but only for an org whose plan lacks `model_choice`. AI_PLAN_ENTITLEMENTS therefore projects the pin out through an allowlist rather than a delete, so a field added to the wire later is omitted by default. Six call sites that rendered a per-message cost or a model name for every org now read useShowThreadCost; the rows drop out rather than greying, because a greyed "cost —" still says a cost exists. One was an aria-label, where a screen reader exposed what the eye did not. The entitlements card renders an explicit "couldn't read this organization's plan" with a retry instead of returning null, and useEntitlements no longer swallows the error: a failure the user cannot see is a failure nobody can report.
9303404 to
f5a3533
Compare
Default off. Off means the previous behaviour exactly: the server-side gate answers nothing, so every feature reads as open, no model pin is honoured and no budget is enforced. The flag is published on /api/config so the browser skips the entitlements query too. Separate from aiGatewayEnabled on purpose — having a gateway configured must not switch plans on with the deploy.
Storing a provider key, completing the provider OAuth exchange and linking a Claude subscription are all ways of choosing your own model, so all three now require the model_choice feature. Drops the hardcoded upsell copy from the simple-mode section; the paywall states it instead.
Sidebar chip becomes a usage bar instead of a dollar balance; the wallet stays the only place a dollar figure appears. Deployments with plans off keep the credit chip. Also stops the feature paywall reappearing behind a collapsed main panel: closing it now marks the feature dismissed for that tab.
…gured Without a hosted agent sandbox, resolving claude-code as the default harness only fails later at dispatch, and assembling the VM file tools throws outright. Both now check agentSandboxEnabled() first and fall back to the plain decopilot harness with no VM tools.
`publicConfig` is persisted to localStorage and hydrated before React mounts (lib/query-persist.ts), and both readers asked for `staleTime: Infinity`. A hydrated entry is therefore never stale and never refetches, so a field added or flipped server-side stays invisible to an existing browser until the 24h expiry or the next `__STUDIO_VERSION__` bump — no amount of reloading clears it. Found by STUDIO_PLANS_ENABLED reading false in the browser while /api/config served true. That also makes flipping the flag in prod a release-coupled operation: restarting pods with a new env value but the same version would leave open sessions on the old config for up to a day. Five minutes instead. Hydration still paints with no spinner; this only restores the background revalidation that query-persist already documents these queries as doing.
… flag Three things the plan surface was missing. **`credits` gates the top-up path.** Free's $2 allowance is a ceiling, and the way past it is a plan. `AI_PROVIDER_TOPUP_URL` declares `requiresFeature: "credits"` — gated on the server as well as in the UI, because a checkout URL is the thing that takes money and hiding a button is not a control. The exhausted copy now says "upgrade" rather than "upgrade or top up" for a plan that cannot top up, and the plan picker labels Free's chat as limited instead of listing it beside the paid tiers. **No second enforcement flag.** `STUDIO_PLAN_USAGE_ENFORCED` is gone. `STUDIO_PLANS_ENABLED` already governs the whole feature: with it off `getOrgPlanState` answers null, so `usageState` is null and `isUsageBlocked` is false anyway. A second switch only bought the half-on state where the bar fills and nothing happens. **`useFeaturesSettled` for surfaces the plan can REMOVE.** The gates fail open while the query is in flight, which is right for correctness and wrong for rendering — the ungated UI paints for a frame and then swaps. A surface the plan removes (the model picker, the BYO tiles) now waits for the answer; surfaces the plan only annotates keep failing open. Entitlements are also persisted and revalidated, so a cold cache does not flicker, and a finished chat turn invalidates them so the bar does not sit at a stale percentage.
…ing open silently Three defects in the plan gate, plus the escalation's mesh half. **An exhausted bar refused work an org had already paid for.** `isUsageBlocked` read only `usageState`, and `OrgPlanState` dropped the `credits` field on the floor. So a Pro org that spent its allowance, bought $100 of credits and had the provider headroom to match was still 403'd on every chat turn — while the exhausted copy told that same org it could "upgrade or top up". The bar is the plan's envelope and money still cannot move it; credits are a second pool that is nonetheless spendable, and they fund the key directly. `credits: null` stays "the gateway didn't say", so it cannot manufacture a block. Removing `STUDIO_PLAN_USAGE_ENFORCED` is what made this reachable — there is no longer a second switch holding the stop dormant. **`planStateCache` was unbounded.** A module-level Map with a TTL check and a bare `.set()`, read on every gated tool call — the same bug #7083 fixed for `orgArchivedCache` and #7123 for `orgNoticeCache`, whose TTL rationale this file's docblock had copied without the bound. Mirrors that pattern exactly: a cap, expired-first eviction, and a delete-then-set refresh so a hot org moves to the newest position instead of being evicted first. **A failed entitlements lookup was swallowed by a bare `catch {}`.** It still fails open — a gateway blip must not take an org's CMS down — but silence made a misconfiguration indistinguishable from a healthy free org: a wrong service key means every tenant owns every feature, forever, with nothing logged. One warn line. This paid for itself immediately: it located a 401 on the gateway within seconds during end-to-end testing. **`AI_PLAN_SET` was in `basic-usage`** — inside the sub-block its own comment calls read-only — so every member held the tool that changes the org's plan and takes no payment. It lives in `ai-providers:manage` only now; reading the plan and the catalog stay basic-usage, since the billing card and picker need both. `setPlan` also now sends `X-Provision-Key`, which the gateway requires. **`Progress` rendered EMPTY above 100%.** The indicator is positioned by `translateX(-(100 - value)%)` inside an `overflow-hidden` track, so 137 translated it out of the track — reading as "nothing used" at the exact moment an org is over its limit, which is verbatim the misreading the usage card's docblock says must never happen. Clamped in the shared component, so every consumer is covered. Verified over studio's own HTTP surface (real Better Auth signup, real org, real gateway): `AI_PROVIDER_TOPUP_URL` and `/monitor/*` both 403 `feature_not_in_plan` on Free and both pass the gate on Ultra, and `AI_PLAN_SET` round-trips 200 with a correctly shaped body.
…suite able to fail
STUDIO_JWT_SECRET fell back to BETTER_AUTH_SECRET, a value with no reason to
match the gateway's MESH_JWT_SECRET. When it doesn't, every /entitlements call
401s, every gate reads that as 'no answer' and OPENS, and the paid product is
free for everyone with nothing in the request path saying so. It is the single
most likely misconfiguration of this feature. resolveConfig now refuses to boot
when plans are on and no explicit secret is set; mintGatewayJwt keeps the same
invariant for settings built by another path.
And the suite could not have caught any of this. A mock.module for the settings
module escaped sandbox/start.test.ts — Bun keeps module mocks alive for the
whole shard, and a top-level afterAll runs at the end of the PROCESS, not the
file, so there is no way to put it back. Every later file saw a getSettings()
returning only { nodeEnv }, so the two flags the gate reads were undefined,
which the gate treats as 'no answer' and opens: any test written for those
gates would have passed whether or not the gate worked. The file only ever
needed nodeEnv, so it installs real settings through the real accessor and
mocks nothing.
apps/api/src was RED on that: 3777 pass / 3 fail, two files failing outright on
'http://localhost:undefined'. It is 3791 / 0 now. The third failure was the
same class — a leaked mock of refresh-access-token decided whether a backoff
window armed — and that test now asserts the window itself through a named
predicate instead of inferring it from a network call.
settings-not-mocked.test.ts is a canary so the next leak fails loudly.
…TA survives the click
feature_not_in_plan and ai_budget_exhausted were handled NOWHERE in apps/web —
callStudioTool structurally could not see them, parsing only { error } off a
non-2xx. So every window in which the client's gate fails open (first paint, an
org switch, an error state, a cross-pod skew right after an upgrade) ended at
the server gate as a generic error, indistinguishable from a bug, while the
settings card promised 'chat and tasks pause until you upgrade or top up'. The
error now carries the code, planRefusalOf() reads it, and the query and
mutation caches say what happened wherever it lands. A plan refusal is also no
longer retried: it is a decision about this org, not a blip.
'See plans' fired two router navigations in one tick — the absolute one to the
plan card, then the caller's onDismiss, which on the tab call site is
closePanel()'s route-relative replace:true. The second won, so the primary
upsell CTA closed the panel and never reached the plans. Dismiss and see-plans
are separate callbacks now.
And dismissing the tab paywall left that view permanently blank: TabBody is
mounted once for the life of the panel, so the dismissal outlived it and every
later click on that tab rendered an empty panel — no content, no paywall, no way
back. Dismissal is the URL's own ?mainpanel=false, which openPanel clears.
New i18n keys added to en and pt-br together.
… flag that did nothing A 4xx from the gateway is a DEFINITIVE answer from a reachable service — a wrong service key, a wrong JWT secret, an identity it will not accept. Retrying cannot fix it, it does not pass on its own, and its blast radius is every org at once. A 5xx or a network error is a blip. Both had the same consequence (the gate has no answer and opens) and the same single warn line, so a fleet-wide 'the paid product is free for everyone' read exactly like a momentary hiccup. The 4xx is an error now, and it names the two env vars to check. The root test script carried --parallel for months. It is a `bun run` flag, not a `bun test` one, and bun swallows unknown flags silently — `bun test --definitely-not-a-flag` exits 0 — so the entire unit tier ran in one process the whole time while the script said otherwise. Removed, and TESTING.md now lists both this and the mock.module trap as anti-patterns, since the second one is what made the gate tests unable to fail.
…e could not deploy The chart's Secret template enumerates known keys, and the two shared secrets the gate needs were not among them: STUDIO_JWT_SECRET (== the gateway's MESH_JWT_SECRET) and STUDIO_PROVISION_SECRET_KEY (== its STUDIO_PROVISION_KEY), plus DECO_AI_GATEWAY_ADMIN_TOKEN for credit top-ups. So a Helm deployment could set STUDIO_PLANS_ENABLED and had no supported way to supply the secret — and since Studio now refuses to boot in exactly that state rather than opening every gate, this was a hard deploy blocker rather than a silent one. Found by deploying it. values.yaml also spells out that turning plans on is a fleet-wide entitlement cutover and that the gateway's org_plans backfill comes first.
…eplicas Measured in the cluster: 120 concurrent gated calls returned HTTP 500 'sorry, too many clients already'. The arithmetic is exact — DATABASE_POOL_MAX defaults to 20, and it is per API PROCESS, of which each pod runs two, plus a worker replica: 20 x (2 x 2 + 1) = 100, which is postgres's own default max_connections. So the bundled local Postgres ran out at the second replica, and the failure surfaced as a 500 rather than as a queue. The local umbrella's throwaway Postgres gets headroom; values.yaml spells out the formula next to the knob, because the number that has to change is not the one an operator is looking at when they scale replicas. Worth noting what did NOT happen: the gate did not fail OPEN under that pressure. Those requests errored, which is the safe direction — no org received a feature its plan excludes.
Installing real settings through the real accessor fixed the mock.module leak but kept the cross-file coupling one step milder: settings/index.test.ts's whole subject is getSettings()'s auto-init from env, and this file pre-empted it. bun test already sets NODE_ENV=test, which is all this file ever wanted, so it installs nothing at all. apps/api/src: 3793 pass, 0 fail.
Two halves of the same problem — a gate that fails open is right for
ACCESS and wrong for what the plan says the org must not see.
The tab body now has three states instead of two. `useFeature` fails
open while entitlements load, so a cold `/{org}?main=board` mounted the
real view, fired its own queries against BFF routes that answer 403, and
then swapped in the paywall: a layout thrash on the primary panel plus a
lazy chunk downloaded for a view the org cannot open. It now renders a
skeleton until `useFeaturesSettled` — the body withheld, the paywall
withheld too, since the org may well own the feature and a paywall that
flashes at a paying customer is the worse frame.
`useShowThreadCost` becomes `useModelDisclosure` and fails CLOSED. The
model's name and per-message cost are withheld below Ultra (§1, §6), but
fail-open showed both for the first frame of every mount and every org
switch — and, when the entitlements read failed, for the whole session,
because "no answer" reads as allowed. It now wants a real success. The
tier picker reads the same gate rather than settled + fail-open. Plans
off still discloses everything, exactly as before.
The rule is extracted as `modelDisclosureAllowed` so it has a test that
does not need a query client.
`usePlansEnabled` reads the non-suspending public-config hook, so it cannot tell "not loaded" from "plans off" — and on a plans-ON deployment the first frame took the plans-OFF branch. The sidebar rendered CreditChip's dollar amount and the AI Providers hero a $xx.xx at text-3xl, both then swapping out, and the hero also fired the credits query the plans path does not want. The pricing model says consumption is a percent and dollars appear only at a top-up; this showed dollars in two places first. Localstorage hydration hid it on repeat visits, so it was a first-load, cleared-profile and private-window bug. All three call sites sit inside a Suspense boundary, so they now read the suspending `usePublicConfig()` and wait for the answer. The plan card too, which was merely absent-then-present rather than wrong. `usePlansEnabled` stays for the leaf components that render outside any boundary of their own.
… a no-op Two corrections to my own last two commits. `TierTrigger` was moved wholesale onto the fail-CLOSED disclosure gate. It is both things at once: whether the picker EXISTS is access (below Ultra there is none), and whether its rows may NAME the model is disclosure. Collapsing them meant one blipped entitlements read — with `retry: false` — removed the only fast/smart/thinking selector from the composer for the rest of the session, for an org that pays for it. The existence gate goes back to failing open; the names and the override cog fall back to the tier blurbs when disclosure is closed, which is the shape the pricing doc describes for a non-Ultra org anyway. And `45265dc5e` is reverted: its premise does not hold. `ThemeProvider` calls `useSuspenseQuery` on the same `KEYS.publicConfig()` entry above every other provider and stays a mounted observer, so `usePublicConfigOptional()` cannot return `undefined` for anything below it — the dollars-then-percent flicker it claimed to fix is not reachable, and the change was a no-op on a false story. The one real stale-config path (a localStorage-hydrated entry from before a flag flip) is served identically by both hooks.
The paywall work shipped inert, and one gated feature had no server-side
enforcement at all. Both found by review of this branch; each fix has a
test that fails when the fix is reverted.
`tools-rest.ts` stripped `code` off every plan refusal. `FeatureNotInPlanError`
and `AiBudgetExhaustedError` both extend `ForbiddenError`, and that branch
answered `{error}` only — so `planRefusalOf` returned null for every
gated tool, `notifyPlanRefusal` never fired, and the "never retry a plan
refusal" rule never applied. A Free org clicking "Buy credits" got an
untranslated internal string, retried once. The code now travels.
KANBAN had no server gate. `kanban` is Ultra-only, every `TASK_BOARD_*`
tool sits in the basic-usage capability granted to every member of every
org, and the only obstacle was the client's tab paywall — so any member
of a Free org could create a card and re-run it through the tool REST
endpoint and get a working, unbilled agent fleet. The gateway even built
a chokepoint for this (`/tasks/claim`, with its 402 and trial grants) and
mesh never called it. Gated at `enqueueAgentRunForTask`, which is the
point that spends, rather than on the ~25 board tools: reading and
organising a board costs nothing. The budget stop rides along.
Mesh now REFUSES TO BOOT with plans on and no `STUDIO_PROVISION_SECRET_KEY`.
Only the JWT-secret half had that check, though the consequence is
identical and just as fleet-wide: without the provision key the gateway
cannot recognise this server, falls back to a per-user membership
callback that 401s for anyone who never completed the gateway OAuth flow,
and every gate then fails open. The gateway's own route comment already
described this outcome verbatim.
A stale plan state now has a 10-minute ceiling. Serving stale is right
for a blip, but a cached features map is authoritative and an absent key
DENIES — so serving stale is fail-CLOSED whenever the stale value is a
deny, and the failure path never advanced `at`, so there was no ceiling:
an org upgraded through the gateway's admin route (which does not
invalidate this cache) and then hit by an outage was locked out of the
CMS it had just paid for, indefinitely.
A 200 whose `features` is missing is now a failed read. The wire body is
an unchecked cast, so `features: undefined` reached `isFeatureAllowed`,
which reads it as "no answer" and ALLOWS — and it was written to the
cache, so every gate opened for the full TTL with no log line at all.
`{}` stays a legitimate body: an org entitled to nothing.
The gateway's named refusals (`plans_disabled`, `service_key_required`)
surface as refusals instead of an opaque 500 carrying an HTTP number. An
operator mid-rollout, mesh's flag on and the gateway's still off, saw
"Couldn't change plan: Failed to change plan: 503".
Client: `usage: null` no longer draws a full-width EMPTY track, which is
the one reading the card's own docstring forbids — an empty bar says
"nothing used", not "we could not read it". Both percent call sites are
clamped and finite-checked: `width: NaN%` is an invalid declaration the
browser drops, so a bad number painted a FULL bar. `useFeaturesSettled`
treats React Query's offline `paused` state as settled, or a gated tab
body was a permanent skeleton while every ungated tab rendered. And the
plan-refusal toast has real fallback copy, since its translations are
filled in mid-render and a refusal from above that point toasted "".
Tests: `requiresFeature`/`requiresAiBudget` now have enforcement tests
that mount a real `defineTool` — a mutation audit found `isFeatureAllowed`
could `return true` and the whole enforcement block could be disabled
with a `false &&`, both with the full API suite green, because the
predicates were covered and the wiring was not. Same for the new Kanban
gate. Each verified by re-running the mutation.
`GET /api/teams/:orgId/balance` was the one route on that router with no
membership check — any signed-in user could read any org's balance and
`credit_funded` by id, and org ids are not secret. It is not even a pure
read: `readKeyUsage` calls `forgetKey` on a provider 404, which deletes
the key row and clears the usage baseline, so an unauthorized caller was
reaching a write. It now checks membership, with the same service-key
bypass `/entitlements` has, and mesh's adapter sends that key on this
call — otherwise the check would 401 every user who has never minted a
per-user gateway-OAuth token, which is most of them.
Also labelled the adapter's dead top-up fallback: it posts to the
gateway's `/api/credits/checkout`, retired on both branches, so the 404
that has been read as "Stripe is absent" is actually "the route is gone".
The top-up path has never been verified end to end in either direction.
And the two new gate tests no longer poison their neighbours. They set
plans on via `mock.module("@/settings")`, which is process-wide, and with
a partial settings object — so `/api/config` and the hosted-provider
schemas started failing on fields those files never mentioned. They now
build a COMPLETE Settings through the real `resolveConfig`, install it
with `setGlobalSettings`, and restore the previous one afterwards; the
gateway adapter's `getEntitlements` is swapped as a single property on
the singleton rather than as a module. The CI-equivalent api suite is
3806 pass / 0 fail, up from 3793.
…uter IMAGE_MODEL_PREFERENCES listed openai/gpt-image-1 and google/gemini-2.0-flash-image for deco and openrouter. Both are gone from OpenRouter's catalog, so neither the exact nor the substring pass could match and the slot always fell through to the capability predicate — which takes the first entry advertising image output, in catalog order: openrouter/auto-beta, the auto-router. It claims every modality, then resolves the image call to whatever text model it likes, and OpenRouter answers "No endpoints available for any resolved phaser models: z-ai/glm-5.2". Preferences now name models that exist, and the fallback excludes openrouter/auto* outright. That second half is the load-bearing one: this list will go stale again, and without it a stale list silently routes image work to a text model instead of failing.
… monitoring surfaces Three surfaces rendered the model id straight through, with no disclosure gate: the generate_image card (its `model` comes back in the tool result), the web_search card, and the monitoring model leaderboard. The monitoring one is the widest: `monitoring` is a Pro feature while `model_choice` is Ultra-only, so every Pro org has been seeing a ranked model breakdown, including a mode="cost" variant printing dollar figures through formatUsd. Gated inside ModelLeaderboardTable rather than at its six call sites, so a seventh cannot forget. All three use the existing useModelDisclosure(), which fails closed and returns true with plans off, so nothing moves on the old path. Not closed here: the name still ships in the tool-result payload and is persisted into thread_message_parts, so this is a UI gate, not a real one. If §6 is a disclosure promise rather than a convention, the strip belongs server-side.
The only gateway read that omitted X-Provision-Key — balance, entitlements, setPlan and provisionKey all send it. Without it the gateway cannot tell mesh's server from a browser, so it falls through to canAccessOrg, which calls back over a per-user gateway-OAuth token most users have never minted. That answers 401 mesh_token_expired and the plan picker renders an empty catalog forever. The gateway's /plans route grew the service-key bypass precisely for this call, so the bypass has been unreachable on this path. Optional like the other reads, so a self-hosted deployment with no key still falls back to the membership check.
…e grant, and the gate's fail-open doors
Lands the uncommitted Stripe→entitlement join (none of it was in the PR) with
fixes for the findings pass 6 confirmed.
ENFORCEMENT
- `nudgeThreadTurn` takes the `kanban` gate and the budget stop, next to
`enqueueThreadRun`. It is the other way to dispatch a full agent run onto a
card and it was the way round both: `TASK_BOARD_ITEM_LIST` is basic-usage —
every member of every org — and fires `recoverStalledTasks` fire-and-forget,
which lands here; so did the reviewer sweeper's boot-time timer, with no member
action at all. Gated in the callee, not at the three call sites, because one
forgetting is the whole bug. All three callers already isolate a throw.
- `git/suggest-commit` and `git/judge-review` on the sandbox proxy take
`assertAiBudget`. They are the only routes under /sandbox that spend: one
`generateText` each on the org's gateway credential, with a caller-sized prompt
up to 512 KB, reachable by any plain member — and they are BFF routes, so
`defineTool`'s `requiresAiBudget` never saw them.
- `requireOwnedSite` checks ownership before the plan, so an unowned-slug probe
gets the documented 404 instead of a 403 that both leaks the gate's existence
and costs a gateway round trip.
- `defineTool` warns when a tool declares a plan gate and no org is in scope. It
runs ungated in that case; harmless today only because all five gated tools
happen to call `requireOrganization` themselves.
MONEY
- `setGatewayOrgPlan` separates "this deployment has no gateway" (return, still
correct for self-hosted) from "there IS a gateway and we cannot write to it"
(throw, so Stripe's redelivery is the retry queue — what `creditGatewayTopUp`
already does). One silent `return` covered both, so a paid upgrade granted
nothing and a cancellation revoked nothing, with no log and no throw, against
this function's own docblock, its caller's comment and the rollout doc. Stripe
saw a 200 and never redelivered; `AI_PLAN_SET` cannot repair it, it takes only
`free`.
- Two boot assertions, beside the two that exist:
· `plansEnabled && !aiGatewayEnabled` refuses. The flags are independent and
only the first was guarded, so plans-on + gateway-off opened every gate on
both halves — and the chart ships `DECO_AI_GATEWAY_ENABLED: "false"` as an
active default while its plans block never mentions it.
· a non-empty `STRIPE_PLAN_PRICE_IDS` with no `DECO_AI_GATEWAY_ADMIN_TOKEN`
refuses. Selling a tier the gateway cannot be told about is a card charged
for an entitlement that cannot land.
- The Stripe grant path invalidates the plan-state cache, as the downgrade path
already did. Per-pod and 60s-bounded either way, but the grant did not
invalidate at all, so the pod that took the webhook could refuse the customer
the features they had just paid for.
GATE BEHAVIOUR
- One in-flight entitlements read per org. N concurrent gated requests were N
gateway round trips, and the failure path never writes the cache — so a stalled
gateway meant every request paid the full 10s timeout with no short-circuit.
- `EntitlementsFetchError` carries the gateway's `code`, read the way
`refusal()` already reads it for the two sibling routes. `plans_disabled` is
the gateway's own flag being off — a state the runbook CREATES, since the
gateway deploys before either flag — and it was logged as "gateway
unreachable" on every gated call on every org throughout the dormant window.
CLIENT
- `ai-plan-entitlements` is no longer persisted to localStorage. A hydrated
entry restores as `status: "success"`, so `useFeaturesSettled()` and
`isSuccess` were immediately true from a decision up to a day old: model names
and per-message costs for an org that has since dropped off Ultra, and
`FeaturePaywall` at an org that paid ninety seconds ago, on the first frame of
every reload. The flicker it was added for is what the third-state skeleton
already handles.
- `retry: 1` on that query. It is invalidated after every assistant turn and
`useModelDisclosure` fails CLOSED on `isSuccess`, so with no retry a single
blip stripped model names and costs from a paying Ultra org's screen
mid-thread. Three was what made Billing & AI hang; one is a round trip.
- The monitoring overview's cost card is gated on the same disclosure rule as
its leaderboard. The gate's own comment gives "nor a dollar figure" as half its
reason while the card's headline figure and its cost time series, three lines
below, were ungated — and with disclosure off and spend > 0 the card rendered
an empty body under a populated headline.
- The chat credits modal and the exhausted banner check `credits`.
`AI_PROVIDER_TOPUP_URL` declares `requiresFeature: "credits"`, which Free does
not have, so every amount on those surfaces returned 403 while the plan card in
the same product said the allowance cannot be topped up.
CONTRACT
- `periodStart`/`periodEnd` are nullable end to end, following the gateway:
in trial mode the bar is lifetime-over-lifetime and never resets.
Tests: the two new boot assertions and the `setGatewayOrgPlan` split are
covered; the suites over the changed areas go from 816 pass / 16 fail to
832 pass / 15 fail (the remainder are pre-existing DB-dependent cases).
--parallel was removed as "a flag bun test does not have" after `bun test --definitely-not-a-flag` exited 0 locally. That check was run against Bun 1.3.11; CI pins 1.3.14 (.github/actions/setup-bun), where `--parallel` is real: "Run test files in parallel using N worker processes. Implies --isolate." Without it the whole unit tier shares one global, and 168 tests in files the PR never touched failed on each other's leftovers — happy-dom unregistered so `document.body` was undefined, a readonly `window.localStorage`, leaked mock.module stubs. Measured on ./apps/web/src with 1.3.14: 0 fail with the flag, 118 fail and 22 errors without. TESTING.md's anti-pattern entry said the opposite, so it is rewritten to say which Bun to run `--help` against. Separately, default-model.test.ts typed a distractor model "tools", which is not in MODEL_CAPABILITIES. The test only needs it to not be an image model, so ["text"] carries the same meaning and compiles.
…t read The bar's numerator is OpenRouter's per-key usage counter, and that counter settles asynchronously. `onFinish` invalidated the entitlements query the instant a turn ended, so the refetch it triggered returned the PRE-turn number and overwrote nothing — the bar sat at the same percentage through a whole session of spending and read as broken. The invalidate was doing the opposite of its job: racing the provider and winning. The turn's own cost is already in hand here — it is what the per-message cost pill renders — so the client moves the bar itself and lets the next natural read reconcile. `refetchType: "none"` marks the query stale without firing that losing race; a focus, a remount or the 60s staleTime replaces the estimate with the gateway's truth once the provider has caught up. Moving a percent needs its denominator, so the gateway now sends the bar's `usedMicros`/`limitMicros` alongside it. They are arithmetic and are never rendered: §1 still holds, the bar is a percent and the only amount an org is shown is a wallet top-up. Both are nullable, so a gateway that predates them skips the bump rather than dividing by undefined — as does an org whose envelope is zero. ponytail: the reconcile can step the bar back slightly if the provider is still settling when it lands. Cheap against a bar that never moved at all; make the merge monotonic per periodStart if the flicker bites.
The gateway sends a period_end for every plan, free included, and the card rendered it as "Resets on <date>". Free has no allowance — its funding is the one-time $2 trial deposit — so that date was a promise of money that never arrives. Free now reads as a one-time allowance and shows no reset date.
At 100% the turn was refused, but by the wrong check and in the wrong
words: "No model available for tier \"smart\". Connect a provider" —
rendered as the literal JSON envelope, braces and escaped quotes and all.
Three holes, one per layer.
1. The messages POST resolved the tier BEFORE its own plan gate. An org
whose allowance is spent has an unfunded key and an empty catalog, so
`resolveTier` threw first and `assertAiBudget` never ran. The gate
moves above `validate()`; the org id is the route's own scope, so it
needs no body parse to read.
2. The composer sent anyway. `useAiBudgetExhausted` mirrors the server's
`isUsageBlocked` — the same two conditions, so the client refuses
exactly what the POST would — and stops the send with the paywall
dialog instead. The draft stays in the composer.
3. The error rendered as a blob. `chatPostErrorMessage` unwraps the
route's `{ error, code }` envelope and tags the two plan refusals, so
a stale-by-a-minute client (the turn that exhausts the bar is by
definition sent while it still read ok) gets a card with "See plans"
rather than an error. `parseErrorMessage` unwraps the envelope for
every other error on that path too.
Also: a mid-stream 402 on a plan that cannot top up rendered NOTHING —
the top-up dialog returns null without the `credits` feature, so a Free
org's turn failed with no card and no explanation at all. It routes to
the plan card now.
|
Closing as stale: this PR sat past the bot's 48h merge window, main has moved on, and its CI results no longer reflect the current base. This is a housekeeping close, not a rejection of the change — if the underlying problem still exists, the bot will find it again and open a fresh, rebased PR. [studio-bot:stale-close] |
The gateway's own section had nothing left to say. Plans moved the balance into PlanUsageCard and gated the top-up on `credits`, so on Free it rendered a titled card holding a logo and a Disconnect button — no subject, no next action, and a destructive control as the only thing to click. Free is also the plan most orgs are on, so that empty box was the common case. Credits are the second of the plan card's two pools, so they belong in it: one card, one subject, no second place to look. `QuickTopUp` is exported and rendered there; the standalone section now renders only with plans OFF, which is the path that has no plan card at all. Also drops the em dashes from the plan copy, per the house style. Known: with plans ON there is no longer a Disconnect affordance on this page. Deleting the key takes out every plan, bar and gate that reads from it, and it is provisioned automatically at org creation, so hiding it is arguably right — but it is a product call, and a one-line revert if not. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Choosing a plan used to be a dialog behind a "Change plan" button: four rows, one feature subtitle each. Comparing plans is the whole decision and a dialog gave it four lines. The catalog now renders on the page as four cards with the same feature rows in the same order, so what a tier adds is read down a column and across a row. The current plan is ringed with a dashed "Current plan" slot; the tier right above it gets the primary button. Names and feature flags only, by design: allowances and prices never reach this client. The blurbs are copy about the tier, not terms. Also: - Downgrading to Free asks first. It removed every gated feature on one click with a toast. - "Manage billing" on the plan card when the org has a Stripe account. AI_PLAN_SET refuses to drop a subscribed org and tells it to cancel in billing, but this page had no way there. - Plan card: bigger plan name, credits line separated from the hint, the top-up under its own rule. Skeleton sized to the card. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ady say The plan card carried a "Current plan" eyebrow under a section titled Plan, a two-sentence explanation of exhaustion next to a badge that says Limit reached, and a sentence introducing the credits amount. The plan cards each opened with a blurb. All of it restated what the layout shows. One line under the bar now, and it only says when the bar moves. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A label, a 2px bar and a percent read as a settings row. The percent is the one figure this card is allowed to show, so it is now the figure: 6xl, coloured by state, with a thicker bar under it and the reset date on the same line. The state colour makes a full bar read as full from across the room, so the "Limit reached" badge said it twice and is gone. Credits and the top-up share one footer row; the balance sits inline, and only once the bar is full. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The plan ladder needs one colour per tier, and the tiers are ours — so they come from the brand palette, not from a new token family invented for the ladder. `--brand-blue` is the only new value; the other two lines just expose existing brand values to Tailwind so `text-brand-purple` and `text-brand-blue` resolve at all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The plans surfaces were assembled piece by piece, and it showed: the plan card, the catalog and the paywall each drew the tier differently, and the paywall was a panel-shaped page rather than a dialog. `plan-ladder.tsx` is now the one place that answers "what does this tier look like" — its mark, its colour, its price. Everything that draws a tier reads from it, so the card and the catalog cannot disagree. `PaywallDialog` is the shell every paywall wears: a centred column, the tier's mark printed large as the artwork, and light falling from the top edge in the tier's own colour. `FeaturePaywall` fills it in, and the gated panel now renders a populated, inert board behind it, so the dialog is not sitting on an empty screen. The sidebar follows the same rule: a row whose view the plan withholds carries a lock, defined once in `use-tab-locked.ts` so both nav lists agree, and the footer icons finally line up on the same 16px slot as the rows above them. Prices are hardcoded in `planPriceBrl`, keyed by plan id, until Stripe is wired. A mark on the wrong rung is cosmetic; a price on the wrong plan is a misquote. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… run Two bugs that turned out to share a file. A plan without `credits` cannot top up — `AI_PROVIDER_TOPUP_URL` requires that feature — so both credits dialogs bailed with `return null`. The exhausted banner already had the fallback its caller needs; the empty state did not, so an org whose allowance was spent opened a chat it could not run and was told nothing at all. It now shows the same plan dialog the composer shows for a spent allowance. The two dialogs were also copies of each other: the same presets, the same currency toggle, the same mutation and the same tracking, under two sets of translation keys holding the same sentences. `TopUpAmounts` is that shared picker, and the keys move to a `credits` domain, since they were never about chat. Two deliberate changes to behaviour: - The currency now follows the user's language, as the settings top-up already did; the dialogs hard-coded USD. - `tier_label` reports a stable slug. It used to report the translation key, and the two surfaces sent different ones for the same tier, so one tier read as two in the funnel. Historical values will not match. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Reopened and fast-forwarded to Why this PR and not that one: #7218 was opened as a reopen of this branch after it was closed unmerged, but the work originated here. Rather than carry the review history and the authorship onto a second PR, the ten What came across: Authorship: the design commits keep @rafavalls as their author in git. The body has been rewritten to describe both halves, and the gaps that came with the design pass (no paywall backdrop for CMS or Monitor, BYO keys orphaned on downgrade, the Stripe dev string in the subscribe toast, hardcoded Still blocked on decocms/ai-gateway#30 merging first. |
|
Closing as stale: this PR sat past the bot's 48h merge window, main has moved on, and its CI results no longer reflect the current base. This is a housekeeping close, not a rejection of the change — if the underlying problem still exists, the bot will find it again and open a fresh, rebased PR. [studio-bot:stale-close] |
…wser `hydrate` restores each persisted entry with its original `dataUpdatedAt`, so a reload inside publicConfig's staleTime served the localStorage copy and never asked the server. publicConfig is how every deployment flag reaches the browser — STUDIO_PLANS_ENABLED among them — and those flip server-side with no version bump to bust the cache, so flipping one and restarting, which takes far less than the staleTime, left the old value painted with nothing to dislodge it. Mark that one key invalidated after hydrating: an invalidated query is stale regardless of staleTime, so the first observer refetches once via refetchOnMount. Hydration still paints instantly — only the guaranteed revalidation is added, and only for publicConfig.
…ture-gates # Conflicts: # apps/web/src/lib/query-persist.test.ts
The studio half of tiered plans. Gateway half: decocms/ai-gateway#30, which should merge first — without it
DECO_AI_GATEWAY_ENABLEDis unset, no gateway adapter exists, and every gate here fails open, so this PR is inert rather than broken.The split this PR preserves
Studio stays open source and holds no pricing logic. It hardcodes the feature keys —
cms,chat,kanban,monitoring,model_choice,diagnostic,diagnostic_enriched, all public strings — and every value is the gateway's answer for that org at that moment. That is what lets a gate or an allowance move without a mesh deploy. No allowance, price or margin appears anywhere in this diff, with one marked exception (planPriceBrl, below).Server-side enforcement
requiresFeatureondefineToolsits in the same execute wrapper as the org-block gate, so declaring it is the gate — no handler remembers a check. The surfaces that are not tools each declare their own at their own single chokepoint, which is one insertion per surface because each has exactly one:diagnostic_enrichedCOMMERCE_DISCOVERY_RUNrequiresFeature(+requiresAiBudget)chatPOST …/threads/:id/messagesrouteorgHasFeature+assertAiBudgetat the top of the handlermonitoringfetchrequireOwnedSite— the one guard all four monitor routes sharecmsresolveDecofileScope— the one middleware all four decofile routes shareA plan refusal returns 403 with a
codeand is logged as a warning, not through the error path — it is the expected answer for an org without the feature, not an incident.Every gate fails OPEN when the gateway has no answer at all (no gateway configured, no user to mint a JWT for, or a gateway that could not be reached with nothing cached). This is product gating, not access control: the gateway is what actually refuses the spend, and locking a paying org out of its CMS because one fetch blipped is the worse bug. A refresh failure serves the stale entry rather than denying.
Known gap: the sandbox proxy (
/api/:org/sandbox/*) stays open, deliberately — it is the whole sandbox surface (files, exec, preview, events) and not CMS-specific, so gating it oncmswould lock agents and dev sessions out. An org withoutcmscan still reach content through a live sandbox.The exhausted-bar stop — built, dormant
assertAiBudgetrefuses once the bar readsexhausted;requiresAiBudget: trueis the declarative form. This is the margin stop the bar was otherwise only reporting.STUDIO_PLAN_USAGE_ENFORCED, default off. A hard refusal on the chat and dispatch hot paths must not arrive with a deploy, and a self-hosted deployment never wants it.usage: nullis unknown, never exhausted. A gateway that could not read consumption must not stop a paying org's work. Unit-tested.cmsandmonitoringdeclare no budget, so a full bar leaves them working — which is the promise the billing card makes ("CMS keeps working, chat pauses").The Kanban gate and the budget stop apply at the task-dispatch paths and the two sandbox routes that spend, not just the board tools.
Model governance
Model choice is Ultra-only, and the gate covers the model's name, not just the picker — below Ultra an org is never told which model ran its chat, Kanban task, image generation or web research.
resolveTiernow resolves pin → user override → org slot → default-pick. A deco-admin pin from the gateway outranks everything, but only for an org withoutmodel_choice; an Ultra org bought the right to choose and keeps it. So below Ultra an org's writes to its own slots stopped deciding anything.AI_PLAN_ENTITLEMENTSprojects the pin out throughtoClientEntitlements— an explicit allowlist, not adelete, so a field added to the wire later is omitted by default instead of leaking on the next gateway deploy. Unit-tested for exactly that, including that the payload contains no model name.useShowThreadCost(). The cost and model rows drop out below Ultra rather than greying — a greyed "cost —" still says a cost exists. Token counts are unaffected, including the in/out rows that used to be nested inside thecost > 0branch. One of the six was anaria-label, where a screen reader leaked what the eye did not.Client gates and the paywall
useFeature("<key>")answers one gate;<FeaturePaywall feature="…">is the popup it opens on. Also fails open — in flight and on a failed read, every feature reads as included.cms·kanban·monitoringTabBody, keyed byfeatureForTabchathandleSubmitinchat/input.tsxcanSubmitso the click explains itself; a dead button reads as "my message failed", not "my plan is why". The draft is keptmodel_choiceTierTriggerGating at the tab body rather than the tab button is deliberate: a deep-linked
?main=boardgoes through the same door.The design pass
plan-ladder.tsxis the single answer to "what does this tier look like" — its mark, its colour, its price. The plan card, the catalog and the paywall all read from it, so they cannot disagree.PaywallDialogis the shell;FeaturePaywallfills it in. The gated panel renders a populated, inert board behind it so the dialog is not sitting on an empty screen. Site Editor and Monitor deliberately have no backdrop — see Known gaps.use-tab-locked.tsso both nav lists agree.TopUpAmountsis the shared one, under a newcreditsi18n domain.return nullon a plan withoutcredits. The empty state had no fallback, so an org whose allowance was spent opened a chat it could not run and was told nothing. It now shows the plan dialog.The billing card
Plan name, a Change plan button, the bar as a percent with ok/warn/exhausted colouring, and "Resets on 8 October 2026" — a real date, from the same rule that performs the roll, not "next month". Plans with no AI envelope hide the bar entirely and say so; a full red bar would read as you burned through it on an org that never had any. At 100% a second line appears with the credit balance in dollars — the only amount in the card, and only at the moment credits are what the org is spending. The usage bar is a percent, never a dollar, and advances optimistically by each turn's own cost.
The card renders an explicit "couldn't read this organization's plan" with a retry instead of returning
null, anduseEntitlementsno longer swallows the error. A failure the user cannot see is a failure nobody can report — previously a 500 and a self-hosted deployment with no plan surface looked identical: an absent card.Payment
Stripe is the only way to acquire a paid tier: a price mapped in
STRIPE_PLAN_PRICE_IDSgrants the tier on an active subscription and revokes it on cancellation. Studio refuses to boot with plans on and no explicit gateway JWT secret or provision key, rather than silently opening every gate.Needs a decision before merge
planPriceBrl, keyed by plan id, until Stripe checkout is wired end to end. This is the one place a number appears in the diff, and it is marked temporary. Keyed by id and not by rung on purpose: a mark on the wrong rung is cosmetic, a price on the wrong plan is a misquote.Pro+wants to beScale. Product decision made; needs a gateway migration (drizzle/0009_add_plans.sqlseeds the name), so it is not in this diff.POST /api/teams/:org/tasks/claimexists on the gateway with e2e coverage and mesh never calls it.apps/api/src/billing/task-quota.tsis already a task-execution quota with the same shape — claim keyed by task id, charged at dispatch, refunded when a run produced nothing, period keys without cron, per-org overrides, behindSTUDIO_TASK_QUOTA_ENFORCED— but it answers "has this org paid for task executions?" against the Stripe subscription, where the gateway's grant answers "does this plan include kanban, or has it trial grants left?" against the plan. Wiring both means two sources of truth and two refund paths. The plan should eventually subsume the subscription quota — that is a migration, not a wiring task.Known gaps
TaskBoardPagereads one plain query key. Site Editor is a live sandbox iframe with nothing to seed; the CDN tab depends on a suspense query keyed by a runtime MCP client object plus ~7 derived keys and default component state. Left blank rather than coupled to two components' internals.ConnectedProvidersSectionis gated onmodel_choice, so an org that drops from Ultra keeps its keys in the database with no UI to see or remove them. Not fixed here.Stripe API 503: plan 'pro' has no Stripe price configured).ORGANIZATION_SETTINGS_UPDATEis still writable below Ultra. It is now a write that does nothing (the pin outranks it) rather than a privilege escalation, which is why this is a note and not a blocker — but arequiresFeaturethere would make it say so.ctx.access.check()the sibling AI-provider tools use, which is not a "may this person commit the org to money" check. Any member can move the org to Ultra. Must tighten alongside payment.etagbuys nothing yet. The gateway emits it and the adapter types it, but there is noIf-None-Match; the card leans on react-query's 60sstaleTime. Both response shapes now go through onetoPlanEntitlementsmapper, which is what a revalidation change would hook into — they had drifted as two copies.Testing
bun run check,bun run lintandbun run fmtpass.plan-feature-gate.test.tscovers both pure decisions:isFeatureAllowed(absent = denied,null= fail open, a truthy non-boolean is not granted) andisUsageBlocked(dormant unless enforced,nullusage never reads as exhausted).client-entitlements.test.tsproves the browser projection never carries a model name, and that it is an allowlist rather than adelete.tab-feature.test.tscovers the tab→feature map..context/plans-ui/plans.sh.No automated test yet for the tools, the card, the paywall, the four route-level gates, or
resolveTier's pin branch. Those are e2e-shaped (real server + a gateway fake) andpackages/e2ehas no gateway fake yet — worth a follow-up.Not verified by hand: Monitor below Ultra (needs seeded LLM stats — no OpenRouter credentials locally, so the cards read empty either way), the Billing states after a downgrade, and the plan card's loading/error/unavailable/warn states.