Skip to content

Starter Content Seeding

Every new podcast is seeded with platform starter content: system notification/show-notes templates plus three pre-built automation rules, seeded dormant. The seeder is src/lib/onboarding/seed-starter-content.ts; the rule catalog is src/lib/automation/starter-catalog.ts.

Entry points

CallerBehavior
Podcast creation (src/routes/(app)/p/new/+page.server.ts)Fail-open side effect: a seed failure logs and never fails creation
POST /api/automations/starters/seed (src/api/routes/automations/starters.ts, admin on the podcast)Recovery/backfill for podcasts created before the hook existed or whose creation-time seed failed. Idempotent, so re-invoking is always safe

Both require the service-role client (requireAuth() provides one on the API path): seeded rows are platform content (is_system), not a user write. The cap triggers still fire, because service-role does not bypass triggers; seeding passes only because of the slot semantics below.

The pipeline

  1. System templates. Two DB functions, both ON CONFLICT DO NOTHING: seed_default_notification_templates() (20260107164436_automation_engine.sql) inserts three is_system = TRUE email templates, and seed_default_show_notes_templates() inserts the show-notes structure. Both functions were dormant until this feature; 20260711130200_fix_template_seed_functions.sql fixed two latent bugs caught by the pgTAP acceptance suite: the show-notes seed violated the sectioned-model CHECK (it now carries target_section = 'shared'), and the ON CONFLICT DO NOTHING clauses had NO unique constraint to arbitrate, so re-seeding silently duplicated every system template. The fix is a PARTIAL unique index (podcast_id, name) WHERE is_system, scoped so user template naming stays unconstrained.
  2. Template resolution by name. The seeder selects the podcast's is_system notification templates and maps name to id. This is the catalog-to-seed-function contract: each catalog entry's templateName MUST exactly match a template name inserted by seed_default_notification_templates(), and the catalog test suite pins it. A catalog entry whose template cannot be resolved is skipped and reported in skippedMissingTemplate, never a throw.
  3. Rule rows. For each resolved entry, buildStarterWorkflowData() produces the workflow_data graph: one trigger node connected to one send_email action node, the same shape the flow builder saves for a minimal rule (top-level template_id / to_override keys; position values are synthesized and never read). Rows are inserted with is_enabled: false, is_system: true.
  4. Idempotent upsert. automation_rules has UNIQUE(podcast_id, name) (20260107164436), and the upsert uses { onConflict: 'podcast_id,name', ignoreDuplicates: true }. The select returns ONLY newly inserted rows, so existing rules, including user-edited ones, are never rewritten, and action rows are only written for rules created by THIS call.
  5. Action derivation. For each newly created rule, buildActionInserts() (the SAME path the rules API uses on save) denormalizes workflow_data into automation_actions rows. The executor only reads automation_actions, so this step is what makes a starter actually run once activated.

The result reports templateCount, rulesCreated, rulesExisting, and skippedMissingTemplate.

The catalog

Three starters (STARTER_RULES):

Rule nameTriggerSystem templateRecipient
Send booking confirmation emailbooking.confirmedGuest Booking Confirmedguest
Recording reminder, 24 hours beforetime.before_recording (24h before recording_date)Recording Reminder - 24 Hoursguest
Thank your guest when the episode publishesepisode.publishedEpisode Published - Guest Notificationall_episode_guests

Rule name is the stable seed identity (via the unique constraint), so renaming a catalog entry creates a NEW starter on reseed rather than updating the old one.

Slot-occupying cap semantics

Free-tier caps for both notification_templates_per_account and automation_rules_per_account are 0, and the GATE-3 backstop triggers originally counted EVERY row, so seeding would have tripped ERRCODE 23514 on the first insert and bricked free-tier podcast creation. Migration 20260711130100_starter_content_cap_semantics.sql switches both caps to slot-occupying semantics (decision 2026-07-11):

TableOccupies a slot when
notification_templatesis_system = FALSE (system templates NEVER count)
automation_rules(NOT is_system) OR is_enabled

Consequences:

  • Seeding is free on every tier. Disabled system rules and system templates pass the triggers without a cap check (the unprovisioned-account deny still applies to every write).
  • Activation consumes a slot. The automation_rules trigger now also fires on UPDATE OF is_enabled, is_system, so enabling a starter is DB-backstopped: blocked on Free (cap 0, where the checklist and the automations page render upsells instead) and counted toward paid caps. Disabling a starter frees its slot. An UPDATE where the OLD row already occupied a slot in the same account passes without a check (net occupancy unchanged).
  • The migration also adds the automation_rules.is_system column itself: a durable marker that survives user edits and drives cap occupancy, "Starter" labeling in the UI, and reseed idempotency. It is never settable through the API schemas.

The trigger bodies preserve the GATE-3 A1 shape exactly: advisory xact lock on the shared quota key, C8 missing-entitlement-row deny, effective_cap(), account-pooled count, ERRCODE 23514. See Entitlement Model for the cap resolution stack.

Why disabled starters do not complete the checklist

The Get Started checklist's activate-automation step counts ENABLED rules only, so the seeded catalog never self-completes onboarding; the user has to activate one (or build their own). See Get Started Checklist.

Internal documentation - Not for public distribution