Slug Aliases & the Redirect Net
Epic 17's one principle: machine URLs get IDs, human URLs get slugs. The feed URL — the one address in podcasting that must never move — is keyed by the podcast UUID (rss.cdn.media/{podcast_id}). Everything humans read ({slug}.show.fm, /p/{slug}/e/{episode-slug}, booking pages) stays a slug, and BECAUSE the feed no longer depends on it, slugs are safely renameable behind this layer.
Authoritative schema: supabase/migrations/20260826103810_epic17_slug_alias_layer.sql. Plan + rulings: docs/planning/epics/epic-17/.
The data layer
podcast_slug_aliases(global namespace, shared withpodcasts.slug): every previously-live show slug, permanent.podcast_idset = redirect alias;podcast_idNULL = a deleted show's tombstone, reclaimable only byreserved_for_account(its old billing account — delete-and-recreate keeps working; everyone else is blocked forever, because a dead show's residual subscribers still poll its URLs).episode_slug_aliases(scoped per podcast): minted only when an ever-published episode is renamed (a draft's address simply stops existing). Cascades with its owner — the namespace is per podcast, so reuse can only reach the same tenant.slug_rename_events: the 3-per-rolling-30-days rate limit counts rename events, not alias rows (renaming back reuses a row).- Renames happen ONLY through
rename_podcast_slug()/rename_episode_slug()— SECURITY DEFINER, executable byservice_roleonly, taking the validated user asp_actor. The server layer (settings action / episode actions) owns the session, the mutable-admin gate and the TS-only slug guard; the RPC owns atomicity: namespace advisory locks, cross-table uniqueness, alias minting, the rate limit, and the storednotifications.linkrewrite. - Write-path triggers on
podcasts.slug/episodes.slugreject UUID-shaped slugs (the shape is reserved for ID routing), enforce uniqueness against the alias tables (ERRCODE 23505), and block direct Data API slug changes — the old "frozen in the UI" freeze was bypassable via raw PostgREST.
The redirect net (resolution law: live slug first, alias second)
| Surface | On an alias hit |
|---|---|
rss.cdn.media/{slug} | permanent 301 to the ID URL (workers/rss-feed — resolveFeedPodcastId), cacheable, monitored forever |
Dashboard /p/{slug}, /p/{slug}/e/{episodeSlug} | 303 to the canonical path (src/lib/server/require-podcast-access.ts, workspace-episode.ts) — stale bookmarks, teammate tabs and stored notification links self-heal |
Listen {slug}.show.fm (+ path-style) and episode pages | 301 (src/lib/server/listen-redirects.ts) |
Booking book.show.fm/{podcastSlug}/{linkSlug} | 301 — already-sent invitations survive |
Public API /v1/podcasts/:slug..., embed /latest/:slug | 301 (workers/public-api); oEmbed retries inline with the canonical slugs |
Cache keys moved with the feed: KV feed:{id}, R2 snapshots keyed by ID (dr-replicator and the nightly reconcile follow), feed_requests blob1 = the ID (rollup accepts legacy slug rows during the WAE transition window, resolving them through the alias table so a renamed show keeps its pre-rename traffic).
Rename UX
Save handlers goto() the canonical URL in the same breath (replaceState), so the active tab never lands on a dead route; every other holder of the old URL goes through the net above. The (app) layout re-derives the pp_last_podcast cookie from the route on that navigation — it stays the cookie's only writer.
Episode addresses (GATE-3)
Episodes never had an address surface; the slug was minted from the first title (or a booking's recording-… stamp) and shipped unseen. Now: the Details tab carries the address card, whose change flow opens the shared SidePanel (right-hand slide-over on desktop, bottom sheet on mobile, actions pinned in the sticky footer), the Publishing card shows the address before anything goes live with a one-click use-my-title rename, and recording-… addresses auto-apply the title-derived slug at publish or schedule (at publish, after the status flip, so the outgoing slug is aliased). The apply moment is deliberate: renaming on every title keystroke would thrash the workspace URL and the shell load, so derivation is applied at the go-live decision, never per keystroke.