Gate Errors & UI Surfaces
Every entitlement block, regardless of channel, reaches the client as one canonical envelope: the GateError. A pure mapper then decides how it renders: a global upgrade dialog for sellable gates, an inline banner for preconditions, a toast otherwise.
The GateError envelope
Defined in src/lib/messaging/gate-error.ts:
interface GateError {
error: string; // human copy (server *Message() factories, or the primitive sentinel)
code: GateCode;
key?: string; // the entitlement key, when applicable
meta?: GateMeta; // cap, used, nextCount, planKey, recommendedPlan, month, hint, ...
}GateCode | Source | Transport status / SQLSTATE |
|---|---|---|
feature_not_granted | EntitlementError | HTTP 403 |
limit_exceeded | EntitlementError | HTTP 409 |
quota_exceeded | EntitlementError | HTTP 429 |
recording_window | PG raise | PT422 (hint: recording_window_floor | recording_window_advance) |
audio_not_finalized | PG raise | PT428 |
month_full | PG raise | PT429 |
publish_limit | PG raise | PT430 |
Two transport paths, one envelope:
- Fetch path (Hono): the primitives throw
EntitlementError;entitlementErrorHandler(src/lib/entitlements/adapters/hono.ts, installed asapp.onErrorinsrc/api/index.ts) renders{ error, code, key, meta }with the error's status. The client parses it back withgateFromApiError(). - Form path (SvelteKit actions):
toActionFailure()(src/lib/entitlements/adapters/sveltekit.ts) returnsfail(status, { error, gate }). Pages read it viaextractGate(form), orgateFromForm(form), which also synthesizes a gate from the legacy typed flags some page-servers set (publishLimit,monthFull,recordingWindow+recordingWindowHint).
PT-code raises are translated to gates by the server helpers (monthFullGate(), publishLimitGate(), externalHostingGate(), and the recording-window copy factory) at the page-server / route that caught the Postgres error.
The mapper: choosing a surface
src/lib/messaging/mapper.ts gateErrorToSurface(gate, currentPlan) is pure (no DOM, no framework) and returns { mode, title, body, meter?, primaryCta? }:
| Mode | Codes | Rendering |
|---|---|---|
upsell | feature_not_granted, limit_exceeded, quota_exceeded, month_full, publish_limit, and recording_window with the advance hint | Global upgrade dialog with an Upgrade CTA |
inline | audio_not_finalized, and recording_window with the floor hint (or no hint) | Inline banner, no upgrade CTA (a precondition upgrading cannot fix) |
notice | Anything unrecognized | Toast |
Two special cases:
recording_window(PT422) branches onmeta.hint:recording_window_advanceis a genuine upsell (higher tiers schedule further ahead) and renders the dialog;recording_window_floorcannot be fixed by upgrading and stays inline.limit_exceededonadvance_booking_daysgets a dedicated surface (advanceWindowSurface): it is a value cap, not a count, so the generated copy leads with reducing the value ("Set the maximum advance to {cap} days or fewer, or upgrade for a longer window") under the title Booking window too long, correct on every tier becausemeta.capcarries the plan's day limit. Real (non-sentinel)gate.errorcopy wins over the generated body, same contract asresolveBody— the click-time gate (advanceWindowClickGate) sends copy for a selection that was blocked, where "reduce the value" would misread.
Dialog titles
From the TITLES map:
| Code | Title |
|---|---|
feature_not_granted | This is a paid feature |
limit_exceeded | Plan limit reached |
quota_exceeded | You've reached your monthly limit |
month_full | This month is fully booked |
publish_limit | Publishing limit reached |
recording_window | Outside your booking window |
audio_not_finalized | Audio not ready |
(advance_booking_days overrides the limit_exceeded title with "Booking window too long".)
Body copy resolution
The generic primitives throw errors whose .message is the internal sentinel "<code>: <key>", not user copy, and the adapters forward it verbatim. resolveBody() shows the server copy when it is real and substitutes friendly copy when it is only the sentinel: first per-key copy (KEY_COPY: webhook_sends_per_month, notification_templates_per_account, booking_links_per_account, automation_rules_per_account, analytics_advanced, analytics_export, analytics_engagement_imports, analytics_client_reports, analytics_retention_days), then per-code fallback (CODE_COPY). Server-composed gates (monthFullMessage, externalHostingMessage, page upsell copy) always carry real copy and win.
Usage meter
gateMeter(gate) derives a { used, limit, label } meter for count-based limit keys only, from the RESOURCE_LABELS map:
| Key | Meter label |
|---|---|
podcasts_per_account | Podcasts |
automation_rules_per_account | Automation rules |
booking_links_per_account | Booking links |
notification_templates_per_account | Notification templates |
calendar_integrations_per_user | Calendar connections |
staff_seats_per_account | Team seats |
client_workspaces_per_account | Client workspaces |
No meter renders when the key has no label (value caps, features, quotas), when meta.cap is missing or ≤ 0, or when usage cannot be derived (meta.used, else meta.nextCount - 1).
Upgrade CTA destination
upgradeHref(gate, currentPlan): Free (or unknown) plans go to /pricing (/pricing?plan=... when meta.recommendedPlan is present); paid plans go to /settings, where the plan-change dialog lives.
Client wiring
- Imperative fetch path:
handleGateError(responseOrBody)insrc/lib/messaging/client.tsparses the body, maps it, and either callsshowUpgradePrompt(gate)or falls back tonotify.error(). It returnstruewhen it handled a gate so callers can skip their generic error path. - The one dialog:
showUpgradePromptpushes onto theupgradePromptsingleton (src/lib/messaging/store.svelte.ts, runes in a class); the single<UpgradePromptHost>mounted insrc/routes/(app)/+layout.svelterenderssrc/lib/components/messaging/UpgradePrompt.svelte. - Form path: pages render
<UpgradePrompt gate={extractGate(form)} ... />inline from the action result. - Toasts:
src/lib/messaging/notify.tswrapssvelte-sonnerfor confirmations only; it deliberately has no upsell path (a toast cannot sell a plan).
Click-time gating (pre-flight upsells)
The server 409/429 remains the enforcement backstop, but "New ..." buttons open the same dialog before the user invests in a form, from numbers the page load already resolved:
- Server side:
src/lib/server/entitlement-page-data.tsloadEffectiveCap(accountId, key): number = capped,null= unlimited,undefined= unresolved. C8: a missing(account, key)row returns0(deny), never unlimited. Resolution always goes through theeffective_capRPC so account overrides are honored; never inferred fromplanKey.- Usage counts on the service-role client (account pools span podcasts the current user's RLS would hide), mirroring the API counterparts exactly:
countAccountTemplates()(non-system rows),countOccupiedAutomationSlots()(user rules + enabled system rules),countAccountBookingLinks()(all rows). - Every function fails open to
undefined(pre-check disabled; the API 409 + DB trigger still enforce).
- Client side:
src/lib/messaging/gates.tsisAtLimit(used, cap):falsefornull(unlimited) andundefined(unresolved; fail open, blocking on unknown data would strand paying users).buildLimitGate(key, { cap, used, planKey, planName, noun, action, message })builds alimit_exceededgate with generated copy (messageoverrides it; emptyerrorlets the mapper'sKEY_COPYsupply the body), which the click handler feeds toshowUpgradePrompt.advanceWindowClickGate(days, cap, { planKey, planName })(#296 item 1): non-null when pickingdayswould exceed theadvance_booking_daysvalue cap; carries blocked-selection copy (no "reduce the value" instruction).calendarConnectGate(used, cap, { planKey, planName })(#296 item 3): non-null at thecalendar_integrations_per_usercap so the Connect button prompts before a doomed Google OAuth round-trip; reconnect flows bypass it (replacing a connection consumes no slot).
Adopters: src/routes/(app)/p/[slug]/templates/+page.svelte, .../automations/+page.svelte, .../booking-links/+page.svelte (and /new), .../settings/team/+page.svelte, .../settings/calendars/+page.svelte (Connect button), the booking-link editors' shared AdvanceBookingSelect (src/lib/components/booking-links/, which also locks over-cap options in the dropdown), and the Get Started checklist (src/lib/components/onboarding/GetStartedChecklist.svelte), each fed by its +page.server.ts calling loadEffectiveCap + the matching count.
Always-on billing surfaces
(app)layout:src/routes/(app)/+layout.server.tsresolvesLayoutEntitlements(accountId,planKey,podcastsPerAccountwherenull= unlimited and a missing row = 0) and aLayoutSubscriptioncard model, on the service-role client, failing closed to a free-shaped shell./pricing: hydrated live fromentitlement_catalogviabuildPricingViewModel()(src/routes/pricing/+page.server.ts), so advertised numbers equal enforced caps./settings: the subscription card plus the storage usage card, fed byGET /api/account/usage({ usedBytes, capBytes },capBytes: null= unlimited).
Related
- Entitlement Model for the primitives that throw
- Slot Models & Enforcement Walls for the PT-code raises