Skip to content

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.

TableRole
entitlement_catalogGlobal reference: key, kind (feature | quota | limit), scope (account | podcast | user), unit, plan_defaults JSONB (per-plan map; null value = unlimited)
billing_accountsplan_key, Stripe mirror fields, subscription_status. No client write policy (a browser owner-UPDATE could self-set plan_key='enterprise')
account_entitlementsPer-account mirror + counter: granted (features), quota_limit (absolute override; NULL = no override, not unlimited), quota_used, period_start/period_end
account_entitlement_overridesAdditive comps: granted boolean override and/or quota_delta, reason, expires_at (NULL = permanent)
add_on_credit_grantsOne-time pack pool: quantity_granted/quantity_consumed, status (active/exhausted/expired/revoked), validity window
usage_eventsAppend-only consume ledger; unique partial index on (billing_account_id, idempotency_key)
usage_credit_adjustmentsAppend-only pack draw/credit-back journal (pack_draw / pack_credit_back / admin)
published_episode_meter_eventsAppend-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:

KeyKindMigration
podcast_importfeature20260622130000_free_import_gate.sql
published_episodes_per_monthquota20260625120000_published_episode_meter.sql
ai_promo_assetsfeature20260703161340_ai_promo_assets_catalog_key.sql
analytics_retention_days, analytics_advanced, analytics_export, analytics_engagement_imports, analytics_client_reportslimit + 4 features20260705204215_analytics_catalog_keys.sql
player_branding_removalfeature20260706185127_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
  END
  • entitlement_base_cap() handles both plan_defaults value shapes: a bare number (most keys) and an object {"limit": N, "mode": "soft"} (only import_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 TypeScript remapPlanKey() in src/lib/entitlements/core.ts is features-only.
  • NULL from effective_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, and authenticated; granted only to service_role. Cloudflare Workers call it over Hyperdrive as the postgres function owner (which retains EXECUTE). A user-scoped Supabase client gets permission denied, which the app helpers deliberately throw rather than falling open.
  • effective_cap_for_plan(account, key, plan_key) (migration 20260611094845 §1) is the same union resolved against an explicit target plan; the change-plan pre-flight uses it because effective_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:

  1. Reject non-positive quantity (ERRCODE 22023), and reject any p_mode_override other than NULL or 'soft'.
  2. 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.
  3. Replay check: with an idempotency key, an existing usage_events row means a prior admit; return replayed = TRUE, allowed = TRUE and increment nothing. Rows are written only on admit, so row-existence equals prior admission.
  4. 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 returns allowed = FALSE (C8 deny).
  5. Resolve the mode from the catalog: object-shaped plan_defaults values supply mode, anything else is hard. p_mode_override = 'soft' forces the never-blocks path for one call (this exists solely for the GATE-5 generative-AI post-pay decrement).
  6. Hard denial gate: hard mode with a finite cap denies when post_roll_used + quantity > monthly_cap + pool, writing nothing.
  7. Charge base first, then pool: the spend fills the monthly allowance, overflow draws the pool FEFO (ORDER BY valid_until ASC NULLS LAST), advancing quantity_consumed and flipping exhausted grants to status = 'exhausted'. In soft mode any residue past cap + pool is recorded on quota_used (an over-cap advisory, never a block).
  8. Lazy calendar-month roll: a NULL or expired period_end resets 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.
  9. Record on admit: one usage_events row (ON CONFLICT ... DO NOTHING against 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, status 403/409/429 with codes feature_not_granted / limit_exceeded / quota_exceeded.
  • Three primitives: requireFeature() (403 on deny), assertLimit() (409 when nextCount exceeds the effective cap; for advance_booking_days the "count" is the VALUE being set), consumeQuota() (429 on a hard deny; soft returns allowed: false without 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, 0 for 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, inline assertLimit/consumeQuota/checkFeature, and entitlementErrorHandler (the app.onError hook rendering { error, code, key, meta }).
    • adapters/sveltekit.ts: the same primitives over a service-role admin client, plus toLoadError() / toActionFailure() surface translation.
    • adapters/worker.ts: a postgres-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 via billing_account_members. Account-level surfaces with no podcast (/p/new, storage) use resolveAccountIdForUser, which is attribution only: with a targetAccountId it verifies membership; without one it resolves only a user's sole account and returns NULL (fail closed) for 0 or >1 memberships.

Feature grants: an additive union

featureGrantedFrom() in core.ts grants a feature iff:

text
activeOverrideGranted  OR  account_entitlements.granted  OR  catalogBaseline(plan_key after founders→studio)
  • A granted: false override 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 = TRUE is 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 distinguishes granted / 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.

KeyFreeCreatorStudioProduction HouseEnterpriseScope / notes
podcasts_per_account1Occupied-slot count (lifecycle-aware)
staff_seats_per_account11310Distinct users + pending unexpired invites
booking_links_per_account1525Account-pooled
automation_rules_per_account01050Slot-occupying (see starter content)
notification_templates_per_account01050System templates never count
calendar_integrations_per_user1235Per user, not per account
advance_booking_days146090180365Value cap, clamped at read time; Enterprise finite
storage_gb_per_account125100500GiB (1024³ bytes), audio bytes only
client_workspaces_per_account00025
analytics_retention_days90365Read-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.

KeyFreeCreatorStudioProduction HouseEnterpriseMode
managed_episodes_per_month2630100Hard on every tier (slot model, not consume_quota)
published_episodes_per_month2Hard (only Free is finite)
automation_runs_per_month251,0005,00020,000Hard
email_sends_per_month2002,0007,50020,000Hard
notification_deliveries_per_month5005,00020,00050,000Hard; push channel only is metered here (email meters as email sends, in-app is unmetered; see src/lib/notifications/delivery.ts)
webhook_sends_per_month01,0005,00020,000100,000Hard; a finite security cap even on Enterprise
ai_credits_per_month01203001,200Hard (the single AI meter)
import_jobs_per_month152050Soft: 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() consumes ceil(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, then recordGenerativeSpend() soft-records actual usage with modeOverride: 'soft'. Accepted overshoot is at most one call's tokens per concurrent call.

Feature matrix (catalog defaults)

Tier introducesFeatures
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 updata_export_full, analytics_export (CSV), analytics_engagement_imports
Production House and upclient_workspaces, per_client_billing_rollups, agency_dashboard, white_label_branding, white_label_feature_toggle, priority_support_sla, analytics_client_reports
Enterprise onlywhite_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_typeTops upQuantityValidityPrice
episode_pack_10managed_episodes_per_month+106 months$15
ai_credits_300ai_credits_per_month+3006 months$10
email_pack_5000email_sends_per_month+5,0001 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

MeterWhere consumedOn exhaustion
email_sends_per_monthsrc/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_monthworkers/automation-executor/src/index.ts (keyed by execution_id)Execution recorded as skipped with a quota reason; never retried
webhook_sends_per_monthExecutor, keyed ${execution_id}:${action_id}Terminal per-action skip in action_results; Free = 0 blocks webhooks outright
notification_deliveries_per_monthsrc/lib/notifications/delivery.ts (push channel only)Push delivery skipped; email channel meters as email sends; in-app unmetered
ai_credits_per_monthai-credits.ts helpers (see above)Job skipped with a host-visible artifact
import_jobs_per_monthsrc/api/routes/imports/index.tsSoft: 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).

Internal documentation - Not for public distribution