Skip to content

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:

typescript
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, ...
}
GateCodeSourceTransport status / SQLSTATE
feature_not_grantedEntitlementErrorHTTP 403
limit_exceededEntitlementErrorHTTP 409
quota_exceededEntitlementErrorHTTP 429
recording_windowPG raisePT422 (hint: recording_window_floor | recording_window_advance)
audio_not_finalizedPG raisePT428
month_fullPG raisePT429
publish_limitPG raisePT430

Two transport paths, one envelope:

  • Fetch path (Hono): the primitives throw EntitlementError; entitlementErrorHandler (src/lib/entitlements/adapters/hono.ts, installed as app.onError in src/api/index.ts) renders { error, code, key, meta } with the error's status. The client parses it back with gateFromApiError().
  • Form path (SvelteKit actions): toActionFailure() (src/lib/entitlements/adapters/sveltekit.ts) returns fail(status, { error, gate }). Pages read it via extractGate(form), or gateFromForm(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? }:

ModeCodesRendering
upsellfeature_not_granted, limit_exceeded, quota_exceeded, month_full, publish_limit, and recording_window with the advance hintGlobal upgrade dialog with an Upgrade CTA
inlineaudio_not_finalized, and recording_window with the floor hint (or no hint)Inline banner, no upgrade CTA (a precondition upgrading cannot fix)
noticeAnything unrecognizedToast

Two special cases:

  • recording_window (PT422) branches on meta.hint: recording_window_advance is a genuine upsell (higher tiers schedule further ahead) and renders the dialog; recording_window_floor cannot be fixed by upgrading and stays inline.
  • limit_exceeded on advance_booking_days gets 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 because meta.cap carries the plan's day limit. Real (non-sentinel) gate.error copy wins over the generated body, same contract as resolveBody — 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:

CodeTitle
feature_not_grantedThis is a paid feature
limit_exceededPlan limit reached
quota_exceededYou've reached your monthly limit
month_fullThis month is fully booked
publish_limitPublishing limit reached
recording_windowOutside your booking window
audio_not_finalizedAudio 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:

KeyMeter label
podcasts_per_accountPodcasts
automation_rules_per_accountAutomation rules
booking_links_per_accountBooking links
notification_templates_per_accountNotification templates
calendar_integrations_per_userCalendar connections
staff_seats_per_accountTeam seats
client_workspaces_per_accountClient 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) in src/lib/messaging/client.ts parses the body, maps it, and either calls showUpgradePrompt(gate) or falls back to notify.error(). It returns true when it handled a gate so callers can skip their generic error path.
  • The one dialog: showUpgradePrompt pushes onto the upgradePrompt singleton (src/lib/messaging/store.svelte.ts, runes in a class); the single <UpgradePromptHost> mounted in src/routes/(app)/+layout.svelte renders src/lib/components/messaging/UpgradePrompt.svelte.
  • Form path: pages render <UpgradePrompt gate={extractGate(form)} ... /> inline from the action result.
  • Toasts: src/lib/messaging/notify.ts wraps svelte-sonner for 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:

  1. Server side: src/lib/server/entitlement-page-data.ts
    • loadEffectiveCap(accountId, key): number = capped, null = unlimited, undefined = unresolved. C8: a missing (account, key) row returns 0 (deny), never unlimited. Resolution always goes through the effective_cap RPC so account overrides are honored; never inferred from planKey.
    • 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).
  2. Client side: src/lib/messaging/gates.ts
    • isAtLimit(used, cap): false for null (unlimited) and undefined (unresolved; fail open, blocking on unknown data would strand paying users).
    • buildLimitGate(key, { cap, used, planKey, planName, noun, action, message }) builds a limit_exceeded gate with generated copy (message overrides it; empty error lets the mapper's KEY_COPY supply the body), which the click handler feeds to showUpgradePrompt.
    • advanceWindowClickGate(days, cap, { planKey, planName }) (#296 item 1): non-null when picking days would exceed the advance_booking_days value cap; carries blocked-selection copy (no "reduce the value" instruction).
    • calendarConnectGate(used, cap, { planKey, planName }) (#296 item 3): non-null at the calendar_integrations_per_user cap 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.ts resolves LayoutEntitlements (accountId, planKey, podcastsPerAccount where null = unlimited and a missing row = 0) and a LayoutSubscription card model, on the service-role client, failing closed to a free-shaped shell.
  • /pricing: hydrated live from entitlement_catalog via buildPricingViewModel() (src/routes/pricing/+page.server.ts), so advertised numbers equal enforced caps.
  • /settings: the subscription card plus the storage usage card, fed by GET /api/account/usage ({ usedBytes, capBytes }, capBytes: null = unlimited).

Internal documentation - Not for public distribution