Entitlement Model
The entitlement system answers one question for every gated action: what is this billing account allowed to do right now? The answer is always computed from the database catalog plus per-account layers, never hardcoded per plan.
Tables
All built in migration 20260608110521_gatekeeping_entitlements.sql unless noted.
| Table | Role |
|---|---|
entitlement_catalog | Global reference: key, kind (feature | quota | limit), scope (account | podcast | user), unit, plan_defaults JSONB (per-plan map; null value = unlimited) |
billing_accounts | plan_key, Stripe mirror fields, subscription_status. No client write policy (a browser owner-UPDATE could self-set plan_key='enterprise') |
account_entitlements | Per-account mirror + counter: granted (features), quota_limit (absolute override; NULL = no override, not unlimited), quota_used, period_start/period_end |
account_entitlement_overrides | Additive comps: granted boolean override and/or quota_delta, reason, expires_at (NULL = permanent) |
add_on_credit_grants | One-time pack pool: quantity_granted/quantity_consumed, status (active/exhausted/expired/revoked), validity window |
usage_events | Append-only consume ledger; unique partial index on (billing_account_id, idempotency_key) |
usage_credit_adjustments | Append-only pack draw/credit-back journal (pack_draw / pack_credit_back / admin) |
published_episode_meter_events | Append-only publish "realize" ledger (migration 20260625120000, see Slots & Walls) |
usage_events, usage_credit_adjustments, and admin_action_log carry prevent_mutation() triggers on UPDATE/DELETE plus statement-level TRUNCATE guards.
Catalog contents
The GATE-1 seed (20260608110521 §10) inserted 34 keys (18 features, 9 limits, 7 quotas). Later migrations added six more:
| Key | Kind | Migration |
|---|---|---|
podcast_import | feature | 20260622130000_free_import_gate.sql |
published_episodes_per_month | quota | 20260625120000_published_episode_meter.sql |
ai_promo_assets | feature | 20260703161340_ai_promo_assets_catalog_key.sql |
analytics_retention_days, analytics_advanced, analytics_export, analytics_engagement_imports, analytics_client_reports | limit + 4 features | 20260705204215_analytics_catalog_keys.sql |
player_branding_removal | feature | 20260706185127_embeddable_player.sql |
Plan keys inside plan_defaults are free / creator / studio / production_house / enterprise. There is no founders key: Founders resolves through the studio entry (see below).
Numeric resolution: the effective_cap() family
Defined once in SQL (20260608110521 §6), LANGUAGE sql SECURITY DEFINER, and called by every numeric gate (DB triggers, app adapters, and workers over Hyperdrive):
effective_cap(account, key) =
CASE WHEN base IS NULL THEN NULL -- unlimited (additive layers never make NULL finite)
ELSE base -- entitlement_base_cap():
-- COALESCE(account_entitlements.quota_limit,
-- catalog plan_defaults[plan_key, founders→studio])
+ entitlement_override_delta(...) -- Σ active account_entitlement_overrides.quota_delta
+ entitlement_pool_remaining(...) -- Σ in-window active add_on_credit_grants remaining
ENDentitlement_base_cap()handles bothplan_defaultsvalue shapes: a bare number (most keys) and an object{"limit": N, "mode": "soft"}(onlyimport_jobs_per_month).- The founders → studio remap for numeric keys happens inside
entitlement_base_cap()(CASE WHEN ba.plan_key = 'founders' THEN 'studio' ELSE ba.plan_key END). The TypeScriptremapPlanKey()insrc/lib/entitlements/core.tsis features-only. NULLfromeffective_cap()means either intentionally unlimited or no(account, key)row. The row-existence check is the caller's job: every consumer pairs the cap read with a row read and treats a missing row as DENY (C8).- Lockdown: EXECUTE is revoked from
PUBLIC,anon, andauthenticated; granted only toservice_role. Cloudflare Workers call it over Hyperdrive as thepostgresfunction owner (which retains EXECUTE). A user-scoped Supabase client getspermission denied, which the app helpers deliberately throw rather than falling open. effective_cap_for_plan(account, key, plan_key)(migration20260611094845§1) is the same union resolved against an explicit target plan; the change-plan pre-flight uses it becauseeffective_cap()can only see the current plan, while account-specific layers (grandfathered overrides, active deltas) survive a plan change.
consume_quota() (quota meters)
SECURITY DEFINER VOLATILE, service-role-only (same lockdown). Original in 20260608110521 §7; the current 9-parameter signature (adding p_mode_override) is from 20260612210000_gate5_consume_quota_mode_override.sql. OUT params: (allowed, used, cap, replayed).
Execution order:
- Reject non-positive quantity (
ERRCODE 22023), and reject anyp_mode_overrideother thanNULLor'soft'. - Serialize per
(account, key):pg_advisory_xact_lock(hashtext('quota:' || account || ':' || key)). The same key scheme is used by the GATE-3 cap triggers, so pre-flights and consumes serialize against each other. - Replay check: with an idempotency key, an existing
usage_eventsrow means a prior admit; returnreplayed = TRUE, allowed = TRUEand increment nothing. Rows are written only on admit, so row-existence equals prior admission. - Lock candidate add-on grants (
FOR UPDATE, serializing against refund revokes), then the counter row; compute the post-roll counter, the recurring monthly cap (base + Σ delta), and the pool. A missing counter row returnsallowed = FALSE(C8 deny). - Resolve the mode from the catalog: object-shaped
plan_defaultsvalues supplymode, anything else ishard.p_mode_override = 'soft'forces the never-blocks path for one call (this exists solely for the GATE-5 generative-AI post-pay decrement). - Hard denial gate: hard mode with a finite cap denies when
post_roll_used + quantity > monthly_cap + pool, writing nothing. - Charge base first, then pool: the spend fills the monthly allowance, overflow draws the pool FEFO (
ORDER BY valid_until ASC NULLS LAST), advancingquantity_consumedand flipping exhausted grants tostatus = 'exhausted'. In soft mode any residue past cap + pool is recorded onquota_used(an over-cap advisory, never a block). - Lazy calendar-month roll: a
NULLor expiredperiod_endresets the window to[date_trunc('month', NOW()), +1 month)and the counter restarts from the post-roll value. There is no cron; the window rolls on first use each month. - Record on admit: one
usage_eventsrow (ON CONFLICT ... DO NOTHINGagainst the partial unique idempotency index).
cap excludes the pool
The returned cap is the recurring monthly cap only (base + Σ delta). Admission is judged against cap + pool, but readers that need full headroom must use effective_cap() / effectiveRemaining(), never the consume_quota result's cap.
The TypeScript core
src/lib/entitlements/core.ts is framework-agnostic (no Hono, no SvelteKit imports) and ships:
EntitlementError: the one typed error,status403/409/429 with codesfeature_not_granted/limit_exceeded/quota_exceeded.- Three primitives:
requireFeature()(403 on deny),assertLimit()(409 whennextCountexceeds the effective cap; foradvance_booking_daysthe "count" is the VALUE being set),consumeQuota()(429 on a hard deny; soft returnsallowed: falsewithout throwing). SOFT_QUOTA_KEYS = new Set(['import_jobs_per_month']): the TS mirror of the catalog's only soft key, so callers never pass a mode by hand. A new soft key must be added in both places.effectiveRemaining(): read-only headroom,NULL= unlimited,0for a missing row (C8). Used by the worker's generative-AI pre-flight.- Per-request memo (
EffectiveEntitlementCache): promises memoized per${accountId}::${key}on the ambient request object. There is deliberately no cross-request cache; a webhook writes the DB and the next request re-reads. - The executor seam: the primitives never touch a DB client. Three adapters implement it:
adapters/hono.ts:requireFeature(key)middleware, inlineassertLimit/consumeQuota/checkFeature, andentitlementErrorHandler(theapp.onErrorhook rendering{ error, code, key, meta }).adapters/sveltekit.ts: the same primitives over a service-role admin client, plustoLoadError()/toActionFailure()surface translation.adapters/worker.ts: apostgres-over-Hyperdrive executor for workers (quota subset only; features never run in workers).
- Account resolution: consumption resolves the account via
podcasts.billing_account_id(resolveAccountIdForPodcast), never viabilling_account_members. Account-level surfaces with no podcast (/p/new, storage) useresolveAccountIdForUser, which is attribution only: with atargetAccountIdit verifies membership; without one it resolves only a user's sole account and returnsNULL(fail closed) for 0 or >1 memberships.
Feature grants: an additive union
featureGrantedFrom() in core.ts grants a feature iff:
activeOverrideGranted OR account_entitlements.granted OR catalogBaseline(plan_key after founders→studio)- A
granted: falseoverride is inert; the union is additive, never a revoke. - A missing
(account, key)row denies before the union runs (C8). - The seed writes feature rows with
granted = FALSE(see Provisioning), so plan features resolve live from the catalog baseline;granted = TRUEis reserved for non-plan grants (Stripe à-la-carte entitlements,source = 'stripe'). - Tri-state consumer: the public booking submission uses
resolveAiResearchGrant()(src/lib/entitlements/ai-research-feature.ts), which distinguishesgranted/denied(real entitlement decision: reject stale AI answers) /unavailable(the read itself failed: accept answers, waive required-ness, and let the fail-closed check at confirm time gate the actual research job).
Limits matrix (catalog defaults)
∞ = null in plan_defaults (unlimited). These are plan defaults; a real account's effective cap can differ via overrides, deltas, and packs.
| Key | Free | Creator | Studio | Production House | Enterprise | Scope / notes |
|---|---|---|---|---|---|---|
podcasts_per_account | 1 | ∞ | ∞ | ∞ | ∞ | Occupied-slot count (lifecycle-aware) |
staff_seats_per_account | 1 | 1 | 3 | 10 | ∞ | Distinct users + pending unexpired invites |
booking_links_per_account | 1 | 5 | 25 | ∞ | ∞ | Account-pooled |
automation_rules_per_account | 0 | 10 | 50 | ∞ | ∞ | Slot-occupying (see starter content) |
notification_templates_per_account | 0 | 10 | 50 | ∞ | ∞ | System templates never count |
calendar_integrations_per_user | 1 | 2 | 3 | 5 | ∞ | Per user, not per account |
advance_booking_days | 14 | 60 | 90 | 180 | 365 | Value cap, clamped at read time; Enterprise finite |
storage_gb_per_account | 1 | 25 | 100 | 500 | ∞ | GiB (1024³ bytes), audio bytes only |
client_workspaces_per_account | 0 | 0 | 0 | 25 | ∞ | |
analytics_retention_days | 90 | 365 | ∞ | ∞ | ∞ | Read-time clamp; data retained forever, unlocks retroactively |
A Free 0 means "not included on this plan", not "limited". Paid podcasts are genuinely unlimited; the Studio "2 to 5 shows" tagline is positioning copy, not a cap.
Monthly quotas matrix (catalog defaults)
Calendar-month windows, reset lazily on the 1st by consume_quota's window roll.
| Key | Free | Creator | Studio | Production House | Enterprise | Mode |
|---|---|---|---|---|---|---|
managed_episodes_per_month | 2 | 6 | 30 | 100 | ∞ | Hard on every tier (slot model, not consume_quota) |
published_episodes_per_month | 2 | ∞ | ∞ | ∞ | ∞ | Hard (only Free is finite) |
automation_runs_per_month | 25 | 1,000 | 5,000 | 20,000 | ∞ | Hard |
email_sends_per_month | 200 | 2,000 | 7,500 | 20,000 | ∞ | Hard |
notification_deliveries_per_month | 500 | 5,000 | 20,000 | 50,000 | ∞ | Hard; push channel only is metered here (email meters as email sends, in-app is unmetered; see src/lib/notifications/delivery.ts) |
webhook_sends_per_month | 0 | 1,000 | 5,000 | 20,000 | 100,000 | Hard; a finite security cap even on Enterprise |
ai_credits_per_month | 0 | 120 | 300 | 1,200 | ∞ | Hard (the single AI meter) |
import_jobs_per_month | 1 | 5 | 20 | 50 | ∞ | Soft: records overage, never blocks. Free also lacks the podcast_import feature, so "Free: 1 import/month" is wrong; Free cannot import at all |
Two distinct Free "2 per month" caps
managed_episodes_per_month caps how many episodes may exist in a recording month (all tiers, slot model). published_episodes_per_month caps how many may go live in a publish month (Free only). Never merge them in docs or copy.
AI credits are the only AI meter
1 credit = 50 Cloudflare Neurons ≈ 1 minute of transcription (src/lib/entitlements/ai-credits.ts: NEURONS_PER_CREDIT = 50, TRANSCRIPTION_CREDITS_PER_AUDIO_MINUTE = 1). "Transcription hours" on the pricing page are a presentation of this meter (credits / 60), never a separate key. The superseded ai_minutes_per_month / ai_generation_uses_per_month keys must not reappear. Call patterns:
- Transcription: exact pre-pay,
reserveTranscriptionCredits()consumesceil(audioMinutes)hard, keyed by the AI job id, before the Workers-AI call. Denied = skip the job, write a host-visible artifact, ack (never retry a monthly quota). - Guest research: flat 5 credits per job (
GUEST_RESEARCH_CREDITS = 5), same pre-pay shape. - Generative:
checkGenerativeHeadroom()(a read; block at ≤ 0) then run, thenrecordGenerativeSpend()soft-records actual usage withmodeOverride: 'soft'. Accepted overshoot is at most one call's tokens per concurrent call.
Feature matrix (catalog defaults)
| Tier introduces | Features |
|---|---|
| Everyone (including Free) | managed_hosting |
| All paid (Creator and up) | external_hosting, publish_handoff, ai_transcription, ai_show_notes_generation, ai_social_clips, ai_prep_questions, podcast_pause, podcast_import, ai_promo_assets, player_branding_removal, analytics_advanced |
| Studio and up | data_export_full, analytics_export (CSV), analytics_engagement_imports |
| Production House and up | client_workspaces, per_client_billing_rollups, agency_dashboard, white_label_branding, white_label_feature_toggle, priority_support_sla, analytics_client_reports |
| Enterprise only | white_label_custom_domain, sso_saml, audit_log_access |
Add-on packs
One-time purchases that top up the drawable pool (src/lib/billing/price-catalog.ts ADDON_PACKS; display copy in pricing-display.ts ADD_ON_PACKS). Drawn after the monthly base, FEFO by valid_until.
add_on_type | Tops up | Quantity | Validity | Price |
|---|---|---|---|---|
episode_pack_10 | managed_episodes_per_month | +10 | 6 months | $15 |
ai_credits_300 | ai_credits_per_month | +300 | 6 months | $10 |
email_pack_5000 | email_sends_per_month | +5,000 | 1 month | $5 |
Grants land in add_on_credit_grants via the Stripe webhook (idempotent on stripe_checkout_session_id); charge.refunded / dispute revokes the unused remainder (status = 'revoked'; the consumed portion stays consumed, and credit_back_managed_episode_grant skips terminal grants).
Meter exhaustion behavior
| Meter | Where consumed | On exhaustion |
|---|---|---|
email_sends_per_month | src/lib/email/index.ts (EmailSendMeter, metered before the Resend call) | Not sent; over-cap, unresolvable billing account, and metering errors all fail closed (terminal quota_exceeded) |
automation_runs_per_month | workers/automation-executor/src/index.ts (keyed by execution_id) | Execution recorded as skipped with a quota reason; never retried |
webhook_sends_per_month | Executor, keyed ${execution_id}:${action_id} | Terminal per-action skip in action_results; Free = 0 blocks webhooks outright |
notification_deliveries_per_month | src/lib/notifications/delivery.ts (push channel only) | Push delivery skipped; email channel meters as email sends; in-app unmetered |
ai_credits_per_month | ai-credits.ts helpers (see above) | Job skipped with a host-visible artifact |
import_jobs_per_month | src/api/routes/imports/index.ts | Soft: records the overage for an upgrade nudge, never blocks |
Storage is not a monthly meter: it is a running SUM(episodes.audio_file_size_bytes) against storage_gb_per_account, enforced by an upload pre-check plus a DB trigger, and deleting audio frees it immediately (see Slots & Walls).