Skip to content

feat(plans): tiered plans, feature gates and the AI usage bar - #7218

Closed
rafavalls wants to merge 38 commits into
mainfrom
rafavalls/billing-plans-ui
Closed

rafavalls wants to merge 38 commits into
mainfrom
rafavalls/billing-plans-ui

Conversation

@rafavalls

@rafavalls rafavalls commented Sep 15, 2026

Copy link
Copy Markdown
Collaborator

The studio half of tiered plans, reopened. Supersedes #7116, which was closed unmerged — this branch is a strict superset of that PR's head (0 commits missing) plus main merged in and a design pass over every plans surface.

Gateway half first: decocms/ai-gateway#30. Without it DECO_AI_GATEWAY_ENABLED is unset, no gateway adapter exists, and every gate here fails open — so this PR is inert rather than broken.

Authorship

The server-side work — plan-feature-gate.ts, stripe-webhook.ts, entitlements.ts, topup-url.ts, the requiresFeature wrapper on defineTool — is @pedrofrxncx's, carried over from #7116 unchanged. The commits on top (10, all apps/web plus four lines of packages/ui/src/styles/global.css) are the design pass.

The split this PR preserves

Studio stays open source and holds no pricing logic. It hardcodes the feature keyscms, chat, kanban, monitoring, model_choice, diagnostic, diagnostic_enriched — and every value is the gateway's answer for that org at that moment. No allowance, price or margin appears in this diff, with one marked exception below.

The design pass on top

  • One tier ladder. plan-ladder.tsx is 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.
  • One paywall dialog. PaywallDialog is the shell; FeaturePaywall fills 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.
  • Locks in the sidebar. A row whose view the plan withholds carries a lock, defined once in use-tab-locked.ts so both nav lists agree.
  • One top-up picker. The zero-balance and exhausted-mid-stream dialogs were byte-for-byte copies; TopUpAmounts is the shared one, under a new credits i18n domain.
  • A Free org is told why. Both credits dialogs bailed with return null on a plan without credits. 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.

Needs a decision before merge

  • Prices are hardcoded in planPriceBrl, keyed by plan id, until Stripe is wired. This is the one place a number appears in the diff, and it is marked as 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 be Scale. Product decision made; needs a gateway migration (drizzle/0009_add_plans.sql seeds the name), so it is not in this diff.
  • Below Ultra, gated UI disappears silently — the tier pill in the composer, Default models and the BYO tiles on Billing, the Cost card on Monitor. Consistent with itself, but it contradicts the sidebar, which locks rather than hides. Worth settling in one pass across all three.

Known gaps

  • No paywall backdrop for CMS or Monitor. Kanban's works because TaskBoardPage reads 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.
  • A downgrade orphans BYO provider keys. ConnectedProvidersSection is gated on model_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.
  • The subscribe error toast leaks a dev string (Stripe API 503: plan 'pro' has no Stripe price configured).

Not verified

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.

Testing

bun run check, bun run lint and bun run fmt pass. Plans surfaces were exercised by hand against a local gateway via .context/plans-ui/plans.sh.


Summary by cubic

Adds tiered plans with per-org feature gates and an AI usage bar, enforced server-side so a gated tool is covered by declaring requiresFeature once. Studio hardcodes only the feature keys; every value is the gateway's answer for that org, and every gate fails open without an answer — the PR is inert until decocms/ai-gateway#30 lands.

Behavior changes

  • Gated tools refuse at the execute wrapper; chat, monitoring and CMS gate at their own chokepoints — the messages POST, requireOwnedSite, and resolveDecofileScope.
  • Stripe is the only way to acquire a paid tier: a price mapped in STRIPE_PLAN_PRICE_IDS grants 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, instead of silently opening every gate.
  • The Kanban gate and budget stop apply at the task-dispatch paths and the two sandbox routes that spend, not just the board tools.
  • BYO credentials and model pins are gated on model_choice; below Ultra the model's name and per-message costs are withheld, a gate that fails closed.
  • The usage bar is a percent, never a dollar; it advances optimistically by each turn's own cost.

Client surfaces

  • One tier ladder (plan-ladder.tsx) drives the plan card, catalog and paywall, so they cannot disagree; prices are hardcoded in planPriceBrl keyed by plan id until Stripe is wired.
  • A sidebar row whose view the plan withholds carries a lock, defined once in use-tab-locked so both nav lists agree.
  • The two credits dialogs share one top-up picker, and a Free org with a spent allowance now sees the plan dialog instead of nothing.
  • Entitlements are not persisted to localStorage, so a stale cached decision can't paint paywalls or model names.
  • Known gaps: only Kanban has a paywall backdrop, a downgrade orphans BYO keys with no UI to remove them, and Pro+ will be renamed Scale in a separate gateway migration.

Written for commit 119db17. Summary will update on new commits.

Review in cubic

Pedro França and others added 30 commits September 9, 2026 14:43
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.
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.
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>
rafavalls and others added 8 commits September 14, 2026 13:16
…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>
@pedrofrxncx

Copy link
Copy Markdown
Collaborator

Folded into #7116 — that branch now points at 119db179, this PR's exact head. Closing in favour of it.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants