Assign a tier to a tenant manually (without billing)
When you onboard a partner, run a beta cohort, or fix a botched payment,
you need to grant a tenant a tier without a billing purchase going through.
The tier-engine feature ships a SystemAdmin-only handler and a built-in
admin screen for exactly that.
What you get
Section titled “What you get”- A SystemAdmin sitting in their own tenant can assign a tier to any tenant — cross-tenant write, no role-impersonation needed.
- The grant is stamped
source: "manual"so the next time your Stripe → tier sync runs, it leaves the operator-set tier alone. - The resolver cache that gates
r.toggleable()features is updated synchronously in the same request, so a toggleable feature unlocked by the tier is reachable immediately — no cache flush, no restart. - An app simply mounts the built-in admin screen; no custom UI required.
Operator flow
Section titled “Operator flow”From the SystemAdmin’s perspective:
- Open the app’s sysadmin workspace.
- Pick Tier admin from the nav (or open it directly via the
qualified screen ref
tier-engine:screen:tier-admin). - Pick the target tenant and the tier name from the dropdown — the
options come from your app’s
TierMap, not from a hard-coded list. - Save. The grant is live before the next request.
Wiring it up
Section titled “Wiring it up”A real app configures the tier-engine with its own TierMap. The map
defines which tier names exist and what each tier unlocks. Mount the
admin screen via r.nav so it appears in the SystemAdmin workspace.
This is the whole composition — no extra handlers, no custom screen, no per-app set-tenant-tier endpoint. The bundled feature owns all of it:
import { createTierEngineFeature, type TierMap,} from "@cosmicdrift/kumiko-bundled-features/tier-engine";
// --- App caps ---//// Each app picks its own cap dimensions. Here a tiny example: how many// notes a tenant may keep. The TierMap is generic in this cap-shape so// downstream code stays type-safe end-to-end.
export type AppCaps = { readonly maxNotes: number };
// --- Tier map ---//// "free" + "pro" — the operator picks one of these names when granting// a tier manually. `features` is empty in this sample because the recipe// focuses on the grant flow; a real app would list the toggleable// feature ids that the tier unlocks.
export const appTierMap: TierMap<AppCaps> = { free: { features: [], caps: { maxNotes: 5 } }, pro: { features: [], caps: { maxNotes: 100 } },};
// --- Configured tier-engine ---//// `defaultTier: "free"` means every new tenant starts on free via the// `inTransaction` entity hook the tier-engine registers — no app code// needed. `tierMap` makes `tier-options` return ["free", "pro"] so the// tier-admin screen can populate its picker without hard-coding.
export const tierEngineForApp = createTierEngineFeature<AppCaps>({ defaultTier: "free", tierMap: appTierMap,});Surface the admin screen from your app shell:
// In an app feature that owns the sysadmin navigation:r.nav({ workspace: "sysadmin", screen: "tier-engine:screen:tier-admin", title: "Tier admin",});The screen ref must be qualified (tier-engine:screen:tier-admin)
because the screen is owned by the bundled feature, not by the app
feature that mounts it.
The cache-sync invariant
Section titled “The cache-sync invariant”The detail that makes this correct: set-tenant-tier writes through
the event-store executor directly, which bypasses the postSave entity
hook the resolver normally uses to invalidate its cache. Without an
explicit sync the grant would persist (next request would see it) but
this request still sees the old tier — surprising for an operator who
just clicked Set tier to pro.
The feature wires onAssigned into the handler for exactly this reason.
The recipe’s integration test exercises the round trip — grant, then
read-back via get-tenant-tier, then list options via tier-options —
end-to-end against the real dispatcher.
See also
Section titled “See also”- Recipe: tier-admin sample — runnable example with integration tests covering grant + idempotent re-grant + role-fail-closed.
- Reference: tier-engine — full handler/query inventory plus the resolver-extension setup.