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
| Caller | Behavior |
|---|---|
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
- System templates. Two DB functions, both
ON CONFLICT DO NOTHING:seed_default_notification_templates()(20260107164436_automation_engine.sql) inserts threeis_system = TRUEemail templates, andseed_default_show_notes_templates()inserts the show-notes structure. Both functions were dormant until this feature;20260711130200_fix_template_seed_functions.sqlfixed two latent bugs caught by the pgTAP acceptance suite: the show-notes seed violated the sectioned-model CHECK (it now carriestarget_section = 'shared'), and theON CONFLICT DO NOTHINGclauses 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. - Template resolution by name. The seeder selects the podcast's
is_systemnotification templates and mapsnametoid. This is the catalog-to-seed-function contract: each catalog entry'stemplateNameMUST exactly match a template name inserted byseed_default_notification_templates(), and the catalog test suite pins it. A catalog entry whose template cannot be resolved is skipped and reported inskippedMissingTemplate, never a throw. - Rule rows. For each resolved entry,
buildStarterWorkflowData()produces theworkflow_datagraph: one trigger node connected to onesend_emailaction node, the same shape the flow builder saves for a minimal rule (top-leveltemplate_id/to_overridekeys;positionvalues are synthesized and never read). Rows are inserted withis_enabled: false, is_system: true. - Idempotent upsert.
automation_ruleshasUNIQUE(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. - Action derivation. For each newly created rule,
buildActionInserts()(the SAME path the rules API uses on save) denormalizesworkflow_dataintoautomation_actionsrows. The executor only readsautomation_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 name | Trigger | System template | Recipient |
|---|---|---|---|
| Send booking confirmation email | booking.confirmed | Guest Booking Confirmed | guest |
| Recording reminder, 24 hours before | time.before_recording (24h before recording_date) | Recording Reminder - 24 Hours | guest |
| Thank your guest when the episode publishes | episode.published | Episode Published - Guest Notification | all_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):
| Table | Occupies a slot when |
|---|---|
notification_templates | is_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_rulestrigger now also fires onUPDATE 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_systemcolumn 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.