Skip to content
GenerateSpecs

Async Product Demo & Screen Recording Platform

18,732 lines · 188,447 words

CC BY 4.0 Open online
saasPublic

Async Product Demo & Screen Recording Platform

Browser and desktop screen recorder with AI auto-editing that turns raw captures into polished, trackable product demos.

18,732 lines · 188,447 words · 30 sections · Aug 19, 2026

Reelay — Async Product Demo & Screen Recording Platform #

Product Requirements Document · Final

Reelay is a screen recording and demo platform for teams. It records screen, camera and microphone from the browser or a lightweight desktop app, automatically edits the raw capture into a polished product demo — zoom-to-cursor, damped camera motion, framed backgrounds — and turns the result into a shareable, trackable, access-controlled asset with an embeddable player, viewer analytics, comments, calls-to-action and a branded library.

The product's centre of gravity is the async product demo. Auto-editing quality is the differentiator, and it is specified here to a level an engineer can implement: real thresholds, real filter coefficients, real formulas. Everything else — support replies, sales follow-ups, standups — falls out of the same capture-and-share pipeline.

This document is written to be executed cold by an AI coding agent or an engineering team, with no clarifying questions required. Every concern has exactly one canonical section; every other section references it by number rather than restating it. Where a decision was open, it has been made and recorded as settled fact — there are no deferred choices anywhere in this document.


Table of Contents #

  1. Before You Start — Customization Decisions
  2. Product Overview, Vision & Scope Boundaries
  3. Technology Stack & System Architecture
  4. Conventions, Standards & Code Organization
  5. Data Model & Database Schema
  6. Identity, Workspaces, Roles & Permissions
  7. API Design — REST, Public API & Webhooks
  8. Capture — Browser & Desktop Recording
  9. Media Pipeline — Upload, Transcode, Storage & Delivery
  10. AI Auto-Editing Engine — Zoom, Motion & Backgrounds
  11. Timeline Editor & Edit Decision List
  12. Transcription, Captions, Chapters & AI Metadata
  13. Screenshot Capture & Beautification
  14. Sharing, Link Security & Access Control
  15. The Embeddable Player
  16. Viewer Analytics
  17. Engagement — Comments, Reactions, CTAs & Email Capture
  18. Video Library, Folders, Collections & Brand Kit
  19. Retention, Deletion & Data Lifecycle
  20. Integrations — Slack, Notion, HubSpot & Webhooks
  21. Billing, Plans & Usage Enforcement
  22. Security, Privacy & Compliance
  23. Accessibility — WCAG 2.2 AA
  24. Observability, Reliability & Operations
  25. Testing Strategy & Quality Assurance
  26. Deployment, Environments & Configuration
  27. Performance Budgets & Scale Targets
  28. Milestones & Execution Plan
  29. Executor Instructions

1. Before You Start — Customization Decisions #

This section lists every decision the executing team is likely to want to revisit before or during the build. Each row states the decision, the working default this document assumes everywhere else, the reasoning behind that default, and exactly what changes if a different choice is made. The rest of this document is written as if every default below has already been accepted — an executor who accepts all defaults can start building immediately with zero open questions.

1.1 How to use this document #

This is a single Markdown file, organized into 29 numbered top-level sections (## 1. through ## 29.), each owning a specific subsystem. Subsections are ### <N>.<M> and, where needed, #### <N>.<M>.<K>. Every fact — a table schema, an API error code, a naming rule, a config value — is defined in exactly ONE section (its "owning" section). Every other section that needs that fact references it by section number instead of restating it (for example: "validated per the shared Zod schema convention in Section 4.5" or "returns the error envelope defined in Section 7.6"). This means:

  • If two parts of this document ever appear to disagree, the OWNING section is authoritative.
  • Version numbers appear exactly once, in Section 3.1. Elsewhere the document says "the versions in Section 3" rather than repeating a number, so a version bump never requires a global find-replace.
  • Database tables are defined once in Section 5. Any section that touches a table (billing touching subscriptions, sharing touching share_links) references the table name and links back to 5.
  • An executing AI agent or engineer should be able to start at Section 1, read forward, and implement the product with no external clarification. Anywhere this document would normally say "TBD," it instead states a concrete default and this section explains why.
  • Cross-references read as "Section 7.6" or "Section 19.3" — never as a filename, a chat log, or an external document. If the team wants to record their own deviations from this spec as they build, they should keep a DECISIONS.md in their own repository documenting what they changed and why; that file is theirs to own, not part of this spec.

1.2 Decision table #

# Decision Working default Why this default What to change if you pick otherwise
1 Product / brand name Reelay Short, ownable, unclaimed enough in the dev-tools space to plausibly register a domain and package scope for; used consistently as the product name throughout this document. Rename by find-replacing Reelay in: the marketing copy (Section 2), the npm scope (@reelay/* packages, Section 3.2), the email "From" name (Section 20.2), the desktop app bundle identifier (Section 8.4), and the public API's User-Agent string. No architectural impact — the name is not baked into any schema, enum, or URL path segment other than the domain itself.
2 Primary domain reelay.app (product), api.reelay.app (API), share.reelay.app (default share-link host), cdn.reelay.app (asset CDN) A single apex plus purpose-scoped subdomains keeps cookie scoping, CORS, and CSP simple (Section 22.4) and matches the custom-domain design in Section 14, where Business-plan workspaces CNAME onto share.reelay.app. Update the domain constants in packages/shared/src/constants/domains.ts (Section 4.3), the CSP directives in Section 22.4, the OAuth redirect URIs in Section 6.6, and the CNAME target documented for customers in Section 14.7. Nothing else in the schema or API shape depends on the literal domain string.
3 Managed streaming vendor Mux (Video API + signed playback) Mux owns adaptive-bitrate transcoding, per-title encoding, and signed playback URLs as a managed service, which removes an entire category of infrastructure (Section 3.9 explains why this is not hand-rolled). Mux's asset/playback-id model maps directly onto the renditions table (Section 5.4.4). All Mux calls are routed through the StreamingProvider interface defined in Section 3.10. To swap vendor (e.g. Cloudflare Stream, api.video), implement that interface against the new vendor's SDK, register it in packages/shared/src/streaming/, and flip the STREAMING_PROVIDER env var (Section 26.3). No other code — API routes, worker jobs, the player — calls a vendor SDK directly; they only call the interface. Webhook signature verification (Section 7.10) is vendor-specific and lives behind the same interface.
4 Transcription vendor Deepgram (Nova model family) Deepgram Nova gives sub-realtime turnaround, word-level timestamps, and diarization needed for captions and filler-word detection (Section 12.2) at materially lower per-minute cost than OpenAI Whisper-as-a-service at this stage. Alternatives: Whisper via Groq (fastest inference, weaker diarization — acceptable if diarization is deprioritized) or AssemblyAI (strong diarization and built-in PII redaction, higher cost). All three are routed through the TranscriptionProvider interface (Section 12.3); swapping is a matter of implementing that interface and changing TRANSCRIPTION_PROVIDER (Section 26.3). The transcripts table (Section 5.4.7) stores a provider and provider_model column specifically so historical transcripts remain attributable after a vendor switch.
5 LLM vendor for chapters/summaries/titles Claude, via the Anthropic API Claude's long-context window and instruction-following reliability suit the structured-JSON extraction task in Section 12.5 (chapters, summary, title, tags) without excessive prompt engineering. Routed through the LlmProvider interface (Section 12.6). Swapping to OpenAI, Gemini, or a self-hosted model means implementing that interface and changing LLM_PROVIDER / LLM_MODEL (Section 26.3). Prompts live in packages/shared/src/ai/prompts/ versioned by filename suffix so a model swap can be A/B rolled out per workspace via the feature-flag system (Section 20.6, feature_flags table).
6 Object storage Cloudflare R2 Zero egress fees matter directly to unit economics here: every viewer play is a read against object storage or the CDN in front of it, and view volume scales with the product's core loop (share → watch). R2 is S3-API-compatible, so the AWS SDK v3 client (Section 3.1) works unmodified against it. Alternatives: AWS S3 (deepest ecosystem integration, egress cost is the tradeoff) or Backblaze B2 (also zero/low egress via Bandwidth Alliance, smaller ecosystem). All three are accessed exclusively through the S3-compatible client configured in Section 3.11 (bucket layout) — endpoint, region, and credentials are env-driven (Section 26.3), and no code branches on which vendor is active. Switching is a config change plus a one-time data migration (out of scope for this document; the bucket layout in Section 3.11 is vendor-neutral by design).
7 Email vendor Resend Modern deliverability, React-Email template support (matches the TypeScript-first stack), and a webhook model for bounce/complaint handling that fits the email_log table (Section 5.4.15). Swappable behind the mail-sending module in apps/api/src/lib/email/ (Section 20.2); templates are React-Email components independent of the vendor. Changing vendor means a new transport adapter and updating EMAIL_PROVIDER (Section 26.3); template content is unaffected.
8 Payment processor Stripe (Billing + Checkout + Customer Portal) Stripe's metered/per-seat billing primitives map directly onto the per-seat Pro/Business pricing in Section 21.2, and Stripe Tax handles the compliance surface (Section 21.9) without a separate integration. Alternatives, listed explicitly rather than by pointer: Paddle (merchant-of-record — Paddle, not Reelay, is the seller of record, which removes VAT/sales-tax registration burden at the cost of less billing-logic flexibility) and Lemon Squeezy (also merchant-of-record, simpler API surface, smaller feature set for usage-based billing). All billing logic in Section 21 is written against an internal BillingProvider interface (Section 21.3); the subscriptions, invoices, and usage_counters tables (Section 5) are processor-neutral. Switching processor means a new adapter implementation and a customer-data migration (webhook replay from the new processor), which is out of scope here.
9 Hosting Vercel for apps/web (Next.js 16); Fly.io for apps/api and apps/worker; Railway as the documented fallback for both if the team prefers a single provider Vercel is the reference deployment target for Next.js App Router and gives zero-config preview environments per PR (Section 26.2). Fly.io runs long-lived Fastify and BullMQ worker processes close to the database region with persistent volumes for local buffer directories; Railway is a viable single-vendor alternative with a materially simpler ops model at the cost of less fine-grained region control. Section 26 (Deployment, Environments & Configuration) documents both the Vercel+Fly.io topology and notes the Railway substitution inline where it changes a step. Neither apps/web nor apps/api contains a Vercel- or Fly-specific API call; both are stock Next.js and stock Node processes, so hosting is a deploy-config decision, not a code decision.
10 Error tracking Sentry (one project per app: web, api, worker, desktop) Unified error + performance tracing across a polyglot-runtime monorepo (browser, Node, Electron main/renderer) with source-map support for all four. Section 24.3 documents the Sentry SDK wiring per app. Swapping to Bugsnag/Rollbar/self-hosted GlitchTip means replacing the SDK init in packages/shared/src/observability/ (Section 4.3) with an equivalent client; the structured-logging convention (Section 4.6) is vendor-independent and remains the primary debugging surface regardless.
11 Analytics warehouse posture Postgres-native for v1: partitioned video_view_events table + hourly rollups into video_view_daily and video_engagement_curve (Section 16), no separate data warehouse. Product/growth analytics (funnel, activation) is deferred to a lightweight product-analytics tool (e.g. PostHog) wired via server-side event forwarding, not built in-house. Viewer analytics is a product feature with tight latency and correctness requirements (Section 16) — it belongs in the system of record. Internal product-analytics (how the Reelay team understands Reelay's own funnel) is a different workload with different freshness needs and does not justify a warehouse (Snowflake/BigQuery/ClickHouse) at this stage. If usage outgrows Postgres partitioning (Section 16.7 states the concrete signal: >500M raw events/month sustained), the migration path is to add a ClickHouse or BigQuery sink fed by the same rollup jobs, without changing the video_view_daily/video_engagement_curve read API that the dashboard consumes (Section 16.4).
12 Default region us-east (Postgres primary, R2 bucket location, Fly.io primary region), with the CDN in front of storage and Mux being globally distributed regardless of origin region Minimizes latency to the largest expected initial user base (North America B2B SaaS buyers) while keeping playback fast everywhere via the CDN/Mux edge network — origin region only affects upload and control-plane latency, not viewer playback latency. Region is an env var (PRIMARY_REGION, Section 26.3) read at deploy time by every app; no code path hardcodes a region string. A team targeting EU-first customers (for data-residency reasons, Section 22.8) should set PRIMARY_REGION=eu-west and provision the Postgres primary, R2 bucket, and Fly.io app in that region before first deploy — this is a pre-launch infrastructure decision, not a code change.
13 Are viewer seats billable? No — viewer seats are free and unlimited on every plan. Only recording/editing seats (workspace members who can create recordings) count against the per-seat pricing in Section 21.2. The product's growth loop depends on frictionless viewing: a prospect who receives a demo link must never hit a paywall to watch it, or the share-to-view conversion the whole wedge (Section 2.2) depends on breaks. This is stated as a hard product invariant, not a pricing experiment. If this is ever revisited, it changes: the workspace_members role semantics (Section 6.8, where viewer currently never consumes a seat), the plan-limit table (Section 21.2, which would need a "billable viewer" row), and the seat-counting logic in usage_counters (Section 5.4.13). This document assumes the "no" answer everywhere; treat any change here as a cross-cutting change to Sections 6, 19, and 21 together, not a local one.
14 Support/help-widget vendor Plain (help-desk + shared inbox), embedded as a small support-widget snippet on the dashboard only, never on the watch page or in the embedded player Keeps the support surface out of the performance-critical watch/embed path entirely (Section 15's budget has zero tolerance for a third-party support-widget script), while giving the team a modern, API-driven inbox for handling upgrade/billing/bug-report conversations that reference a workspaceId/videoId for context. Swapping to Intercom, Zendesk, or Front is a snippet and webhook-endpoint change confined to apps/web's dashboard shell layout; no schema or API contract depends on the vendor.
15 Desktop app auto-update strategy electron-updater, pulling signed release artifacts from a private release channel (GitHub Releases by default), staged rollout (10% → 50% → 100% over 48h), silent background download + prompt-to-restart install Electron auto-update is a solved problem with a mature, well-audited library; staged rollout limits blast radius if a capture-path regression ships (capture bugs are severe — Section 8's invariant is "never lose a recording"). Swapping the artifact host (e.g., to a private S3-hosted update feed instead of GitHub Releases) is an electron-updater provider-config change in apps/desktop/src/main/updater.ts; the staged-rollout percentages are config values, not code.
16 Default currency & pricing display USD, Stripe Tax (Section 21.9) handles VAT/GST calculation and display for non-US customers at checkout Simplifies the initial pricing page and billing schema (invoices.amount_cents and plans.price_cents are USD-denominated integers, Section 5.4.13) while Stripe Tax still gives international buyers a compliant, localized tax line at checkout without a multi-currency pricing engine. Multi-currency pricing (localized list prices, not just tax) requires adding a currency column to plans and invoices and Stripe Checkout's currency_options — a Section 21 schema change, not a Section 3/4 architectural change.

2. Product Overview, Vision & Scope Boundaries #

2.1 The problem #

Three categories of screen-recording tool exist today, and each leaves a gap:

  • Loom is fast to record and share, but the output is raw: no automatic camera movement, no cleanup of dead air or filler words, and analytics/team workflow are shallow. It optimizes for "record and send" over "record and land the message."
  • Screen Studio and Tella produce polished, cinematic output — auto zoom-to-cursor, smooth camera motion, branded backgrounds — but they are single-player editing tools first. Sharing is an export step, not a first-class distribution and analytics surface, and there is no real team workflow (shared libraries, permissions, brand-kit enforcement, audit trail).
  • Vidyard and Descript sit in adjacent spaces — Vidyard leans sales-video-hosting with reasonable analytics but dated, non-auto editing; Descript leans deep multi-track editing for produced content, which is more tool than most day-to-day async communication needs.

No product combines Screen Studio-grade automatic polish with Loom-grade share velocity and Vidyard-grade analytics and team control, in one capture-to-share pipeline.

2.2 The wedge #

Reelay has one deliberate center of gravity: the async product demo — the video a salesperson, founder, or product manager records to show a prospect or teammate how something works, without a live call. Automatic editing quality (Section 10) is the differentiator: a demo that looks professionally cut with zero manual editing effort is what separates Reelay from "just another Loom clone." Every other use case the product supports — support-ticket replies, sales follow-ups, async standups, bug reports — is a byproduct of the same capture → auto-edit → share → measure pipeline, not a separately engineered workflow. The product deliberately does not build vertical-specific tooling per use case (Section 2.5); the wedge is the pipeline, not the persona.

2.3 Target users and jobs-to-be-done #

Persona Who they are Job-to-be-done What "done well" looks like
Product / growth PM or growth marketer at a B2B SaaS company "I need to show a new feature to users/prospects without scheduling a call or writing docs nobody reads." A demo that looks intentional (auto-zoomed, clean audio) goes out in the same session it was recorded, embedded in an email or changelog, with view/watch-through data coming back within the hour.
Sales Account executive or sales engineer "I need to follow up after a call with something more personal than a text but faster than a booked demo." A 90-second personalized walkthrough recorded between calls, sent with a tracked link, that tells the rep whether the prospect actually watched before the next call.
Support Support or success engineer "I need to answer 'how do I do X' without typing a multi-step doc the user won't read." A recording with an auto-generated chapter list and a clear title, dropped into a ticket reply in under two minutes, redacting any account data visible on screen.
General teams Anyone doing async standups, PR walkthroughs, or bug reports "I need to explain something visual to a teammate in a different timezone without a meeting." A quick recording with a title auto-suggested by AI, findable later in a shared team folder, watchable without asking "can you resend that link."

2.4 Competitive positioning #

Capability Reelay Loom Tella Screen Studio Vidyard Descript
Auto zoom-to-cursor / camera motion Full, deterministic, re-editable EDL (Section 10) None Manual/template-driven Yes, best-in-class polish, single-player None None
Non-destructive editing (revertible cuts/fillers) Full EDL model (Section 11) Basic trim only Limited Local project file, not server-side Basic trim only Full — this is Descript's core strength
Team library, folders, permissions Full (Section 18), role-based (Section 6) Yes, workspace-based Minimal None — local app Yes, sales-team focused Limited
Viewer analytics (per-viewer, drop-off curve) Full, per-viewer + aggregate (Section 16) Basic view count None None Strong — Vidyard's core strength Basic
Redaction (server-side burn-in) Yes (Sections 11.7 and 22.2, 14) No No No No Manual blur, client-exported
Embeddable player (perf-budgeted) Yes, ≤8 KB loader (Section 15) Yes, heavier Export-only, no embed SDK Export-only Yes Export-only
REST API + webhooks Yes, Business plan (Section 7) Limited No No Yes Limited
Native desktop capture app Yes, Electron (Section 8) Yes Yes, macOS-first Yes, macOS-only No Yes
Where Reelay is honestly behind Larger existing install base, more mature Slack/Notion integration ecosystem at launch (Section 20 ships a smaller integration set day one) Screen Studio's manual fine-tuning controls exceed Reelay's automatic-first UX for power users who want frame-by-frame control Vidyard's sales-specific CRM depth (multi-CRM native cards) exceeds Reelay's integration set (Section 20) at launch Descript's multi-track podcast/video editing and AI voice tools are out of scope here (Section 2.5)

2.5 Success metrics #

Metric Definition Target
Activation rate % of signups who complete first recording within 24h of signup ≥ 45%
Time-to-first-share Median minutes from signup to first share-link created ≤ 15 minutes
Share-to-view rate % of created share links that receive ≥ 1 view within 7 days ≥ 70%
Watch-through rate Median % of video duration watched per view session (Section 16.5 defines the calculation) ≥ 55%
Free-to-paid conversion % of workspaces that create ≥ 3 videos in 30 days and convert to Pro/Business within 60 days ≥ 8%
Weekly active recorders % of workspace recording-seat members who record ≥ 1 video/week, 4-week rolling ≥ 35%

2.6 User journey — install to shared demo to analytics #

  1. Install: user signs up with email+password or Google OAuth (Section 6.6), verifies email (required before any public sharing, Section 6.9), and is offered browser-based recording immediately or a desktop app download for OS-audio/high-fps capture (Section 8.4).
  2. Record: user starts a screen (+ optional webcam) recording. Cursor telemetry is captured throughout (Section 10.1). Recording streams to local buffer and uploads concurrently (Section 9.2) so there is no "finish recording, then wait to save" step.
  3. Auto-edit: on upload completion, the auto-edit pipeline (Section 10) generates a default EditDecisionList — zoom-to-cursor segments, smooth camera path, chosen background — without any user action. This is what the user sees first when they open the video.
  4. Refine (optional): user opens the timeline editor (Section 11), adjusts zoom timing, removes filler words/silence (Section 12.2, Pro+), applies a brand-kit background (Section 18.5, plan-gated per Section 21.2), and the system re-renders from the EDL, never destroying the original.
  5. Share: user creates a share link (Section 14) with a visibility level, optional password/ expiry/domain restriction, and copies the URL or embeds the player (Section 15). AI-suggested title, summary, and chapters (Section 12.5) are attached automatically and editable.
  6. Viewer watches: recipient opens the watch page or embedded player. No account or app install required for link/public visibility. Viewing generates analytics events (Section 16.2) from first frame.
  7. Analytics come back: the sender sees, per viewer where identity is known, watch-through %, drop-off curve, and any CTA clicks/comments/reactions (Section 16, 17) — typically within seconds of a heartbeat event landing, definitely within the hourly rollup window (Section 16.3) for aggregate views.
  8. Team workflow: the video lands in a shared folder per workspace permissions (Section 18.3); teammates with member+ role can view analytics on their own videos, admin/owner see workspace- wide analytics and audit log (Section 6.8).

2.7 In scope — the 15 core features #

  1. Browser-based screen + webcam + microphone recording (Section 8.1–8.2)
  2. Desktop capture app for OS-audio, high-fps, multi-display recording (Section 8.4)
  3. Local-first capture with resumable, network-loss-proof upload (Section 9.2)
  4. Automatic zoom-to-cursor, smooth camera motion, and background compositing (Section 10)
  5. Non-destructive timeline editor with a re-renderable Edit Decision List (Section 11)
  6. Filler-word and silence detection and removal (Section 12.2, Pro+)
  7. Automatic transcription, closed captions, and chapter/summary/title generation (Section 12)
  8. Server-side burned-in redaction (blur/box, static or tracked) (Sections 11.7 and 22.2, 14.8)
  9. Screenshot capture and beautification (background, shadow, device frame) (Section 13)
  10. Share links with visibility, password, expiry, domain restriction, and audit log (Section 14)
  11. An embeddable, performance-budgeted player for web and email (Section 15)
  12. Per-viewer and aggregate watch analytics with drop-off curves (Section 16)
  13. Engagement: comments, reactions, CTAs, and email-gated viewing (Section 17)
  14. Team libraries: folders, collections, brand kit, workspace roles and permissions (Sections 6, 18)
  15. Billing and plan enforcement across Free/Pro/Business tiers (Section 21)

2.8 Out of scope — explicit boundaries #

Boundary Rationale
Self-hosted / bring-your-own-storage deployment v1 is cloud-only (Reelay-operated infrastructure). Self-hosting multiplies the support and security-review surface before the core product is proven; revisit only after Business-tier demand is demonstrated.
SSO (SAML) and SCIM provisioning Deferred. The workspace_members table and explicitly-invited (never implicit-domain) membership model (Section 6.4) is designed so SSO/SCIM can be layered in additively later without a data-model migration — see Section 6.10.
Vertical-specific templates or workflows for any single team (e.g. a dedicated "support macro" builder, a "sales sequence" product) The wedge (Section 2.2) is the general capture-and-share pipeline; building per-vertical workflow tooling would fragment engineering effort away from the auto-editing differentiator that every persona benefits from equally.
Native mobile recording apps (iOS/Android) The core recording surfaces (screen content, cursor precision) are desktop-native concepts; mobile screen recording has different technical constraints and a materially smaller addressable job-to-be-done for "product demo." Viewing on mobile is fully supported via the responsive watch page and player (Section 15.7).
AI voice cloning or synthetic avatars Out of keeping with the non-destructive, non-fabrication editing invariant (Section 11.3, 12.7): Reelay edits timing, never content or voice. Synthetic voice/avatar generation is a different product category.
Live streaming and webinar hosting Different infrastructure (low-latency streaming vs. on-demand adaptive playback) and a different buyer motion (event/webinar tooling) than async demo sharing.
Multi-track cinematic editing beyond demo and talking-head use cases (e.g. multi-camera podcast editing, timeline-based B-roll compositing) The timeline editor (Section 11) is scoped to single-source screen/webcam composition plus EDL-driven cuts and auto-edit segments — enough for demo and talking-head content, not a general video editor.

Non-goals that follow from the wedge: Reelay does not attempt to be a general-purpose video hosting platform (no long-form/entertainment video use case), does not attempt to replace a full-featured video editor for produced marketing content, and does not attempt to be a webinar or live-event product. Every feature decision in this document should be evaluated against "does this serve the record-a-demo-and-know-it-landed loop" — features that don't are deliberately excluded even where a competitor offers them.

2.9 Glossary #

Term Definition
Recording A single capture session (screen/webcam/audio) produced by the browser or desktop app before any server-side processing. Immutable once uploaded (Section 9, 11.1).
Video The workspace-visible entity a recording becomes after ingest: one row in videos (Section 5.9), the parent of renditions, EDLs, transcripts, and share links.
Asset A stored media object (an original recording file, a rendered output, a thumbnail, an exported clip) tracked in media_assets (Section 5.4.4) with a storage path and content hash.
Rendition A specific playable output of a video at a given quality/format — an ABR ladder step from Mux, a burned-in redacted MP4, a GIF export (Section 5.4.4, Section 9).
EDL (Edit Decision List) The JSON document describing every edit — cuts, zoom segments, background, redaction regions — applied to a video's source. The source is never modified; renders are derived from EDL + source (Section 11.2).
Interest event A detected moment of user intent during capture — a click, drag, typing burst, or scroll — used to drive automatic zoom/camera decisions (Section 10.2).
Share link A share_links row: a distributable, permissioned URL to a specific video with its own visibility, password, expiry, and audit trail (Section 14).
Viewer A person watching a video via a share link or embed, identified by an anonymous rotating token or, once known, a named identity (Section 16.6). Distinct from a workspace viewer role member (Section 6.8) — a viewer in the analytics sense may not have a Reelay account at all.
share_viewer The anonymous-caller variant of the authorize() actor type (Section 6.9): an internet visitor watching via a share link, identified only by their viewerToken and the videoId they are watching, with no workspace membership. Evaluated solely against the share link's own permission settings (visibility, password, expiry — Section 14), never against the workspace viewer role's capabilities. The workspace role viewer (a member with a Reelay account) and share_viewer (an anonymous link holder) are different populations that must never share a code path, even though both are colloquially "viewers" of a video.
Watch page The hosted Reelay page a share link resolves to when opened directly (as opposed to embedded elsewhere) — player plus title, chapters, comments, and CTAs (Section 15.8).
Embed The player rendered inside a third-party page (a docs site, an email, a help-center article) via the loader script, as opposed to the watch page (Section 15).
Brand kit A workspace-level set of logo, colors, and default background presets applied to new recordings and the watch page for Business-tier enforced branding (Section 18.5).

2.10 Metrics measurement methodology #

Each success metric in Section 2.5 must be computed from a specific, unambiguous data source so there is no ambiguity when the dashboard in Section 24.5 renders it.

Metric Data source Computation
Activation rate users.created_at, first row in videos owned by the user (videos.owner_id) count(users where exists a video with created_at <= users.created_at + 24h) / count(users created in window), computed daily over a trailing 24h signup cohort.
Time-to-first-share users.created_at, first share_links.created_at for a video the user created Median of (first_share_link.created_at - user.created_at) in minutes, per weekly cohort; workspaces with zero shares in 30 days are excluded from the median and reported separately as a "never shared" rate.
Share-to-view rate share_links.created_at, first matching row in video_view_events (Section 5.4.10) for that link count(share_links with >=1 view_event within 7 days of creation) / count(share_links created in window).
Watch-through rate video_engagement_curve (Section 16.3) Median, across view sessions, of (last retention bucket with nonzero viewers) / video.duration_ms, per Section 16.5's exact formula.
Free-to-paid conversion videos count per workspace in a 30-day window, subscriptions.plan_id transitions (Section 5.4.13) count(workspaces with >=3 videos in any 30-day window that transition free -> paid within 60 days of the 3rd video) / count(workspaces reaching the 3-video threshold).
Weekly active recorders videos.owner_id, workspace_members with a recording-capable role (Section 6.8) count(distinct recording-capable members with >=1 video created in trailing 7 days) / count(recording-capable members), refreshed daily as a 4-week rolling average.

3. Technology Stack & System Architecture #

3.1 Version lines #

The following version lines are locked for this build. Each is stated here once; every other section of this document refers back to "the versions in Section 3" rather than restating a number.

Layer Choice
Language TypeScript 7.x
Runtime Node.js 24.x LTS
Web framework Next.js 16.x (App Router, React Server Components)
UI library React 19.x
Styling Tailwind CSS 4.x
Validation Zod 4.x (shared across client and server)
ORM / migrations Drizzle ORM 0.45.x with drizzle-kit
Database PostgreSQL 17
Cache / queue broker Redis 8.x
Queue framework BullMQ 6.x
Desktop shell Electron 43.x
Billing SDK Stripe Node SDK 22.x
Testing Vitest 4.x (unit/integration), Playwright 1.62.x (E2E)
Player HLS fallback hls.js 1.7.x
Object storage client AWS SDK for JavaScript v3 (3.x), used against any S3-compatible provider
Server-side rendering/transcoding worker FFmpeg 7.x
Managed streaming Mux, accessed only through the interface in Section 3.10

These version lines are a known-good floor, not a lockfile. At build time, install the current stable release of each dependency (npm install <pkg>@latest, or your ecosystem's equivalent), confirm the major line still matches, and let the lockfile record the exact resolved versions.

Never pin an exact patch version anywhere else in this document, in code comments, or in documentation the executing team writes from this spec. A version cited more than one major line behind the table above is stale by definition and should be treated as an error in whatever document cites it.

3.2 Monorepo layout #

Package manager: pnpm workspaces. Task orchestration and caching: Turborepo.

reelay/
├── apps/
│   ├── web/          # Next.js 16 app: marketing site, dashboard, timeline editor, watch page
│   ├── api/           # Fastify 5 REST API — internal (dashboard) + public (Section 7) surfaces
│   ├── desktop/        # Electron 43 capture app (macOS, Windows)
│   └── worker/         # BullMQ 6 worker processes — one deployable per queue group (Section 3.6)
├── packages/
│   ├── player/         # Embeddable player — vanilla TypeScript, zero framework runtime, loader ≤8KB gz, core ≤20KB gz (Section 15.2)
│   ├── db/             # Drizzle schema, migrations, seed scripts (Section 5)
│   ├── shared/          # Zod schemas, shared types, constants, provider interfaces (Section 4.5)
│   └── ui/             # React component library — dashboard/editor only, NEVER imported by packages/player
├── turbo.json
├── pnpm-workspace.yaml
└── package.json

Package responsibilities:

Package Owns Must never
apps/web Marketing pages, authenticated dashboard, timeline editor UI, public watch page rendering Contain business logic that the public API also needs — that logic lives in apps/api and is called over HTTP, or in packages/shared if it's pure logic both need.
apps/api All REST endpoints (Section 7), auth session issuance, webhook receivers, request validation Perform long-running media work synchronously in a request handler — anything over ~2s of work is enqueued to apps/worker (Section 3.6).
apps/desktop Native capture (Section 8.4), local chunk buffering, resumable upload client Duplicate business logic already in packages/shared — it imports the same Zod schemas and constants as the web app.
apps/worker Transcode orchestration, EDL rendering, transcription requests, analytics rollups, deletion jobs (Section 3.6) Expose an HTTP surface — workers are queue-driven only, no inbound ports except a health-check endpoint.
packages/player The embeddable player runtime Import React, Next.js, or any package from packages/ui — this is the ≤20KB-gz constraint from Section 15.1; a single stray import of a framework breaks the budget.
packages/db Table definitions, migrations, the Drizzle client factory Contain any HTTP, queue, or UI code — it is a pure data-layer package importable by both apps/api and apps/worker.
packages/shared Zod schemas (request/response DTOs, EDL schema, webhook payloads), shared TypeScript types, cross-cutting constants (domains, plan limits, queue names), the StreamingProvider/TranscriptionProvider/LlmProvider/BillingProvider interfaces Import from apps/* — dependency direction is strictly apps → packages, never the reverse.
packages/ui Dashboard and editor React components (buttons, tables, the timeline UI) Be imported by packages/player (framework-free) or apps/desktop's capture-critical path (keep native capture UI minimal and dependency-light).

3.3 Request-path architecture #

                              ┌─────────────────────────────────────────┐
                              │              CDN (Section 3.8)            │
                              │  static assets · player bundle · posters  │
                              └───────────────┬───────────────────────────┘
                                              │
        ┌──────────────┐        ┌───────────▼────────────┐        ┌──────────────────┐
        │  apps/desktop │        │       apps/web           │        │   packages/player  │
        │ (Electron 43) │        │  (Next.js 16, Vercel)     │        │  (embedded, 3rd-   │
        │ capture+upload│        │ dashboard·editor·watch pg │        │  party page/email)  │
        └───────┬───────┘        └───────────┬────────────┘        └─────────┬──────────┘
                │  multipart upload           │  fetch (RSC + client)         │ fetch /v1/collect
                │  (Section 9.2)               │                               │ + signed playback URL
                ▼                              ▼                               ▼
        ┌──────────────────────────────────────────────────────────────────────────┐
        │                          apps/api (Fastify 5, Fly.io)                        │
        │   /v1/* REST — auth, videos, share-links, analytics ingest, webhooks        │
        └───────┬───────────────────────┬────────────────────────┬───────────────────┘
                │ enqueue jobs           │ read/write               │ signed URLs / webhooks
                ▼                        ▼                          ▼
        ┌───────────────┐        ┌─────────────┐          ┌─────────────────────┐
        │ Redis (BullMQ)  │        │ PostgreSQL 17 │          │  Mux (StreamingProvider) │
        │ queue topology  │        │ system of      │          │  Deepgram / Anthropic     │
        │ (Section 3.6)    │        │ record         │          │  (behind their interfaces) │
        └───────┬─────────┘        └─────────────┘          └─────────────────────┘
                │ consumes
                ▼
        ┌───────────────────────────────────────────┐
        │            apps/worker (Fly.io)              │
        │  transcode orchestration · EDL render (FFmpeg)│
        │  transcription · AI metadata · analytics rollup│
        │  deletion/retention jobs                       │
        └───────────────┬───────────────────────────────┘
                        │ reads/writes
                        ▼
        ┌───────────────────────────────────────────┐
        │   Object storage (R2/S3/B2 — Section 3.11)   │
        │   originals · renditions · exports · posters   │
        └───────────────────────────────────────────┘

Every arrow into apps/api is authenticated per Section 6 (session JWT or API key) except the analytics ingest endpoint POST /v1/collect and public webhook receivers, both of which are unauthenticated-but-verified (rate-limited and, for webhooks, signature-verified — Section 7.10).

3.4 Control plane vs. data plane #

  • Control plane (PostgreSQL 17, Redis 8): every fact about what exists and what state it's in — users, workspaces, video metadata, EDLs, share-link permissions, subscription state, job queue state. Small, relational, transactional, backed up continuously (Section 24.6).
  • Data plane (object storage + Mux + CDN): the actual media bytes — original recordings, rendered outputs, thumbnails, exports. Large, immutable-once-written (except deletion, Section 19), never queried relationally, served through the CDN rather than through apps/api.

Control plane and data plane are deliberately decoupled: apps/api never streams media bytes through itself. Upload goes client → object storage directly (pre-signed multipart URLs, Section 9.2); playback goes viewer → CDN/Mux directly (signed playback URLs, Section 14.6). The API's job is to issue and validate the URLs and record the resulting state transitions, not to proxy bytes.

3.5 Why PostgreSQL is the system of record — and what is deliberately not in it #

PostgreSQL 17 holds every fact the product needs to be correct and queryable relationally: entities, relationships, permissions, billing state, and rolled-up analytics. It is chosen over a document/NoSQL store because the domain is heavily relational (workspaces → members → videos → share-links → view-events, with permission checks that join across several of these on nearly every request) and because Section 5's schema needs real foreign keys, check constraints, and transactional guarantees (e.g., a share-link audit event must be written in the same transaction as the permission change it describes, Section 14.7).

Deliberately not in PostgreSQL:

  • Media bytes (recordings, renditions, exports, thumbnails, screenshots) — these live in object storage (Section 3.11) and Mux; Postgres stores only pointers (media_assets.storage_key, renditions.mux_asset_id, etc., Section 5.4.4). Storing large binary blobs in Postgres would bloat the WAL, defeat point-in-time recovery efficiency, and gain nothing — object storage is purpose-built for this.
  • The raw event firehose beyond its partition windowvideo_view_events (Section 5.4.10) is partitioned monthly and raw partitions older than 3 months are dropped after their rollups (video_view_daily, video_engagement_curve, Section 16.3) are confirmed durable. Keeping unbounded raw event history in the primary transactional database would eventually degrade every other query on the same instance; the rollup tables carry the long-term analytical value.
  • Cursor telemetry blobs — raw high-frequency cursor samples (Section 10.1, up to 120 Hz) are stored as compressed blobs in object storage (referenced by cursor_telemetry_blobs.storage_key, Section 5.4.6), not as rows — at 120 samples/sec this is a data-plane volume problem, not a relational one.
  • Session-level caches and rate-limit counters — these live in Redis (Section 3.7), which is the correct tool for high-churn, short-TTL, non-durable state.

3.6 Queue topology #

All async work is BullMQ 6 running against Redis 8. Every job is idempotent, keyed as job.id = <entity>:<operation>:<version> so a duplicate enqueue (from a retried webhook, for example) is a safe no-op rather than duplicate work. Every queue has a paired dead-letter queue (<queue-name>.dlq) that a failed job (after exhausting attempts) is moved to for manual inspection via the operations dashboard (Section 24.5).

Queue Job names Concurrency Retry policy Backoff Dead-letter
video-ingest see Sections 9.3.1 and 9.8 10 5 attempts exponential, base 2s, cap 60s video-ingest.dlq
video-transcode see Sections 9.3.1 and 9.8 5 (Business-plan jobs prioritized via BullMQ job priority, Section 21.2) 5 attempts exponential, base 5s, cap 120s video-transcode.dlq
video-render see Sections 9.3.1 and 9.8 see Section 9.3.1 5 attempts exponential, base 5s, cap 180s video-render.dlq
video-export see Sections 9.3.1 and 9.8 see Section 9.3.1 5 attempts exponential, base 5s, cap 180s video-export.dlq
transcription see Sections 9.3.1 and 9.8 8 5 attempts exponential, base 3s, cap 90s transcription.dlq
ai-metadata see Sections 9.3.1 and 9.8 8 5 attempts exponential, base 3s, cap 90s ai-metadata.dlq
analytics-rollup see Sections 9.3.1 and 9.8 2 (scheduled, not event-driven — Section 16.3) 3 attempts exponential, base 10s, cap 300s analytics-rollup.dlq
email see Sections 9.3.1 and 9.8 15 5 attempts exponential, base 2s, cap 60s email.dlq
webhook-delivery see Sections 9.3.1 and 9.8 10 5 attempts exponential, base 5s, cap 900s (Section 20.7 documents the full 24h retry window as repeated enqueues, not a single job's backoff) webhook-delivery.dlq
retention-deletion see Sections 9.3.1 and 9.8 2 (deliberately low — deletion is destructive and rate-limited, Section 19) 5 attempts exponential, base 30s, cap 600s retention-deletion.dlq
screenshot see Sections 9.3.1 and 9.8 6 3 attempts exponential, base 2s, cap 30s screenshot.dlq

This table is the concurrency/retry/backoff/dead-letter topology for each queue; it is deliberately silent on job names and on the video-render/video-export concurrency figures — Sections 9.3.1 and 9.8 own the exact job name strings and the render/export concurrency values, and restating them here would create exactly the kind of two-source-of-truth drift this document's single-owner convention (Section 1.1) exists to prevent.

All queues run in apps/worker, deployed as separate process groups per queue-family (ingest/ transcode/render/export share a "media" worker deployment; transcription/ai-metadata share an "AI" worker deployment; analytics-rollup/retention-deletion/email/webhook-delivery/screenshot share a "platform" worker deployment) so CPU-heavy FFmpeg work can be scaled independently of I/O-bound webhook/email work.

3.7 Caching layers #

Layer Technology What it caches TTL / invalidation
Session cache Redis 8 Active session lookups (avoids a Postgres round-trip on every authenticated request) 5 min TTL, invalidated on logout/revocation write-through
Rate-limit counters Redis 8 Per-API-key and per-IP request counts (Section 7.9) Sliding window, 60s buckets
Permission cache Redis 8 Resolved workspace-member role for the current request's (user, workspace) pair 60s TTL, invalidated on role change write
CDN edge cache CDN (Section 3.8) Player bundle, posters, thumbnails, static export files, marketing site Content-hashed key, Cache-Control: public, max-age=31536000, immutable for every content-hashed asset (player bundle, posters, thumbnails, exports); marketing site build output follows the same 1-year immutable policy since its filenames are also content-hashed by the build tool
Signed URL cache Redis 8 Recently issued Mux signed playback tokens, keyed by (video, viewer session) to avoid re-signing on every player heartbeat Matches the 6h token TTL (Section 14.8), refreshed at 80% of TTL
Next.js data cache Next.js 16 built-in (RSC fetch cache) Server-rendered dashboard fragments that are workspace-scoped but not per-request-unique Tag-based invalidation on relevant mutation (e.g. revalidateTag('workspace:${id}:videos') on video create/delete)

3.8 CDN posture #

A CDN sits in front of two origins: the reelay-media-delivery object storage bucket (Section 3.11 — posters, thumbnails, exports, screenshots, and the player bundle) and Mux's own edge network (for HLS segment delivery — Mux operates its own CDN for adaptive streaming, so Reelay's CDN layer does not re-proxy video segments, only the surrounding static assets and non-Mux exports). The reelay-media-restricted bucket (Section 3.11) has no CDN origin configured for it at all, in any environment — this is a physical guarantee, not a cache-policy one: a misconfigured CDN rule cannot expose restricted content because the CDN has no path to that bucket to begin with.

Cache-Control policy: content-hashed static assets (player bundle, marketing site build output) are cached immutably for 1 year. Posters, thumbnails, and animated previews use the SAME immutable policy — content-hashed key, Cache-Control: public, max-age=31536000, immutable — rather than a short revalidation window, because this asset class is access-controlled by its key, not by a guessable path: the object key for a poster or thumbnail is scoped by the owning share link's playback_key_version (Section 14.1.1), so a re-render produces a new content hash and a visibility downgrade or link revocation mints a new playback_key_version — either way, the next request serves a new key rather than depending on the old cached response to expire. The old key's CDN cache entry is additionally purged outright (not left to age out) on both a re-render and a visibility downgrade/link revocation, per Section 14.1.1 — a short TTL is not a substitute for an explicit purge on those two events. API responses are never CDN-cached (all /v1/* traffic bypasses the CDN and hits apps/api directly, since responses are per-session/per-key and permission-scoped).

3.9 Why transcoding is not hand-rolled #

Adaptive-bitrate transcoding — probing source video, generating a multi-rendition ladder (multiple resolutions/bitrates), packaging HLS/DASH manifests, and serving them with per-session signed access — is a well-understood but operationally heavy problem: it requires a fleet of GPU/CPU transcode workers that must scale with upload volume, careful queue backpressure so a traffic spike doesn't starve other jobs, codec/format edge-case handling across an enormous device/browser matrix, and constant maintenance as codecs evolve (AV1 adoption, for example). None of this differentiates Reelay — the product's differentiation is the auto-editing layer (Section 10) that sits on top of a correct, fast, reliable transcode, not the transcode itself. Mux is purpose-built for exactly this problem, exposes a signed-playback-URL model that maps directly onto the share-link security model (Section 14.6), and its webhook-driven "asset ready" callback integrates cleanly into the video-transcode queue (Section 3.6). Building and operating an equivalent transcode fleet in-house would be a multi-quarter distraction from the product's actual wedge (Section 2.2) for a capability that is, from the user's perspective, table stakes rather than differentiating. FFmpeg (Section 3.1) is still used directly, but only for two narrower, product-specific tasks Mux does not do: EDL rendering (compositing zoom/pan/background per the auto-edit decision list, Section 10) and burning in redaction regions (Sections 11.7 and 22.2) — both are Reelay-specific transformations applied before the result is handed to Mux (or directly exported) for delivery.

3.10 The managed-streaming boundary — StreamingProvider interface #

No code outside packages/shared/src/streaming/ and its Mux implementation ever imports the Mux SDK directly. Every call site (API routes, worker jobs) depends only on this interface:

// packages/shared/src/streaming/provider.ts

export interface StreamingAsset {
  providerAssetId: string;
  providerPlaybackId: string;
  status: 'preparing' | 'ready' | 'errored';
  durationMs: number | null;
  maxResolutionTier: '720p' | '1080p' | '1440p' | '2160p' | null;
}

export interface SignedPlaybackToken {
  token: string;
  expiresAt: Date; // now + 6h per Section 14.8
}

export interface StreamingProvider {
  /** Create a direct-upload target the client/worker can PUT the source file to. */
  createDirectUpload(input: {
    corsOrigin: string;
    newAssetSettings: { playbackPolicy: 'signed'; maxResolutionTier: StreamingAsset['maxResolutionTier'] };
  }): Promise<{ uploadUrl: string; providerUploadId: string }>;

  /** Look up asset state — used by the poll fallback if a webhook is missed (Section 7.10). */
  getAsset(providerAssetId: string): Promise<StreamingAsset>;

  /** Issue a short-TTL signed playback token for a specific viewer session. */
  signPlaybackToken(providerPlaybackId: string, opts: { ttlSeconds: number }): Promise<SignedPlaybackToken>;

  /** Delete the underlying asset — called from the deletion pipeline (Section 19). */
  deleteAsset(providerAssetId: string): Promise<void>;

  /** Verify an inbound webhook signature and parse it into a normalized event. */
  verifyWebhook(rawBody: Buffer, headers: Record<string, string>): StreamingWebhookEvent;
}

export type StreamingWebhookEvent =
  | { type: 'asset.ready'; providerAssetId: string; playbackId: string; durationMs: number }
  | { type: 'asset.errored'; providerAssetId: string; reason: string };

The Mux implementation (packages/shared/src/streaming/mux-provider.ts) is the only file that imports @mux/mux-node. Swapping streaming vendor (Section 1.2, decision 3) means writing a new file implementing this same interface and flipping STREAMING_PROVIDER=mux|cloudflare-stream|... (Section 26.3); the renditions table (Section 5.4.4) stores provider and provider_asset_id columns generically so historical data survives a swap.

3.11 Media storage bucket layout #

Restricted media uses two physically separate buckets, not two prefixes inside one bucket, so that a single misconfigured bucket policy — a public-read ACL added by mistake, an over-broad IAM role, a CDN origin pointed at the wrong place — can expose at most one class of content and never the other. This is the canonical model (owned in full, including the exhaustive media_assets.kind → bucket mapping, by Section 22.2.2); this section states the architecture-level shape, and Section 9.6 covers the pipeline stages that write to each bucket.

Bucket Env var Holds CDN origin Default access
reelay-media-restricted STORAGE_BUCKET_RESTRICTED Unredacted original recordings; the retained unredacted original for any video that has active redaction regions; raw cursor telemetry blobs (Section 5.4.6) None — no CDN configuration exists for this bucket in any environment Deny-by-default IAM. Only apps/worker (scoped credentials for ingest and render) and apps/api (scoped read, for owner-initiated re-render/export requests only) may access it; every read is audit-logged (Section 22.6) regardless of caller
reelay-media-delivery STORAGE_BUCKET_DELIVERY Every servable rendition (burned-in, redaction-safe renditions and Mux-independent exports), poster, thumbnail, screenshot, export, and the player bundle Yes — the sole CDN origin for object-storage-backed assets (Section 3.8) Public-read or signed-URL, matching the owning share link's visibility (Section 14); never a blanket public-read bucket policy

A misconfigured policy on reelay-media-delivery (for example, an ACL mistake that makes a poster world-readable that shouldn't be) cannot expose anything in reelay-media-restricted, because the two are different buckets with different credentials, different IAM roles, and — critically — reelay-media-restricted has no CDN origin to misconfigure in the first place. This is the specific security property physical bucket separation buys over a single-bucket, prefix-based layout: a bucket-level public-read policy silently wins over a prefix's intended restriction if a single bucket is shared, which is exactly the failure mode that has exposed "private" content in practice elsewhere. Physical separation removes that failure mode by construction rather than depending on every future engineer getting a prefix rule right.

media_assets.kind (Section 5.4.4, canonical) determines which bucket an object lives in:

media_assets.kind Bucket Notes
original reelay-media-restricted The as-recorded source, for any video that has no redaction regions. Never served to a viewer.
redaction_unredacted_original reelay-media-restricted The as-recorded source for any video that has redaction regions. This is the same physical object the deleted source_original kind used to name; this kind is used specifically whenever redaction applies, so "an object under this kind" unambiguously means "unredacted footage that must never be served."
brand_logo reelay-media-delivery Workspace brand-kit assets (Section 18.5). Never contains recorded footage.
poster / thumbnail reelay-media-delivery Access-controlled by the share link's playback_key_version (Section 14.1.1), never a permanent guessable path (Section 15.6).
export reelay-media-delivery On-demand MP4/GIF/WebM exports (Section 9).
screenshot_original reelay-media-restricted The raw captured frame before beautification or redaction (Section 13). Held privately until the owner publishes a screenshot_edited derivative — the same "restricted until reviewed" posture the video pipeline uses, rather than assuming every captured frame is safe to serve.
screenshot_edited reelay-media-delivery The beautified, redaction-burned-in derivative (Section 13). Access-controlled exactly like poster.
rendition master object reelay-media-delivery Not itself a media_assets row — it is a renditions row (Section 5.4.4), stored under the renditions/ prefix. Redaction burn-in (Sections 11.7 and 22.2) always completes before an object is written here, so nothing in this bucket is ever an unredacted frame.
cursor telemetry blob reelay-media-restricted Not itself a media_assets row (it lives in cursor_telemetry_blobs, Section 5.4.6), but stored under the same never-publicly-readable bucket policy.

Path convention within each bucket (both buckets use {workspaceId}/{videoId}/... as the leading path segment so per-workspace lifecycle policies and cost attribution work identically in either bucket):

reelay-media-restricted/
├── originals/{workspaceId}/{videoId}/{recordingId}.{ext}              # kind = 'original' (Section 11.1)
├── unredacted/{workspaceId}/{videoId}/{mediaAssetId}.{ext}            # kind = 'redaction_unredacted_original'
└── telemetry/{workspaceId}/{videoId}/{recordingId}.cbor.gz            # cursor_telemetry_blobs (Section 5.4.6)

reelay-media-delivery/
├── renditions/{workspaceId}/{videoId}/{renditionId}/{file}            # kind = 'rendition'
├── exports/{workspaceId}/{videoId}/{exportId}.{mp4|gif|webm}          # kind = 'export' (Section 9, item list)
├── posters/{workspaceId}/{videoId}/{keyVersion}/{posterId}.jpg        # kind = 'poster', keyed by playback_key_version (Section 14.1.1)
├── thumbnails/{workspaceId}/{videoId}/{keyVersion}/{thumbId}.jpg      # kind = 'thumbnail', same keying
├── screenshots/{workspaceId}/{screenshotId}.{png|jpg}                 # kind = 'screenshot_original' | 'screenshot_edited' (Section 13)
└── exports-tmp/{jobId}/                                               # worker scratch space, lifecycle-expired after 24h, never CDN-fronted

Per-environment naming follows the same suffix convention as the rest of Section 3.12: each non-production environment gets its own bucket pair, reelay-media-restricted-<env> and reelay-media-delivery-<env> (for example reelay-media-restricted-staging and reelay-media-delivery-preview-<pr>), each with the same access-control shape as its production counterpart. The bare names reelay-media-restricted and reelay-media-delivery above are the production pair.

3.12 Environment topology #

Environment Trigger Database Object storage buckets (restricted / delivery, Section 3.11) Streaming provider mode Purpose
Local Developer machine Local PostgreSQL 17 (Docker) or a shared dev Postgres branch Local MinIO (S3-compatible) two-bucket pair, or a dedicated reelay-media-restricted-local-<dev> / reelay-media-delivery-local-<dev> R2 bucket pair Mux test mode (test API keys, sandboxed assets) Day-to-day development
Preview Every PR opened against main Ephemeral database branch (Section 26.4 documents the branching strategy), auto-destroyed on PR close reelay-media-restricted-preview-<pr> / reelay-media-delivery-preview-<pr>, lifecycle-expired 7 days after last write Mux test mode Reviewable, isolated PR environments (Vercel preview for apps/web, a Fly.io preview app for apps/api/apps/worker)
Staging Merge to main Persistent staging database, periodically refreshed from a sanitized production snapshot (Section 24.7) reelay-media-restricted-staging / reelay-media-delivery-staging Mux test mode Pre-production integration testing, the target for E2E (Section 25.4)
Production Manual promotion / tagged release (Section 26.5) Production PostgreSQL 17 primary + read replica (Section 24.6) reelay-media-restricted / reelay-media-delivery Mux live mode Live traffic

3.13 Build-vs-buy table #

Capability Build or buy Justification
Adaptive-bitrate transcoding & streaming Buy — Mux Section 3.9. Not a differentiator; operationally heavy; Mux's webhook + signed-playback model fits directly.
Transcription & diarization Buy — Deepgram (Section 1.2) Speech-to-text at production accuracy is a solved, commoditized problem; building an in-house ASR model is a multi-year investment with no product differentiation upside.
Chapter/summary/title generation Buy (LLM API) — Claude via Anthropic API (Section 1.2) Structured extraction from a transcript is well within general-purpose LLM capability; a fine-tuned in-house model would need a training-data pipeline this product has no early source for.
EDL rendering & redaction burn-in Build — FFmpeg workers (Section 3.9) This IS the product differentiator (Section 2.2); no vendor offers "render our specific zoom/pan/background compositing algorithm."
Auto-edit decision engine (zoom/motion/framing) Build — Section 10 The core differentiator; must be fully specified and owned in-house.
Object storage Buy — R2/S3/B2 (Section 1.2) Building durable, geo-replicated blob storage from scratch is never justified at this stage.
Email delivery Buy — Resend (Section 1.2) Deliverability infrastructure (SPF/DKIM/DMARC reputation management, bounce handling) is a specialized, non-differentiating operational burden.
Payments & subscription billing Buy — Stripe (Section 1.2) PCI compliance scope, dunning logic, and tax handling are far cheaper to buy than build; Section 21.9 relies on Stripe Tax directly.
Error tracking & APM Buy — Sentry (Section 1.2) Cross-runtime (browser/Node/Electron) error aggregation with source maps is a mature, commoditized category.
Authentication (session/password/OAuth) Build — Section 6 Core enough to user trust and data-model integration (workspace membership, roles) that an off-the-shelf auth-as-a-service would fight the schema more than it would save; Argon2id + session tables are not hard to build correctly and keep full control over the permission model in Section 6.
Rate limiting & queueing infrastructure Build on top of bought primitives — BullMQ + Redis (Section 3.6) The queue topology (Section 3.6) is product-specific; the underlying broker (Redis) is bought as managed infrastructure (a managed Redis provider in production), not self-hosted from scratch.
Embeddable player Buildpackages/player (Section 15) The strict ≤8KB/≤20KB budget (Section 15.1) and Shadow-DOM isolation requirement rule out any general-purpose video-player library, all of which carry more weight than the budget allows.

3.14 Why Fastify for apps/api #

Fastify 5 (rather than Next.js Route Handlers or Express) hosts the API for three concrete reasons that matter at this product's request shape: (1) schema-based request/response validation and serialization are first-class and fast — Fastify compiles JSON-schema-derived serializers ahead of time, which matters because every response passes through the shared Zod schemas (Section 4.5) at high frequency on hot paths like /v1/collect; (2) a plugin encapsulation model that maps cleanly onto per-resource route modules (Section 4.3's apps/api/src/routes/ layout) without the global middleware-ordering foot-guns Express has; (3) apps/api needs to run as a standalone long-lived process independent of any web framework's request lifecycle assumptions, since it is deployed separately from apps/web (Section 3.12) and must be reachable by apps/desktop and public API consumers with nothing Next.js-specific in the request path.

3.15 Autoscaling & capacity policy #

Deployment Scale trigger Min instances Max instances Notes
apps/web (Vercel) Platform-managed (serverless/edge) N/A N/A Vercel scales Next.js request handling automatically; no manual policy needed.
apps/api (Fly.io) CPU > 70% sustained 2 min, or p95 request latency > 400 ms sustained 2 min 2 (production), 1 (staging) 10 Fronted by Fly.io's built-in load balancing; health check hits GET /v1/health every 10s.
apps/worker — media group (ingest/transcode/render/export) Redis queue depth for video-render or video-export > 50 jobs sustained 3 min 2 12 CPU-bound FFmpeg work; scale is the primary lever for render-latency SLOs (Section 27.4).
apps/worker — AI group (transcription/ai-metadata) Queue depth > 100 jobs sustained 3 min 1 6 I/O-bound (waiting on vendor APIs), so fewer instances handle more concurrent jobs each (concurrency from Section 3.6 is per-instance).
apps/worker — platform group (rollup/deletion/email/webhook/screenshot) Fixed 1 3 Low, steady volume; scale-out is a manual capacity decision, not automatic.

Autoscaling is a deploy-configuration concern, not application code — no app queries its own instance count. Scale-out safety is guaranteed by every job handler being idempotent (Section 3.6, 4.2), so adding workers never risks double-processing.

3.16 Workspace and pipeline configuration #

pnpm-workspace.yaml and the root Turborepo pipeline are the two files that make the monorepo buildable as a graph rather than a flat script list:

# pnpm-workspace.yaml
packages:
  - "apps/*"
  - "packages/*"
// turbo.json
{
  "$schema": "https://turbo.build/schema.json",
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**", ".next/**", "!.next/cache/**"]
    },
    "dev": { "cache": false, "persistent": true },
    "lint": { "dependsOn": ["^build"] },
    "typecheck": { "dependsOn": ["^build"] },
    "test": { "dependsOn": ["^build"], "outputs": ["coverage/**"] },
    "test:e2e": { "dependsOn": ["build"] }
  }
}

^build expresses the dependency direction fixed in Section 3.2 (apps depend on packages, never the reverse): packages/shared and packages/db build before any app that imports them, and Turborepo's remote cache means an unchanged package is never rebuilt across CI runs, keeping PR checks fast regardless of monorepo size.

3.17 Local development quick reference #

Local development (Section 3.12's "Local" row) runs Postgres, Redis, and an S3-compatible store as containers so a new engineer can be productive without provisioning cloud resources:

# docker-compose.yml (repo root, local dev only — never used in staging/production)
services:
  postgres:
    image: postgres:17
    environment:
      POSTGRES_DB: reelay_dev
      POSTGRES_PASSWORD: reelay_dev
    ports: ["5432:5432"]
    volumes: ["pg-data:/var/lib/postgresql/data"]
  redis:
    image: redis:8
    ports: ["6379:6379"]
  minio:
    image: minio/minio
    command: server /data --console-address ":9001"
    environment:
      MINIO_ROOT_USER: reelay_dev
      MINIO_ROOT_PASSWORD: reelay_dev_secret
    ports: ["9000:9000", "9001:9001"]
    volumes: ["minio-data:/data"]
volumes:
  pg-data:
  minio-data:

STREAMING_PROVIDER and TRANSCRIPTION_PROVIDER (Section 26.3) still point at real vendor sandboxes in local development (Mux test mode, Deepgram's standard API) rather than being mocked, so the capture-to-share pipeline is exercised end-to-end even on a laptop; only object storage and the control-plane datastores are containerized locally.

4. Conventions, Standards & Code Organization #

4.1 Naming conventions #

Domain Convention Example
Database tables snake_case, plural videos, workspace_members, share_link_recipients
Database columns snake_case created_at, duration_ms, storage_key
TypeScript variables/functions camelCase getVideoById, durationMs
TypeScript types/interfaces/components PascalCase VideoRecord, EditDecisionList, ShareLinkCard
TypeScript constants SCREAMING_SNAKE_CASE MAX_UPLOAD_PART_BYTES, DEFAULT_ZOOM_LEVEL
API JSON keys camelCase (transformed at the API boundary from snake_case DB columns) { "durationMs": 4200, "createdAt": "..." }
API paths kebab-case, plural nouns /v1/videos, /v1/share-links, /v1/workspace-members
Files (non-component) kebab-case.ts video-ingest.service.ts, edl-renderer.ts
Files (React components) PascalCase.tsx VideoCard.tsx, TimelineEditor.tsx
Environment variables SCREAMING_SNAKE_CASE DATABASE_URL, STREAMING_PROVIDER, MUX_TOKEN_SECRET
Queue job names dot.case video.transcode.request, video.render.compose, ai.chapters.generate

4.2 Queue job naming detail #

Job names follow the <entity>.<operation>.<qualifier> pattern in dot.case (Section 4.1). The authoritative list of every queue's exact job-name strings is owned by Sections 9.3.1 and 9.8, not by Section 3.6's queue table — Section 3.6 owns concurrency, retry policy, backoff, and dead-letter topology only. The job's BullMQ id (used for idempotency, Section 3.6) is always <entity-type>:<entity-id>:<operation>:<preset-or-schema-version> — for example video:vid_2f8k...:render:compose-v3 or transcript:vid_2f8k...:transcribe:deepgram-nova-3. Re-enqueuing the same job id before the previous run completes is a safe no-op (BullMQ rejects the duplicate); re-enqueuing after completion with the same id is also a no-op unless removeOnComplete has evicted the record, in which case the handler itself re-checks entity state before doing any work (defense in depth beyond BullMQ's own dedup).

4.3 Folder structure inside each app/package #

apps/web/
├── app/                       # Next.js App Router routes
│   ├── (marketing)/            # public marketing pages, no auth
│   ├── (dashboard)/            # authenticated app shell: library, editor, settings
│   ├── (watch)/[shareSlug]/    # public watch page, no auth required
│   └── api/                    # Next.js route handlers ONLY for BFF concerns (auth callbacks,
│                                #  image optimization proxy) — never business logic; that's apps/api
├── components/                 # app-local React components not shared via packages/ui
├── lib/                        # app-local helpers (fetch client wrappers, RSC data loaders)
└── styles/

apps/api/
├── src/
│   ├── routes/                 # one file per resource, matches Section 7's endpoint groups
│   ├── plugins/                # Fastify plugins: auth, rate-limit, error-envelope, request-id
│   ├── services/                # business logic, one file per domain concern
│   ├── lib/                     # infra glue: email transport, streaming provider wiring
│   └── webhooks/                 # inbound webhook receivers (Mux, Deepgram, Stripe)
└── test/

apps/worker/
├── src/
│   ├── queues/                  # one subfolder per queue from Section 3.6, each with its processor
│   ├── jobs/                     # job handler implementations, imported by queues/
│   ├── render/                    # FFmpeg pipeline: EDL compositing (Section 10, 11), redaction burn-in (Sections 11.7, 22.2)
│   └── lib/
└── test/

apps/desktop/
├── src/
│   ├── main/                    # Electron main process: window mgmt, native capture hooks
│   ├── renderer/                 # Electron renderer: capture UI (minimal, imports packages/ui sparingly)
│   └── preload/                  # contextBridge-exposed APIs, strictly typed
└── native/                        # platform-specific native modules (ScreenCaptureKit/WASAPI bindings)

packages/player/
├── src/
│   ├── loader.ts                 # the ≤8KB embed.js entry point (Section 15.2)
│   ├── core/                      # the lazy-loaded player core (Section 15.3)
│   └── shadow-dom/                 # Shadow DOM mounting + scoped styles
└── test/

packages/db/
├── src/
│   ├── schema/                   # one file per table group (e.g. identity.ts, video.ts, billing.ts)
│   ├── client.ts                  # Drizzle client factory
│   └── migrations/                 # drizzle-kit generated SQL migrations
└── seed/

packages/shared/
├── src/
│   ├── schemas/                   # Zod schemas — the single source of truth (Section 4.5)
│   ├── types/                      # types derived from schemas via z.infer, plus pure types
│   ├── constants/                   # domains, plan limits, queue names, timing constants (Section 10)
│   ├── streaming/                    # StreamingProvider interface + implementations (Section 3.10)
│   ├── transcription/                 # TranscriptionProvider interface + implementations (Section 12.3)
│   ├── ai/                             # LlmProvider interface + prompts (Section 12.6)
│   ├── billing/                         # BillingProvider interface + implementations (Section 21.3)
│   ├── errors/                           # typed domain error classes (Section 4.4)
│   └── observability/                     # logging + Sentry init shared across apps (Section 4.6)
└── test/

packages/ui/
├── src/
│   ├── primitives/                # buttons, inputs, dialogs — design-system base layer
│   ├── composed/                   # VideoCard, ShareLinkTable, AnalyticsChart, etc.
│   └── editor/                      # timeline editor-specific components (Section 11)
└── test/

4.4 The Result/error-handling pattern #

No thrown strings, ever. Every function that can fail in an expected, domain-meaningful way returns a Result<T, E> rather than throwing; exceptions are reserved for truly unexpected/programmer-error conditions (which are caught at the process boundary and reported to Sentry, Section 24.3, never handled locally).

// packages/shared/src/errors/result.ts

export type Result<T, E extends DomainError = DomainError> =
  | { ok: true; value: T }
  | { ok: false; error: E };

export function ok<T>(value: T): Result<T, never> {
  return { ok: true, value };
}

export function err<E extends DomainError>(error: E): Result<never, E> {
  return { ok: false, error };
}

// packages/shared/src/errors/domain-error.ts

export abstract class DomainError extends Error {
  abstract readonly code: string;        // maps 1:1 to the API error envelope's `code` (Section 7.6)
  abstract readonly httpStatus: number;  // maps to the HTTP status the API route returns
  readonly details?: Array<{ field: string; issue: string }>;

  constructor(message: string, details?: Array<{ field: string; issue: string }>) {
    super(message);
    this.details = details;
  }
}

export class VideoNotFoundError extends DomainError {
  readonly code = 'video_not_found';
  readonly httpStatus = 404;
  constructor(videoId: string) {
    super(`Video ${videoId} was not found or you do not have access to it.`);
  }
}

export class PlanLimitExceededError extends DomainError {
  readonly code = 'plan_limit_exceeded';
  readonly httpStatus = 402;
  constructor(limitName: string, details?: Array<{ field: string; issue: string }>) {
    super(`This action exceeds the ${limitName} limit for your current plan.`, details);
  }
}

export class ValidationError extends DomainError {
  readonly code = 'validation_failed';
  readonly httpStatus = 422;
  constructor(details: Array<{ field: string; issue: string }>) {
    super('One or more fields failed validation.', details);
  }
}

Service functions return Promise<Result<T, SpecificDomainError>>. Route handlers in apps/api do the only translation step: unwrap the Result, and on ok: false, map error.code/error.httpStatus /error.details directly onto the error envelope owned by Section 7.6:

// apps/api/src/plugins/error-envelope.ts (sketch of the mapping, not the full plugin)
if (!result.ok) {
  return reply.status(result.error.httpStatus).send({
    error: {
      code: result.error.code,
      message: result.error.message,
      details: result.error.details ?? [],
      requestId: request.id,
    },
  });
}

Every new failure mode introduced anywhere in the codebase gets its own DomainError subclass with a stable code; reusing a generic code string across unrelated failures is not allowed — the code is the API's stable contract with public-API consumers (Section 7.6).

4.5 Shared Zod validation strategy #

One schema per domain object, defined once in packages/shared/src/schemas/, and reused in three places without redefinition: the API route's request/response validation, the client-side form validation (dashboard and editor), and the generated public API reference (Section 7.11 documents docs generation from these same schemas).

// packages/shared/src/schemas/video.ts
import { z } from 'zod';

export const createVideoRequestSchema = z.object({
  title: z.string().min(1).max(200).optional(),
  folderId: z.string().startsWith('fld_').optional(),
});
export type CreateVideoRequest = z.infer<typeof createVideoRequestSchema>;

export const videoResponseSchema = z.object({
  id: z.string().startsWith('vid_'),
  title: z.string(),
  durationMs: z.number().int().nonnegative(),
  status: z.enum(['processing', 'ready', 'failed']),
  createdAt: z.string().datetime(),
});
export type VideoResponse = z.infer<typeof videoResponseSchema>;

Rules:

  • API route handlers call .parse() (or .safeParse(), converting a failure into a ValidationError per Section 4.4) on every request body/query/params object — never accept an unvalidated any.
  • Client forms (dashboard, editor) import the exact same request schema for client-side validation before submit, guaranteeing the client never sends something the server would reject for a different reason than the server states.
  • Response schemas are used both to type API client calls (z.infer becomes the TypeScript return type) and, in Section 7.11, to generate the public-facing API reference — so the reference can never drift from the actual validated shape.
  • A schema is never duplicated by hand in a second location. If apps/web needs a slightly different shape (e.g., a subset of fields for a lightweight form), it derives it with .pick()/.omit()/ .partial() from the canonical schema, never by writing a parallel schema from scratch.

4.6 Logging conventions #

All logs are structured JSON, one object per line, written to stdout (collected by the hosting platform's log pipeline, Section 24.2). Required fields on every log line:

{
  "timestamp": "2026-08-19T14:03:22.104Z",
  "level": "info",
  "service": "apps/api",
  "requestId": "req_9fK2...",
  "workspaceId": "ws_3mQ1...",
  "userId": "usr_7bN0...",
  "message": "video.created",
  "durationMs": 42
}
Field Required Notes
timestamp Always ISO 8601 UTC, matches the API's timestamp convention (Section 5's canonical ID/timestamp rules)
level Always debug | info | warn | error | fatal
service Always The app/worker name, e.g. apps/api, apps/worker:transcode
requestId On any request-scoped log Matches the API error envelope's requestId (Section 7.6) for correlation
workspaceId / userId When known Omitted for pre-auth logs (e.g., signup attempt before account exists)
message Always A stable, greppable event name in dot.case (mirrors queue job naming, Section 4.2), e.g. video.created, share_link.visibility_changed
Structured error fields (errorCode, stack) On error/fatal errorCode matches the DomainError.code (Section 4.4) when applicable

Never logged, under any circumstance: raw passwords, session tokens, API key secrets (only the stored prefix, Section 6.8, may be logged), OAuth tokens, Stripe raw card data (never touches Reelay infrastructure at all — Stripe Checkout/Elements handle this, Section 21.4), full email content bodies, unredacted cursor telemetry payloads, or the contents of the reelay-media-restricted bucket (Section 3.11). A log statement that would include any of these must log a redacted placeholder ("token": "[REDACTED]") or the resource's stable ID only.

4.7 Commit and branch conventions #

  • Commit messages follow Conventional Commits: <type>(<scope>): <summary>, types limited to feat, fix, chore, refactor, test, docs, perf, ci. Scope is the package/app name (feat(api): add share-link password support).
  • Branch naming: <type>/<short-slug>, e.g. feat/share-link-password, fix/edl-render-race.
  • No direct commits to main. All work lands via PR.

4.8 PR requirements #

Every PR must: pass CI (lint, typecheck, unit/integration tests, build — Section 25), include a test for any new behavior (Section 25.1's coverage expectations), update the relevant section of any developer-facing docs the team maintains in their own repository if the change alters a documented contract, and be reviewed and approved by at least one other engineer before merge. PRs that touch packages/player additionally require the CI size-limit check (Section 15.1's budget, enforced per Section 25.5) to pass — a budget regression blocks merge regardless of approval.

4.9 Lint/format toolchain #

ESLint (flat config) with @typescript-eslint strict rules, Prettier for formatting (invoked via an ESLint integration so there is exactly one source of truth for style, not two competing tools). Pre-commit hook (via a lightweight git-hooks manager) runs lint-staged: ESLint --fix and Prettier on staged files only. CI runs the full, unfiltered lint and format-check across the repo on every PR.

4.10 TypeScript compiler strictness #

Every package and app extends a shared root config:

// tsconfig.base.json
{
  "compilerOptions": {
    "target": "ES2023",
    "lib": ["ES2023", "DOM", "DOM.Iterable"],
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "noImplicitOverride": true,
    "exactOptionalPropertyTypes": true,
    "noFallthroughCasesInSwitch": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noPropertyAccessFromIndexSignature": true,
    "verbatimModuleSyntax": true,
    "isolatedModules": true,
    "skipLibCheck": true,
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true
  }
}

packages/player additionally sets "types": [] in its own tsconfig.json (no ambient DOM-library globals beyond what it explicitly needs) to keep a compile-time guard against accidentally depending on a framework's global type augmentations.

4.11 Import ordering and path aliases #

Import order (enforced by an ESLint import-order rule, auto-fixed): (1) Node builtins, (2) external packages, (3) @reelay/* workspace packages, (4) absolute app-local imports via path alias, (5) relative imports, each group alphabetized and blank-line-separated. Path aliases per app, configured in each tsconfig.json's paths and mirrored in the bundler config:

App Alias Resolves to
apps/web @/* apps/web/*
apps/api @/* apps/api/src/*
apps/worker @/* apps/worker/src/*
Any app @reelay/db packages/db/src
Any app @reelay/shared packages/shared/src
Any app (except packages/player) @reelay/ui packages/ui/src

4.12 Testing file convention #

Unit and integration tests (Vitest 4.x) live beside the code they test as <name>.test.ts, or in a sibling test/ directory when testing cross-module integration within a package. E2E tests (Playwright 1.62.x) live in apps/web/e2e/ and apps/api/e2e/, named <flow-name>.spec.ts. Test naming: describe(<unit under test>)it(<behavior, stated as an assertion>), e.g. describe('createShareLink')it('rejects an expiry date in the past'). Full testing strategy, coverage targets, and CI gating are owned by Section 25; this section only fixes the file-location and naming convention.

4.13 Do-not-do-this list #

Anti-pattern Why it breaks this codebase
Importing a Mux/Deepgram/Anthropic/Stripe SDK outside its designated interface implementation file (Section 3.10, 12.3, 12.6, 21.3) Breaks the vendor-swappable interface guarantee promised in Section 1.2; a future vendor swap becomes a codebase-wide grep-and-replace instead of a single new file.
Throwing a string or a plain Error from service-layer code Breaks the Result/DomainError pattern (Section 4.4); the API error envelope (Section 7.6) has no code to map, and the failure becomes a generic 500.
Writing a second Zod schema for a shape that already has one, instead of deriving with .pick()/.omit()/.partial() Breaks the single-source-of-truth validation strategy (Section 4.5); the two schemas will drift.
Importing React, Next.js, or packages/ui into packages/player Breaks the ≤20KB gzipped budget (Section 15.1) enforced in CI; the build will fail the size-limit check.
Storing media bytes, large blobs, or unbounded event history directly in a Postgres column Breaks the control-plane/data-plane split (Section 3.4, 3.5); use object storage and a pointer column instead.
Performing long-running media work (transcode, render, transcription) synchronously inside an apps/api route handler Breaks the request-path latency budget (Section 27) and the queue-based architecture (Section 3.6); enqueue a job instead.
Using offset-based pagination (?page=2&perPage=25) anywhere in the API Breaks the cursor-pagination contract fixed for every list endpoint (Section 7.7); offset pagination is not implemented anywhere and must not be introduced.
Logging a raw token, password, or the contents of the reelay-media-restricted bucket Breaks the logging redaction rules (Section 4.6) and the redaction security property (Sections 11.7, 22.2 and 22.6).
Granting workspace access by matching a user's email domain instead of an explicit workspace_members row Breaks the non-implicit-membership invariant (Section 6.4) that is the specific design choice making SSO/SCIM additive later (Section 6.10); implicit domain membership cannot be retrofitted without a security review of every existing workspace.
Mutating an original recording or any object in the reelay-media-restricted bucket in place Breaks the non-destructive editing invariant (Section 11.1) and the redaction security property (Sections 11.7 and 22.2); all edits are EDL operations re-rendered from the untouched source.
Adding a new plan limit, role, or error-envelope field without updating its owning section (21, 6, or 7.6 respectively) and cross-referencing from elsewhere Breaks the single-owner convention this entire document depends on (Section 1.1); duplicated, drifting definitions are exactly what this convention exists to prevent.

4.14 Worked example — request handling end to end #

The following illustrates how Sections 4.4–4.6 compose in a real route, using video creation as the example (the full endpoint contract is owned by Section 7):

// apps/api/src/routes/videos.ts
import { createVideoRequestSchema, videoResponseSchema } from '@reelay/shared/schemas/video';
import { createVideo } from '@/services/video.service';

export async function registerVideoRoutes(app: FastifyInstance) {
  app.post('/v1/videos', async (request, reply) => {
    const parsed = createVideoRequestSchema.safeParse(request.body);
    if (!parsed.success) {
      const error = new ValidationError(
        parsed.error.issues.map((i) => ({ field: i.path.join('.'), issue: i.code })),
      );
      return reply.status(error.httpStatus).send(toErrorEnvelope(error, request.id));
    }

    const result = await createVideo({
      workspaceId: request.auth.workspaceId, // set by the auth plugin, Section 6
      userId: request.auth.userId,
      input: parsed.data,
    });

    if (!result.ok) {
      return reply.status(result.error.httpStatus).send(toErrorEnvelope(result.error, request.id));
    }

    request.log.info({ message: 'video.created', workspaceId: request.auth.workspaceId, videoId: result.value.id });
    return reply.status(201).send({ data: videoResponseSchema.parse(result.value), meta: null });
  });
}
// apps/api/src/services/video.service.ts
export async function createVideo(input: CreateVideoInput): Promise<Result<VideoRecord, DomainError>> {
  const usage = await getWorkspaceUsage(input.workspaceId);
  if (usage.videoCount >= usage.planLimits.libraryVideoCap) {
    return err(new PlanLimitExceededError('library_video_cap'));
  }
  const video = await db.insert(videos).values({ /* ... */ }).returning();
  return ok(video[0]);
}

This shape — parse with the shared schema, call a service that returns Result, translate exactly once at the route boundary — is the only accepted pattern for a mutating endpoint anywhere in apps/api.

4.15 Boolean and enum naming #

  • Boolean columns and fields are prefixed is_/has_/can_/requires_ (is_active, has_password, can_download, requires_email_to_watch) — never a bare adjective (active is ambiguous between a boolean and a status enum).
  • Enum-like columns are snake_case string enums with a fixed, documented value set (never a raw boolean pair standing in for a 3+-state concept), e.g. videos.status is 'processing' | 'ready' | 'failed', not two booleans is_processing/is_failed.
  • TypeScript mirrors this with string-literal union types, never numeric/const enums (numeric enums serialize ambiguously to JSON and complicate the API contract in Section 7).

4.16 Package script conventions #

Every package/app exposes the same script names so Turborepo's pipeline (Section 3.16) and CI (Section 25) can invoke them uniformly without per-package special-casing:

Script Purpose
dev Local development server / watch mode
build Production build
lint ESLint check (no --fix in CI)
typecheck tsc --noEmit
test Vitest unit/integration run
test:e2e Playwright run (only defined in apps/web and apps/api)
test:coverage Vitest with coverage report, used by the CI coverage gate (Section 25.2)

4.17 Code review checklist #

Reviewers check, in addition to correctness: no thrown strings (Section 4.4), no duplicated Zod schemas (Section 4.5), no logging of sensitive fields (Section 4.6), no new vendor SDK import outside its interface file (Section 3.10, 4.13), no offset pagination introduced (Section 4.13), any new error path has a DomainError subclass with a stable code, and any new plan-gated behavior checks the limit server-side (Section 21.2's server-side-enforcement rule) rather than trusting a client flag.

4.18 Environment variable naming subcategories #

Section 26.3 owns the exhaustive environment variable reference; this section fixes the naming subcategory prefixes so a new variable is unambiguous about what it configures on sight:

Prefix Category Example
DATABASE_* PostgreSQL connection DATABASE_URL, DATABASE_POOL_MAX
REDIS_* Redis connection REDIS_URL
STORAGE_* Object storage (vendor-neutral, Section 3.11) STORAGE_BUCKET_RESTRICTED, STORAGE_BUCKET_DELIVERY, STORAGE_ENDPOINT, STORAGE_REGION
STREAMING_* / MUX_* Streaming provider selector and vendor-specific credentials (Section 3.10) STREAMING_PROVIDER, MUX_TOKEN_ID, MUX_TOKEN_SECRET, MUX_WEBHOOK_SECRET
TRANSCRIPTION_* / DEEPGRAM_* Transcription provider selector and credentials (Section 12.3) TRANSCRIPTION_PROVIDER, DEEPGRAM_API_KEY
LLM_* / ANTHROPIC_* LLM provider selector and credentials (Section 12.6) LLM_PROVIDER, LLM_MODEL, ANTHROPIC_API_KEY
STRIPE_* / BILLING_* Billing provider selector and credentials (Section 21.3) BILLING_PROVIDER, STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET
EMAIL_* / RESEND_* Email provider selector and credentials (Section 20.2) EMAIL_PROVIDER, RESEND_API_KEY
AUTH_* Session/auth secrets (Section 6) AUTH_SESSION_SECRET, AUTH_GOOGLE_CLIENT_ID
SENTRY_* Error tracking (Section 24.3) SENTRY_DSN
PRIMARY_REGION, NODE_ENV, PORT Deployment topology, unprefixed because they are runtime-universal, not vendor- or domain-scoped

A variable that configures a vendor-swappable interface (streaming, transcription, LLM, billing, email) always includes both a *_PROVIDER selector variable and the selected vendor's own prefixed credentials — never a bare credential with no accompanying selector, since that would hardcode the vendor choice outside the interface pattern in Section 3.10. The five *_PROVIDER selectors, named explicitly here so Section 26.3 can reference this list rather than re-deriving it: STREAMING_PROVIDER, TRANSCRIPTION_PROVIDER, LLM_PROVIDER, BILLING_PROVIDER, EMAIL_PROVIDER — one per vendor-swappable interface in Section 1.2's decision table, each defaulting to that table's default vendor. Object storage is deliberately not on this list: it has no *_PROVIDER selector because no code branches on which S3-compatible vendor is active (Section 1.2, decision 6) — swapping it is a credential/endpoint change, not an interface-selection change.

5. Data Model & Database Schema #

This section is the canonical source for every table, column, type, constraint, and index in the system. PostgreSQL 17 is the system of record (versions per Section 3). Every other section that mentions a table, column, or constraint is describing behavior built on top of what is defined here — if a conflict is ever perceived, this section wins.

All DDL below is written as it would appear inside Drizzle ORM migrations (managed with drizzle-kit; see Section 5.7) and is directly executable against a PostgreSQL 17 database. Table and column names follow snake_case, tables are plural, per the conventions in Section 4.

5.1 Identifier Policy #

5.1.1 Primary keys: UUIDv7, generated application-side #

Every table's primary key column is id uuid PRIMARY KEY. IDs are never generated by the database (no gen_random_uuid(), no DEFAULT). They are generated in application code using a UUIDv7 implementation before the row is constructed. This is a firm architectural rule, for three reasons:

  1. Time-sortable: UUIDv7 embeds a 48-bit millisecond Unix timestamp in the high bits, so primary keys sort chronologically. B-tree indexes on id stay append-mostly (no random-page inserts the way UUIDv4 causes), which keeps write amplification and index bloat low even at the 100k-workspace scale modeled in Section 5.6.
  2. No DB round-trip for FK graphs: application code that needs to construct a video, its initial recording row, and its media asset row in one transaction can generate all three IDs up front and build the full object graph in memory before touching Postgres.
  3. No coordination required: multiple app server instances, worker processes, and the desktop app (which stages a recording ID before the row ever reaches the server, see Section 9) can all mint IDs independently with no collision risk and no central sequence.

packages/shared exports the generator used everywhere:

// packages/shared/src/id/uuidv7.ts
import { randomBytes } from "node:crypto";

/**
 * Generates a UUIDv7 per draft-ietf-uuidrev-rfc4122bis.
 * Layout: 48-bit unix_ts_ms | 4-bit version (0111) | 12-bit rand_a |
 *         2-bit variant (10) | 62-bit rand_b
 */
export function uuidv7(): string {
  const unixTsMs = BigInt(Date.now());
  const rand = randomBytes(10);

  const bytes = Buffer.alloc(16);
  bytes.writeUIntBE(Number(unixTsMs >> 16n), 0, 4);
  bytes.writeUIntBE(Number(unixTsMs & 0xffffn), 4, 2);

  // version (0111) in top 4 bits of byte 6, rand_a in remaining 12 bits
  bytes[6] = 0x70 | (rand[0] & 0x0f);
  bytes[7] = rand[1];

  // variant (10) in top 2 bits of byte 8, rand_b fills the remaining 62 bits
  bytes[8] = 0x80 | (rand[2] & 0x3f);
  bytes[9] = rand[3];
  rand.copy(bytes, 10, 4, 10);

  const hex = bytes.toString("hex");
  return [
    hex.slice(0, 8),
    hex.slice(8, 12),
    hex.slice(12, 16),
    hex.slice(16, 20),
    hex.slice(20, 32),
  ].join("-");
}

Every Drizzle insert helper sets id explicitly:

// packages/db/src/helpers/insert-with-id.ts
import { uuidv7 } from "@reelay/shared";

export function withId<T extends Record<string, unknown>>(row: T): T & { id: string } {
  return { id: uuidv7(), ...row };
}

5.1.2 Public prefixed-ID map #

Internal UUIDs are never exposed in API responses, URLs, or logs visible to customers. A public ID is a prefix plus a base58-encoding of the raw 16 bytes of the UUID. Base58 (Bitcoin alphabet, no 0, O, I, l) avoids visually ambiguous characters and needs no URL-escaping.

Entity Prefix Example
users usr_ usr_2NEpo7TZRRrLZSi2U
workspaces ws_ ws_4hy9K8Bv2xQmNc7pR
videos vid_ vid_9pQmXeVg3nR7ktYzL
folders fld_ fld_6WbT2yEqR8mKpXd4
share_links lnk_ lnk_HqT9m2XeVgR7ktZL
comments cmt_ cmt_3nR7ktYzLQpM9xVe
video_view_events / analytics events evt_ evt_XeVg3nR7ktYzLQpM9
api_keys api_ api_9K8Bv2xQmNc7pRhy4
workspace_members mem_ mem_R7ktYzLQpM9xVe3n
workspace_invites inv_ inv_M9xVe3nR7ktYzLQp
screenshots scr_ scr_ZLQpM9xVe3nR7ktY
ctas cta_ cta_pM9xVe3nR7ktYzLQ
custom_domains dom_ dom_e3nR7ktYzLQpM9xV
webhook_endpoints whe_ whe_9xVe3nR7ktYzLQpM
integration_connections int_ int_ktYzLQpM9xVe3nR7
upload_sessions ups_ ups_4tRqLQpM9xVe3nR7

Any entity not listed here (e.g. recordings, renditions, transcript_segments) is an internal implementation detail never referenced directly by a public ID; it is always addressed through its owning videos row. upload_sessions is the one exception among pipeline-internal tables: it is public-ID-bearing because the client (browser, desktop app) holds a direct reference to it across the entire multipart upload lifecycle, including across app restarts (Section 9), so it needs a stable, URL-safe, non-guessable handle exactly like any other client-addressed resource — see 5.4.16 for why its id is minted independently rather than borrowed from the recording it belongs to.

Encode/decode lives in packages/shared:

// packages/shared/src/id/public-id.ts
import bs58 from "bs58";

const PREFIXES = {
  user: "usr_", workspace: "ws_", video: "vid_", folder: "fld_",
  shareLink: "lnk_", comment: "cmt_", viewEvent: "evt_", apiKey: "api_",
  workspaceMember: "mem_", workspaceInvite: "inv_", screenshot: "scr_",
  cta: "cta_", customDomain: "dom_", webhookEndpoint: "whe_",
  integrationConnection: "int_", uploadSession: "ups_",
} as const;

export type PublicIdKind = keyof typeof PREFIXES;

export function toPublicId(kind: PublicIdKind, uuid: string): string {
  const bytes = Buffer.from(uuid.replace(/-/g, ""), "hex");
  return PREFIXES[kind] + bs58.encode(bytes);
}

export function fromPublicId(kind: PublicIdKind, publicId: string): string {
  const prefix = PREFIXES[kind];
  if (!publicId.startsWith(prefix)) {
    throw new InvalidPublicIdError(kind, publicId);
  }
  const bytes = bs58.decode(publicId.slice(prefix.length));
  if (bytes.length !== 16) {
    throw new InvalidPublicIdError(kind, publicId);
  }
  const hex = Buffer.from(bytes).toString("hex");
  return [
    hex.slice(0, 8), hex.slice(8, 12), hex.slice(12, 16),
    hex.slice(16, 20), hex.slice(20, 32),
  ].join("-");
}

export class InvalidPublicIdError extends Error {
  constructor(kind: PublicIdKind, value: string) {
    super(`Invalid public id for ${kind}: ${value}`);
    this.name = "InvalidPublicIdError";
  }
}

fromPublicId is called at the API boundary (route param/body parsing, via the Zod transforms described in Section 7) so every handler downstream of that boundary works with raw UUIDs against Postgres. InvalidPublicIdError is caught by the shared error middleware and mapped to the validation_failed error code (Section 7.6) with HTTP 400 — a malformed public ID never reaches a database query.

5.1.3 Share slugs #

share_links.slug is NOT derived from the UUID. It is a 12-character base58 string generated from a cryptographically secure random source (crypto.randomBytes(9) → base58, truncated/padded to exactly 12 chars), independent of the row's primary key, with a unique index. Slugs are never sequential and never derived from any other identifier — a leaked slug must never let an attacker guess adjacent slugs or infer creation order.

5.2 Multi-Tenancy & Workspace Isolation #

Every workspace-scoped table carries a non-nullable workspace_id uuid column with a foreign key to workspaces.id. The isolation rule is absolute: no query against a workspace-scoped table may execute without a workspace_id predicate, and that predicate must be the actor's authorized workspace, resolved through authorize() (Section 6.9) — never taken unchecked from a client-supplied parameter.

To make forgetting this impossible rather than merely discouraged, packages/db exposes a wrapped query builder that requires a workspace scope as a first-class argument:

// packages/db/src/scoped.ts
import { and, eq, type SQL } from "drizzle-orm";
import type { PgTable } from "drizzle-orm/pg-core";
import { db } from "./client";

/**
 * The ONLY sanctioned way to read from a workspace-scoped table. Every call
 * site must supply workspaceId explicitly; there is no overload that omits
 * it. Tables without a `workspace_id` column fail to compile against this
 * helper (TypeScript structural check on `table.workspace_id`).
 */
export function scoped<T extends PgTable & { workspace_id: unknown }>(
  table: T,
  workspaceId: string,
) {
  return {
    where(extra?: SQL) {
      const base = eq(table.workspace_id as never, workspaceId);
      return extra ? and(base, extra) : base;
    },
    query: db.select().from(table),
  };
}

// Usage in a repository function — workspaceId is threaded from the
// authorize() result (Section 6.9), never from req.body/req.query directly.
export async function listVideosForWorkspace(workspaceId: string) {
  return db.select().from(videos).where(scoped(videos, workspaceId).where());
}

A repository-layer ESLint rule (no-unscoped-table-query, enforced in CI per Section 25) statically flags any db.select().from(<workspace-scoped table>) call that does not pass through scoped() or an equivalent named query function reviewed to enforce scoping. This is defense in depth on top of authorize() — the permission check decides whether an action is allowed, scoped()/the lint rule ensure the query that follows cannot silently read across tenants even if a future refactor forgets to re-check.

Tables that are NOT workspace-scoped: users, sessions, oauth_accounts, mfa_credentials, plans (global catalog), feature_flags (global, with optional per-workspace override rows), jobs_audit (system-wide worker telemetry, keyed by job not workspace, though its payload references workspace-scoped entities).

5.3 Soft-Delete Policy #

Per the canonical rule (Section 7.6 owns the API-visible effects; this section owns storage):

  • Soft-delete (deleted_at timestamptz NULL, row retained): users, workspaces, videos, comments.
  • Hard-delete (row physically removed): every other table.
  • Media assets always hard-delete on purge. media_assets and renditions rows, and the underlying object-storage bytes, are permanently and irreversibly removed once the retention or deletion lifecycle reaches the purge stage. The purge lifecycle itself — grace periods, warning emails, the exact trigger conditions, and the worker that executes it — is owned by Section 19. This section only fixes the storage-level fact: there is no "soft-deleted media asset" state to design around; a media_assets row either exists with valid object-storage keys, or it (and its bytes) are gone.

Soft-deleted rows are excluded from all default queries via a deleted_at IS NULL predicate baked into the Drizzle repository layer's default find* helpers (a parallel convention to scoped() in 5.2 — a withoutDeleted() wrapper). Any query that needs to see soft-deleted rows (e.g. an admin-only "restore video" screen, or the deletion-lifecycle worker in Section 19) calls an explicitly named function (findVideoIncludingDeleted) so that intent is visible at the call site and greppable in review.

5.4 Full Schema DDL #

DDL is grouped by domain in dependency order (a table never references a table defined after it, except where a deferred ALTER TABLE ... ADD CONSTRAINT is called out explicitly for a circular reference).

5.4.1 Identity & Sessions #

CREATE TABLE users (
  id                 uuid PRIMARY KEY,
  email              citext NOT NULL,
  email_verified_at  timestamptz NULL,
  password_hash      text NULL,                 -- NULL if the account is OAuth-only
  display_name       text NOT NULL,
  avatar_url         text NULL,
  timezone           text NOT NULL DEFAULT 'UTC',
  locale             text NOT NULL DEFAULT 'en-US',
  last_login_at      timestamptz NULL,
  failed_login_count smallint NOT NULL DEFAULT 0,
  locked_until       timestamptz NULL,           -- brute-force lockout, Section 6.2
  created_at         timestamptz NOT NULL DEFAULT now(),
  updated_at         timestamptz NOT NULL DEFAULT now(),
  deleted_at         timestamptz NULL,

  CONSTRAINT users_display_name_len CHECK (char_length(display_name) BETWEEN 1 AND 120)
);
CREATE UNIQUE INDEX users_email_unique ON users (email) WHERE deleted_at IS NULL;
CREATE INDEX users_email_verified_idx ON users (email) WHERE email_verified_at IS NULL AND deleted_at IS NULL;
-- serves: "find unverified users older than N days" for verification reminder jobs

email uses the citext extension (CREATE EXTENSION IF NOT EXISTS citext; in the first migration) so lookups and the uniqueness constraint are case-insensitive without application-level lowercasing, while the original casing the user typed is preserved for display.

CREATE TABLE oauth_accounts (
  id                 uuid PRIMARY KEY,
  user_id            uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  provider           text NOT NULL,              -- 'google' (only provider at launch)
  provider_account_id text NOT NULL,              -- Google's stable `sub` claim
  provider_email     citext NOT NULL,
  access_token_enc   bytea NULL,                  -- AES-256-GCM encrypted, KMS-wrapped key (Section 22)
  refresh_token_enc  bytea NULL,
  token_expires_at   timestamptz NULL,
  created_at         timestamptz NOT NULL DEFAULT now(),
  updated_at         timestamptz NOT NULL DEFAULT now(),

  CONSTRAINT oauth_accounts_provider_check CHECK (provider IN ('google'))
);
CREATE UNIQUE INDEX oauth_accounts_provider_account_unique
  ON oauth_accounts (provider, provider_account_id);
-- serves: OAuth callback lookup "does this Google sub already have an account"
CREATE INDEX oauth_accounts_user_id_idx ON oauth_accounts (user_id);

ON DELETE CASCADE: an oauth_accounts row has no meaning without its users row; when a user is hard-purged at the end of the deletion lifecycle (Section 19), linked OAuth accounts disappear with it. (Recall users itself is soft-deleted for the ordinary "delete my account" flow — the cascade here only fires at final purge.)

CREATE TABLE mfa_credentials (
  id                 uuid PRIMARY KEY,
  user_id            uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  type               text NOT NULL DEFAULT 'totp',
  secret_enc         bytea NOT NULL,              -- encrypted TOTP seed, Section 22
  recovery_codes_enc bytea NOT NULL,               -- encrypted JSON array of hashed recovery codes
  enrolled_at        timestamptz NOT NULL DEFAULT now(),
  last_used_at       timestamptz NULL,

  CONSTRAINT mfa_credentials_type_check CHECK (type IN ('totp'))
);
CREATE UNIQUE INDEX mfa_credentials_user_unique ON mfa_credentials (user_id);
-- one TOTP enrollment per user at launch; unique index also serves the "is MFA enabled" lookup
CREATE TABLE sessions (
  id                 uuid PRIMARY KEY,
  user_id            uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  token_hash         text NOT NULL,               -- SHA-256 of the opaque cookie token
  user_agent         text NULL,
  ip_address         inet NULL,
  created_at         timestamptz NOT NULL DEFAULT now(),
  last_seen_at       timestamptz NOT NULL DEFAULT now(),
  expires_at         timestamptz NOT NULL,
  revoked_at         timestamptz NULL,
  revoked_reason     text NULL                   -- 'logout' | 'logout_all' | 'password_change' | 'admin'
);
CREATE UNIQUE INDEX sessions_token_hash_unique ON sessions (token_hash);
-- serves: every authenticated request's session lookup (hot path)
CREATE INDEX sessions_user_active_idx ON sessions (user_id) WHERE revoked_at IS NULL;
-- serves: "log out all devices" (bulk revoke by user_id) and the active-sessions settings screen
CREATE INDEX sessions_expires_at_idx ON sessions (expires_at) WHERE revoked_at IS NULL;
-- serves: the hourly sweep job that revokes/deletes rows past expiry

sessions hard-deletes: expired/revoked rows are pruned by a scheduled job (Section 24) after a 30-day retention window kept only for security-audit lookback; the session itself carries no product data worth soft-delete recovery semantics.

5.4.2 Workspaces, Membership & Invites #

CREATE TABLE workspaces (
  id                     uuid PRIMARY KEY,
  name                   text NOT NULL,
  slug                   text NOT NULL,           -- URL-safe, used in dashboard routing only (not public)
  owner_id               uuid NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
  plan_id                uuid NOT NULL REFERENCES plans(id) ON DELETE RESTRICT,
  plan_tier              text NOT NULL DEFAULT 'free',
  -- 'free' | 'pro' | 'business' — a denormalized, always-in-sync cache of plans.code for the
  -- workspace's CURRENT plan (joined through subscriptions -> plans). Kept alongside plan_id
  -- (the normalized FK) specifically because several hot, high-frequency code paths (the
  -- retention scan's per-video plan lookup, Section 19.1.1; storage-quota checks, Section 21)
  -- need the plan tier without a join, and those paths run per-video or per-request at a volume
  -- where the join would be a measurable cost. Updated in the same transaction as any
  -- subscription plan change (Section 21) — there is exactly one writer, the billing subsystem,
  -- and it is never computed ad hoc elsewhere.
  viewer_seats_billable  boolean NOT NULL DEFAULT false,
  storage_used_bytes     bigint NOT NULL DEFAULT 0,   -- cached rollup, reconciled nightly (Section 21)
  lifetime_view_count    bigint NOT NULL DEFAULT 0,
  -- a workspace-level, permanent counter of total historical views, incremented once per purged
  -- video's anonymized final view count (Section 19.3.1) so workspace-level trend reporting
  -- survives individual video deletions. This is the ONLY place that number continues to exist
  -- once a video's own `videos.view_count` and all its `video_view_events`/`video_view_daily`
  -- rows are purged — never decremented, never reconciled against per-video data after the fact.
  scheduled_deletion_at  timestamptz NULL,             -- grace-period deletion, Section 19
  created_at             timestamptz NOT NULL DEFAULT now(),
  updated_at             timestamptz NOT NULL DEFAULT now(),
  deleted_at             timestamptz NULL,

  CONSTRAINT workspaces_name_len CHECK (char_length(name) BETWEEN 1 AND 120),
  CONSTRAINT workspaces_viewer_seats_free CHECK (viewer_seats_billable = false),
  CONSTRAINT workspaces_plan_tier_check CHECK (plan_tier IN ('free', 'pro', 'business'))
);
CREATE UNIQUE INDEX workspaces_slug_unique ON workspaces (slug) WHERE deleted_at IS NULL;
CREATE INDEX workspaces_owner_id_idx ON workspaces (owner_id);
CREATE INDEX workspaces_scheduled_deletion_idx ON workspaces (scheduled_deletion_at)
  WHERE scheduled_deletion_at IS NOT NULL;
-- serves: Section 19's daily sweep for workspaces past their grace period
CREATE INDEX workspaces_plan_tier_idx ON workspaces (plan_tier);
-- serves: the nightly retention scan's per-tier batch query (Section 19.1.1) without a subscriptions join

owner_id ON DELETE RESTRICT: a users row can never be hard-deleted while it still owns a workspace. The account-deletion flow (Section 19) forces an explicit ownership transfer or workspace deletion first — this constraint is the database-level backstop for the one-owner rule in Section 6.7.

workspaces_viewer_seats_free is a CHECK fixed at false: per the locked plan model, viewer seats are never billable at launch. The column exists (rather than being omitted) so the billing engine (Section 21) has a single documented switch to flip if a future plan tier changes this, without a schema migration — but today it is constrained to always be false, so no application code path can accidentally start charging for viewers.

CREATE TABLE workspace_members (
  id           uuid PRIMARY KEY,
  workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
  user_id      uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  role         text NOT NULL,               -- 'owner' | 'admin' | 'member' | 'viewer'
  invited_by   uuid NULL REFERENCES users(id) ON DELETE SET NULL,
  joined_at    timestamptz NOT NULL DEFAULT now(),
  created_at   timestamptz NOT NULL DEFAULT now(),
  updated_at   timestamptz NOT NULL DEFAULT now(),

  CONSTRAINT workspace_members_role_check CHECK (role IN ('owner', 'admin', 'member', 'viewer'))
);
CREATE UNIQUE INDEX workspace_members_workspace_user_unique ON workspace_members (workspace_id, user_id);
-- serves: "is this user a member of this workspace, and with what role" (hottest query in authorize())
CREATE INDEX workspace_members_user_id_idx ON workspace_members (user_id);
-- serves: "list all workspaces this user belongs to" (workspace switcher)
CREATE UNIQUE INDEX workspace_members_one_owner_unique
  ON workspace_members (workspace_id) WHERE role = 'owner';
-- enforces the ONE-owner rule at the database level: a second 'owner' row for the
-- same workspace violates this partial unique index and the insert/update fails

The partial unique index workspace_members_one_owner_unique is the load-bearing constraint for Section 6's "exactly one owner per workspace" rule. Ownership transfer (Section 6.7) is implemented as a single transaction that updates the outgoing owner's role to admin and the incoming member's role to owner — both statements must commit together, or the transaction fails and neither role changes, because at every intermediate instant during a non-atomic sequence the index would either reject a second owner row or (if the demotion ran first) briefly leave zero owners. The application code performs both UPDATEs inside one SERIALIZABLE-isolated transaction to close that window entirely.

CREATE TABLE workspace_invites (
  id             uuid PRIMARY KEY,
  workspace_id   uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
  email          citext NOT NULL,
  role           text NOT NULL,
  invited_by     uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  token_hash     text NOT NULL,             -- SHA-256 of the invite link token
  status         text NOT NULL DEFAULT 'pending',   -- 'pending' | 'accepted' | 'revoked' | 'expired'
  expires_at     timestamptz NOT NULL,
  accepted_at    timestamptz NULL,
  accepted_by    uuid NULL REFERENCES users(id) ON DELETE SET NULL,
  created_at     timestamptz NOT NULL DEFAULT now(),

  CONSTRAINT workspace_invites_role_check CHECK (role IN ('admin', 'member', 'viewer')),
  CONSTRAINT workspace_invites_status_check CHECK (status IN ('pending', 'accepted', 'revoked', 'expired'))
);
CREATE UNIQUE INDEX workspace_invites_token_hash_unique ON workspace_invites (token_hash);
CREATE UNIQUE INDEX workspace_invites_pending_unique
  ON workspace_invites (workspace_id, email) WHERE status = 'pending';
-- prevents duplicate pending invites to the same email; re-inviting first revokes the old row
CREATE INDEX workspace_invites_workspace_idx ON workspace_invites (workspace_id, status);

role on an invite excludes 'owner' by CHECK — ownership is never granted via invite, only via the explicit transfer flow in Section 6.7, which requires the invitee to already be an accepted member.

CREATE TABLE brand_kits (
  id               uuid PRIMARY KEY,
  workspace_id     uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
  logo_media_id    uuid NULL REFERENCES media_assets(id) ON DELETE SET NULL,
  primary_color    text NOT NULL DEFAULT '#4F46E5',
  secondary_color  text NOT NULL DEFAULT '#0EA5E9',
  font_family      text NOT NULL DEFAULT 'Inter',
  watermark_position text NOT NULL DEFAULT 'bottom-right',
  is_enforced      boolean NOT NULL DEFAULT false,  -- Business plan: forces brand kit on all workspace videos
  created_at       timestamptz NOT NULL DEFAULT now(),
  updated_at       timestamptz NOT NULL DEFAULT now(),

  CONSTRAINT brand_kits_primary_color_hex CHECK (primary_color ~* '^#[0-9a-f]{6}$'),
  CONSTRAINT brand_kits_secondary_color_hex CHECK (secondary_color ~* '^#[0-9a-f]{6}$'),
  CONSTRAINT brand_kits_watermark_position_check
    CHECK (watermark_position IN ('bottom-right', 'bottom-left', 'top-right', 'top-left'))
);
CREATE UNIQUE INDEX brand_kits_workspace_unique ON brand_kits (workspace_id);
-- one brand kit per workspace at launch (a workspace-level singleton, not a list)

logo_media_id forward-references media_assets, defined in 5.4.4. Postgres resolves this fine within one migration file because CREATE TABLE statements in a single transaction can reference tables created earlier in the same transaction — media_assets is ordered before brand_kits in the actual migration file (this document presents domains grouped by topic for readability; the generated migration file linearizes strictly by dependency, and drizzle-kit's diff will place them correctly).

5.4.3 Folders & Permission Overrides #

CREATE TABLE folders (
  id           uuid PRIMARY KEY,
  workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
  parent_id    uuid NULL REFERENCES folders(id) ON DELETE CASCADE,
  name         text NOT NULL,
  visibility   text NOT NULL DEFAULT 'private',   -- 'private' | 'workspace'
  created_by   uuid NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
  position     integer NOT NULL DEFAULT 0,   -- manual sort order within parent
  created_at   timestamptz NOT NULL DEFAULT now(),
  updated_at   timestamptz NOT NULL DEFAULT now(),

  CONSTRAINT folders_name_len CHECK (char_length(name) BETWEEN 1 AND 200),
  CONSTRAINT folders_visibility_check CHECK (visibility IN ('private', 'workspace'))
);
CREATE INDEX folders_workspace_parent_idx ON folders (workspace_id, parent_id, position);
-- serves: rendering the folder tree for a workspace, ordered

created_by ON DELETE RESTRICT: a departing member's folders are reassigned (Section 6.7 covers what happens to a removed member's videos and folders) before the users row can ever be purged; this constraint prevents an orphaned created_by.

parent_id ON DELETE CASCADE: deleting a folder deletes its subtree. Videos inside a deleted folder are NOT deleted (see videos.folder_id ON DELETE SET NULL in 5.4.4) — they move to the workspace root, because a folder is an organizational label, not a container that owns video lifecycle.

visibility is the default-deny governor described in Section 6.8.1: workspace means every workspace member gets baseline view access with no explicit grant needed; private (the default for every newly created folder, regardless of the creator's role) means nobody but owner/admin and holders of an explicit folder_permissions grant can see the folder at all. visibility is never inherited. The column is NOT NULL DEFAULT 'private', so every folder row always carries an explicit value of its own, and that value alone governs the folder — there is no "unset" state for an ancestor's setting to fill in. Inheritance applies only to folder_permissions grants, which follow the nearest-ancestor rule in Section 6.8.1. There is deliberately no "inherit-with-no-restriction" fallback: the absence of a grant is a denial, never an implicit allow (Section 6.8.1, and the identical statement of the rule in Section 18.2.1).

CREATE TABLE folder_permissions (
  id                uuid PRIMARY KEY,
  workspace_id      uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
  folder_id         uuid NOT NULL REFERENCES folders(id) ON DELETE CASCADE,
  principal_type    text NOT NULL,               -- 'user' | 'role'
  principal_user_id uuid NULL REFERENCES users(id) ON DELETE CASCADE,
  principal_role    text NULL,                   -- 'owner' | 'admin' | 'member' | 'viewer' (principal_type = 'role' only)
  permission_level  text NOT NULL,               -- 'view' | 'comment' | 'edit' | 'manage'
  granted_by        uuid NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
  created_at        timestamptz NOT NULL DEFAULT now(),

  CONSTRAINT folder_permissions_principal_type_check CHECK (principal_type IN ('user', 'role')),
  CONSTRAINT folder_permissions_principal_consistency CHECK (
    (principal_type = 'user' AND principal_user_id IS NOT NULL AND principal_role IS NULL) OR
    (principal_type = 'role' AND principal_role IS NOT NULL AND principal_user_id IS NULL)
  ),
  CONSTRAINT folder_permissions_principal_role_check CHECK (
    principal_role IS NULL OR principal_role IN ('owner', 'admin', 'member', 'viewer')
  ),
  CONSTRAINT folder_permissions_level_check CHECK (permission_level IN ('view', 'comment', 'edit', 'manage'))
);
CREATE UNIQUE INDEX folder_permissions_folder_user_unique
  ON folder_permissions (folder_id, principal_user_id) WHERE principal_type = 'user';
CREATE UNIQUE INDEX folder_permissions_folder_role_unique
  ON folder_permissions (folder_id, principal_role) WHERE principal_type = 'role';
CREATE INDEX folder_permissions_user_idx ON folder_permissions (principal_user_id) WHERE principal_user_id IS NOT NULL;
-- serves: "what folder overrides apply to this user" when computing effective access
CREATE INDEX folder_permissions_folder_idx ON folder_permissions (folder_id);
-- serves: rendering a folder's full permission list (GET /v1/folders/{id}/permissions)

A grant's principal_type is either a single named user (principal_user_id, referencing users.id directly — not workspace_members.id, because a grant is a statement about a person, and users.id is the identifier that persists across that person's membership lifecycle) or a whole role (principal_role, one of the four workspace roles) — a blanket grant to every member currently holding that role, without enumerating individual users. principal_user_id is scoped by ON DELETE CASCADE to users, but note this only fires at final account purge (Section 19); a member's removal from a specific workspace does not delete their users row, so their folder_permissions grants in that workspace must be cleaned up explicitly by the removal flow (Section 6.7.5), not left to this FK. workspace_id is carried directly on the row (rather than derived by joining through folder_id) so the removal flow's cleanup query (DELETE FROM folder_permissions WHERE workspace_id = $1 AND principal_user_id = $2) never needs a join, and so scoped() (5.2) applies uniformly.

Precedence between a folder grant, folder visibility, and the workspace role is defined in Section 6.8.1 (the permission-matrix section owns the composition rule; this table only stores the grant rows).

5.4.4 Videos, Recordings & Media #

CREATE TABLE videos (
  id                       uuid PRIMARY KEY,
  workspace_id             uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
  folder_id                uuid NULL REFERENCES folders(id) ON DELETE SET NULL,
  owner_id                 uuid NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
  title                    text NOT NULL DEFAULT 'Untitled video',
  description              text NULL,
  summary                  text NULL,
  -- AI-accepted summary (Section 12.9.1), distinct from `description`; surfaced in library
  -- previews and digest emails (Sections 17/18). Written only via the ai_metadata_suggestions
  -- accept/edit_and_accept flow (5.4.7) or direct manual entry — never by generation alone.
  status                   text NOT NULL DEFAULT 'processing',
  -- 'processing' | 'ready' | 'failed' | 'archived' | 'trashed' | 'purging'
  duration_ms              integer NULL,               -- NULL until probed
  source_recording_id      uuid NULL REFERENCES recordings(id) ON DELETE SET NULL,
  active_edl_id            uuid NULL REFERENCES edit_decision_lists(id) ON DELETE SET NULL,
  auto_edit_preset_id      uuid NULL REFERENCES auto_edit_presets(id) ON DELETE SET NULL,
  auto_edit_preset_version integer NULL,               -- immutable snapshot, Section 10
  poster_media_id          uuid NULL REFERENCES media_assets(id) ON DELETE SET NULL,
  mux_asset_id             text NULL,
  visibility_default       text NOT NULL DEFAULT 'workspace',
  -- default visibility applied to new share links; does not itself gate access
  share_gate_status        text NOT NULL DEFAULT 'clear',
  -- 'clear' | 'blocked' — computed/cached, not an independent source of truth: 'blocked'
  -- whenever the video has any redaction_regions row not yet reflected in a
  -- redaction_verified = true rendition covering the video's current EDL version (Section 22.2.5).
  -- Recomputed by the render worker on every render completion and by the redaction-region
  -- create/update handler. A 'blocked' video cannot be shared, embedded, or made public
  -- through any code path (enforced at the single authorization point, Section 6.9).
  last_activity_at         timestamptz NOT NULL DEFAULT now(),
  -- bumped by trigger on any qualifying view or edit event (Section 19.1.1's definition of activity)
  activity_status          text NOT NULL DEFAULT 'active',
  -- 'active' | 'inactive' — computed nightly by the retention scan (Section 19.1.1)
  inactive_since            timestamptz NULL,           -- set the moment activity_status flips to 'inactive'
  retention_deadline       timestamptz NULL,            -- inactive_since + plan retention window; NULL on Business
  purge_at                 timestamptz NULL,            -- set on trash (status='trashed') or workspace/account deletion cascade
  view_count               bigint NOT NULL DEFAULT 0,   -- cached rollup from video_view_daily
  created_at               timestamptz NOT NULL DEFAULT now(),
  updated_at               timestamptz NOT NULL DEFAULT now(),
  deleted_at                timestamptz NULL,

  CONSTRAINT videos_status_check CHECK (status IN ('processing', 'ready', 'failed', 'archived', 'trashed', 'purging')),
  CONSTRAINT videos_visibility_check CHECK (visibility_default IN ('private', 'workspace', 'link', 'public')),
  CONSTRAINT videos_share_gate_status_check CHECK (share_gate_status IN ('clear', 'blocked')),
  CONSTRAINT videos_activity_status_check CHECK (activity_status IN ('active', 'inactive')),
  CONSTRAINT videos_title_len CHECK (char_length(title) BETWEEN 1 AND 300)
);
CREATE INDEX videos_workspace_active_idx ON videos (workspace_id, created_at DESC) WHERE deleted_at IS NULL;
-- serves: the library's default "recent videos" listing
CREATE INDEX videos_workspace_folder_idx ON videos (workspace_id, folder_id) WHERE deleted_at IS NULL;
-- serves: browsing a specific folder
CREATE INDEX videos_owner_idx ON videos (owner_id) WHERE deleted_at IS NULL;
-- serves: "member sees analytics for their OWN videos only" (Section 6.8) and "my videos" filter
CREATE INDEX videos_status_processing_idx ON videos (status) WHERE status = 'processing';
-- serves: operational dashboards / stuck-job detection (Section 24)
CREATE INDEX videos_title_trgm_idx ON videos USING gin (title gin_trgm_ops) WHERE deleted_at IS NULL;
-- serves: in-library fuzzy title search (requires `CREATE EXTENSION pg_trgm`)
CREATE INDEX videos_retention_deadline_idx ON videos (retention_deadline) WHERE retention_deadline IS NOT NULL;
-- serves: the nightly retention-notification sweep and deadline-crossing scan (Section 19.1/19.2)
CREATE INDEX videos_purge_at_idx ON videos (purge_at) WHERE status = 'trashed';
-- serves: the nightly trash-sweep that enqueues true deletion once purge_at elapses (Section 19.4)
CREATE INDEX videos_activity_scan_idx ON videos (activity_status, last_activity_at) WHERE deleted_at IS NULL;
-- serves: the nightly retention scan's "flip active videos with 30 days of silence" query (Section 19.1.1)

status's two added values reflect the trash/purge lifecycle owned by Section 19: trashed is the soft-delete state entered by an explicit user delete (deleted_at is set at the same time, per the soft-delete policy in 5.3 — trashed and deleted_at IS NOT NULL are set together, never one without the other), and purging is entered the instant the true-deletion workflow's first stage locks the video and revokes playback, before the row itself is finally removed. A video is never in both trashed and purging at once — the transition is one-directional and the row is deleted outright at the end of purging, not reset back to trashed.

owner_id ON DELETE RESTRICT: mirrors the workspaces.owner_id reasoning — a user cannot be purged while still owning videos; the member-removal flow (Section 6.7) reassigns video ownership to the workspace owner (or a designated admin) before a user is eligible for final deletion.

folder_id ON DELETE SET NULL: explained under folders above — deleting a folder never deletes its videos.

source_recording_id and active_edl_id are nullable with SET NULL because they are denormalized "current pointer" fields for fast reads; the authoritative history lives in the recordings and edit_decision_lists tables respectively (a video can have exactly one active EDL at a time but retains prior EDL rows for history/audit, per Section 11).

CREATE TABLE recordings (
  id                uuid PRIMARY KEY,
  workspace_id      uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
  video_id          uuid NOT NULL REFERENCES videos(id) ON DELETE CASCADE,
  recorded_by       uuid NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
  source            text NOT NULL,               -- 'browser' | 'desktop'
  capture_mode      text NOT NULL,               -- 'screen' | 'screen_camera' | 'camera' | 'audio'
  capture_region_rect jsonb NULL,
  -- desktop-only region capture: { x, y, width, height } in physical, display-scale-corrected
  -- pixels (Section 8). NULL when the recording covers a full display/window rather than a
  -- user-dragged crop rectangle. The render worker crops to this rect during the mandatory
  -- pre-transcode normalization pass (Section 9); the raw capture itself is always full-display
  -- for encoder stability.
  telemetry_source  text NULL,                   -- 'native_120hz' | 'browser_60hz_degraded'
  -- lets downstream consumers (Section 10 auto-edit, analytics) condition on cursor-telemetry
  -- fidelity without re-deriving it from `source`/`telemetry_sample_hz`. NULL when
  -- has_cursor_telemetry = false.
  recorded_mime_type text NOT NULL,              -- chosen MediaRecorder mime, Section 8
  raw_media_id      uuid NULL REFERENCES media_assets(id) ON DELETE SET NULL,
  has_cursor_telemetry boolean NOT NULL DEFAULT false,
  telemetry_sample_hz smallint NULL,             -- 120 desktop / 60 browser fallback, Section 10.1
  telemetry_blob_id uuid NULL REFERENCES cursor_telemetry_blobs(id) ON DELETE SET NULL,
  captured_at       timestamptz NOT NULL,
  upload_completed_at timestamptz NULL,
  created_at        timestamptz NOT NULL DEFAULT now(),

  CONSTRAINT recordings_source_check CHECK (source IN ('browser', 'desktop')),
  CONSTRAINT recordings_capture_mode_check CHECK (capture_mode IN ('screen', 'screen_camera', 'camera', 'audio')),
  CONSTRAINT recordings_telemetry_source_check CHECK (telemetry_source IN ('native_120hz', 'browser_60hz_degraded'))
);
CREATE INDEX recordings_video_id_idx ON recordings (video_id);
CREATE INDEX recordings_workspace_recorded_by_idx ON recordings (workspace_id, recorded_by, created_at DESC);
-- serves: "my recent recordings" and per-seat usage accounting (Section 21)

recorded_mime_type (renamed from an earlier mime_type working name) is the exact MediaRecorder.isTypeSupported()-probed mime string recorded on capture (Section 8's codec preference order with fallbacks) — the recorded_ prefix disambiguates it from the several other mime/content-type fields elsewhere in the schema (media_assets.content_type, etc.) so a column-name grep is never ambiguous about which stage of the pipeline it describes.

recordings hard-deletes (it is not in the soft-delete list in 5.3); its video_id ON DELETE CASCADE means a recording row is meaningless without a live videos row. Note this is distinct from the video's soft-delete: while videos.deleted_at is set, the recordings row (and its media) still physically exists, because cascade only fires on a hard DELETE, which only happens at final purge (Section 19).

CREATE TABLE media_assets (
  id              uuid PRIMARY KEY,
  workspace_id    uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
  kind            text NOT NULL,
  -- 'original' | 'redaction_unredacted_original' | 'poster' | 'thumbnail' | 'export' |
  -- 'brand_logo' | 'screenshot_original' | 'screenshot_edited' — see the bucket table below
  storage_bucket  text NOT NULL,
  storage_key     text NOT NULL,
  restricted      boolean NOT NULL DEFAULT false,
  -- true for 'original', 'redaction_unredacted_original', 'screenshot_original': never served to
  -- a viewer, never referenced by a share link or playback token, access audit-logged (Section 22).
  -- Enforced in the object-storage access layer (distinct IAM policy per bucket, Section 22.2.2),
  -- not just here — this column drives internal authorization checks, but the restricted bucket's
  -- own bucket policy is the actual backstop.
  content_type    text NOT NULL,
  size_bytes      bigint NOT NULL,
  checksum_sha256 text NULL,
  width_px        integer NULL,
  height_px       integer NULL,
  created_at      timestamptz NOT NULL DEFAULT now(),

  CONSTRAINT media_assets_kind_check CHECK (kind IN (
    'original', 'redaction_unredacted_original', 'poster', 'thumbnail', 'export', 'brand_logo',
    'screenshot_original', 'screenshot_edited'
  )),
  CONSTRAINT media_assets_restricted_consistency CHECK (
    (kind IN ('original', 'redaction_unredacted_original', 'screenshot_original') AND restricted = true) OR
    (kind NOT IN ('original', 'redaction_unredacted_original', 'screenshot_original') AND restricted = false)
  ),
  CONSTRAINT media_assets_bucket_consistency CHECK (
    (restricted = true AND storage_bucket = 'reelay-media-restricted') OR
    (restricted = false AND storage_bucket = 'reelay-media-delivery')
  )
);
CREATE UNIQUE INDEX media_assets_storage_key_unique ON media_assets (storage_bucket, storage_key);
CREATE INDEX media_assets_workspace_kind_idx ON media_assets (workspace_id, kind);
-- serves: storage-quota rollups by kind (Section 21) and the purge worker's sweep by kind

kind resolution and bucket placement — every value, unambiguously (Section 22.2.2 owns the security property; this table is the schema that enforces it):

kind Bucket Restricted? Notes
original reelay-media-restricted Yes The unredacted source upload for a video that has no redaction_regions rows. Never served, never CDN-fronted.
redaction_unredacted_original reelay-media-restricted Yes The same physical role as original — the unredacted source — used specifically once a video has one or more redaction_regions rows, so the kind value itself signals "this object must never be the source of a servable rendition without a burn-in pass first." A video's original transitions from original to redaction_unredacted_original (an in-place UPDATE of the kind column, not a copy) the moment its first redaction region is created; it never transitions back, even if every region is later removed, because the render history already produced renditions from an unredacted source and the conservative default is preserved. There is no source_original value — the two prior candidate names for "the unredacted upload" are resolved into exactly these two kinds, disambiguated only by redaction status, never a third synonym.
poster reelay-media-delivery No CDN-fronted, access-controlled like playback per Section 14's poster/thumbnail cache policy.
thumbnail reelay-media-delivery No Same as poster.
export reelay-media-delivery No GIF/WebM/MP4 export artifacts; servable subject to Section 14 authorization.
brand_logo reelay-media-delivery No Brand-kit assets are intentionally public-servable within the workspace's branding surfaces — never sensitive content.
screenshot_original reelay-media-restricted Yes The raw captured/uploaded frame before beautification (Section 13), held privately until the owner either publishes a screenshot_edited derivative or the screenshot is deleted — this mirrors the video pipeline's "restricted until reviewed" posture rather than assuming every captured frame is automatically safe to serve.
screenshot_edited reelay-media-delivery No The beautified, servable screenshot a screenshots row actually points viewers at (media_asset_id in 5.4.8).

The two-bucket model is physical, not a prefix convention: reelay-media-restricted and reelay-media-delivery are separate buckets with separate IAM policies (Section 22.2.2), so a misconfigured bucket policy on one can never expose the other. The application layer never computes a bucket name from a template string at write time — storage_bucket is always set from the STORAGE_BUCKET_RESTRICTED / STORAGE_BUCKET_DELIVERY environment variables (Section 4's STORAGE_* vendor-swappable naming convention) at the moment a media_assets row is created, and media_assets_bucket_consistency is the database-level backstop that a row can never claim restricted = true while pointing at the delivery bucket or vice versa.

media_assets and renditions are the only tables with NO deleted_at column at all — per Section 5.3, media hard-deletes only, so a soft-delete column would be a footgun (code could set it and believe the bytes were retained when they were not, or the purge worker could be double-guarded against a column that should never be checked). Its absence is deliberate.

CREATE TABLE renditions (
  id             uuid PRIMARY KEY,
  media_asset_id uuid NOT NULL REFERENCES media_assets(id) ON DELETE CASCADE,
  video_id       uuid NOT NULL REFERENCES videos(id) ON DELETE CASCADE,
  rendition_type text NOT NULL,              -- 'hls_abr' | 'mp4_1080p' | 'mp4_4k' | 'gif' | 'webm'
  mux_playback_id text NULL,
  resolution     text NULL,                  -- e.g. '1920x1080'
  bitrate_bps    integer NULL,
  edl_id         uuid NULL REFERENCES edit_decision_lists(id) ON DELETE SET NULL,
  -- which EDL version this rendition was rendered from; NULL = rendered from source with no edits
  render_status  text NOT NULL DEFAULT 'pending',  -- 'pending' | 'rendering' | 'ready' | 'failed'
  redaction_verified boolean NOT NULL DEFAULT false,
  -- true only once the post-render verification pass (Section 22.2.4) has confirmed every
  -- redaction_regions row covering this rendition's EDL version is actually blurred in the
  -- produced output. A rendition with one or more relevant redaction regions can never reach
  -- render_status = 'ready' while this is false (Section 22.2.4's fail-closed gate).
  servable       boolean NOT NULL DEFAULT false,
  -- the actual "safe to serve" flag checked at playback-resolution time — distinct from
  -- render_status because a previously-ready, previously-servable rendition can be flipped back
  -- to servable = false without changing its render_status: this is what happens when a NEW
  -- redaction region is added to a video that already has active share links (Section 22.2.5's
  -- immediate-unpublish rule) — the rendition stays render_status = 'ready' (it did complete) but
  -- servable flips false until a fresh redaction_verified = true rendition replaces it.
  failure_reason text NULL,
  -- set when render_status = 'failed'; e.g. 'redaction_verification_failed',
  -- 'transcode_error', 'source_unreadable'. NULL whenever render_status != 'failed'.
  created_at     timestamptz NOT NULL DEFAULT now(),

  CONSTRAINT renditions_type_check CHECK (rendition_type IN ('hls_abr', 'mp4_1080p', 'mp4_4k', 'gif', 'webm')),
  CONSTRAINT renditions_status_check CHECK (render_status IN ('pending', 'rendering', 'ready', 'failed')),
  CONSTRAINT renditions_failure_reason_consistency CHECK (
    (render_status = 'failed' AND failure_reason IS NOT NULL) OR
    (render_status != 'failed' AND failure_reason IS NULL)
  ),
  CONSTRAINT renditions_servable_requires_ready CHECK (
    servable = false OR render_status = 'ready'
  )
);
CREATE INDEX renditions_video_id_idx ON renditions (video_id, rendition_type);
-- serves: "get the ready HLS rendition for this video" (player load path, hot)
CREATE INDEX renditions_pending_idx ON renditions (render_status) WHERE render_status IN ('pending', 'rendering');
-- serves: worker queue reconciliation / stuck-render detection
CREATE INDEX renditions_servable_idx ON renditions (video_id) WHERE servable = true;
-- serves: the playback-resolution hot path — "the current servable rendition(s) for this video"

5.4.5 Edit Decision Lists & Auto-Edit Presets #

The EDL storage decision — JSONB column on a dedicated edit_decision_lists table, not a normalized set of operation tables — is deliberate:

  • The EDL is read and written as a whole document by the timeline editor (Section 11) and by the render worker (Section 9). There is no query pattern that needs to filter or aggregate across individual EDL operations at the SQL level — every consumer wants "the full ordered operation list for this EDL version," which a normalized edl_operations table would only reconstruct via an ORDER BY sequence query that JSONB gives for free.
  • Versioning (below) means EDLs are effectively immutable snapshots once created; a document column matches that write-once-read-many access pattern far better than a mutable row-per-op table that would need careful transaction boundaries to keep "the current state of an EDL" atomic across many rows.
  • JSONB supports GIN indexing if a future need arises to search within EDL contents (e.g. "find videos with a redaction operation"), without a schema migration.

edit_decision_lists owns where the EDL lives and how it is versioned; the JSON Schema of the document itself (operation types, time-range fields, revert semantics) is defined in Section 11.

CREATE TABLE edit_decision_lists (
  id                uuid PRIMARY KEY,
  workspace_id      uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
  video_id          uuid NOT NULL REFERENCES videos(id) ON DELETE CASCADE,
  version           integer NOT NULL,
  schema_version    text NOT NULL,             -- EDL JSON Schema version, e.g. 'edl.v1' (Section 11)
  document          jsonb NOT NULL,
  generated_by      text NOT NULL,              -- 'auto_edit' | 'manual' | 'system_restore'
  auto_edit_preset_version integer NULL,        -- set when generated_by = 'auto_edit'
  created_by        uuid NULL REFERENCES users(id) ON DELETE SET NULL,
  created_at        timestamptz NOT NULL DEFAULT now(),

  CONSTRAINT edl_generated_by_check CHECK (generated_by IN ('auto_edit', 'manual', 'system_restore')),
  CONSTRAINT edl_version_positive CHECK (version > 0)
);
CREATE UNIQUE INDEX edl_video_version_unique ON edit_decision_lists (video_id, version);
CREATE INDEX edl_video_id_idx ON edit_decision_lists (video_id, version DESC);
-- serves: "load the latest EDL for this video" and full edit-history listing
CREATE INDEX edl_document_gin_idx ON edit_decision_lists USING gin (document jsonb_path_ops);
-- supports future containment queries over EDL content without a migration

Every edit — including a single filler-word removal or a manual trim — creates a new edit_decision_lists row with version incremented, rather than mutating document in place. videos.active_edl_id always points at the current version; prior versions are retained indefinitely (they are cheap JSONB rows, not media) and form the complete revert history described in Section 11 ("restore all returns exactly the original timeline" — version 0, conceptually the empty EDL, is implicit: a video with no edit_decision_lists row at all plays the untouched source).

CREATE TABLE auto_edit_presets (
  id           uuid PRIMARY KEY,
  workspace_id uuid NULL REFERENCES workspaces(id) ON DELETE CASCADE,
  -- NULL = system-provided global preset (seeded, see 5.10); non-NULL = workspace-authored custom preset
  name         text NOT NULL,
  version      integer NOT NULL DEFAULT 1,
  is_system    boolean NOT NULL DEFAULT false,
  parameters   jsonb NOT NULL,          -- full zoom/motion/background parameter set, Section 10
  created_by   uuid NULL REFERENCES users(id) ON DELETE SET NULL,
  created_at   timestamptz NOT NULL DEFAULT now(),
  updated_at   timestamptz NOT NULL DEFAULT now(),

  CONSTRAINT auto_edit_presets_system_workspace_consistency CHECK (
    (is_system = true AND workspace_id IS NULL) OR (is_system = false AND workspace_id IS NOT NULL)
  )
);
CREATE INDEX auto_edit_presets_workspace_idx ON auto_edit_presets (workspace_id) WHERE workspace_id IS NOT NULL;
CREATE UNIQUE INDEX auto_edit_presets_system_name_unique ON auto_edit_presets (name) WHERE is_system = true;

Preset versions are immutable: updating a preset's parameters never mutates an existing (id, version) — it inserts a new row with the same name/lineage and version + 1. This is what makes the determinism requirement in Section 10 enforceable: videos.auto_edit_preset_version (an integer snapshot, not a foreign key to a mutable row) pins the exact parameter set used, so re-rendering later with "the same preset version" is guaranteed byte-identical even if the workspace has since edited the preset going forward. The actual immutable parameter row is addressable as auto_edit_presets filtered to (id = preset_id, version = preset_version) — no separate history table is needed because presets themselves are append-only.

5.4.6 Cursor Telemetry #

Cursor telemetry (Section 10) is high-frequency structured data — 120 samples/sec from the desktop app, each with position, screen id, click/drag/scroll/key state — for recordings that can run up to 4 hours. At 120 Hz that is up to ~51.8 million samples for a single long recording. This is never stored as Postgres rows. It is stored as a compressed binary/JSON-lines blob in object storage, with a single lightweight metadata row in Postgres pointing at it:

CREATE TABLE cursor_telemetry_blobs (
  id              uuid PRIMARY KEY,
  workspace_id    uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
  recording_id    uuid NOT NULL REFERENCES recordings(id) ON DELETE CASCADE,
  storage_bucket  text NOT NULL,
  storage_key     text NOT NULL,
  format          text NOT NULL DEFAULT 'ndjson_gzip',  -- newline-delimited JSON, gzip-compressed
  sample_count    bigint NOT NULL,
  sample_rate_hz  smallint NOT NULL,
  duration_ms     integer NOT NULL,
  size_bytes      bigint NOT NULL,
  checksum_sha256 text NOT NULL,
  created_at      timestamptz NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX cursor_telemetry_blobs_recording_unique ON cursor_telemetry_blobs (recording_id);
CREATE UNIQUE INDEX cursor_telemetry_blobs_storage_key_unique ON cursor_telemetry_blobs (storage_bucket, storage_key);

Rationale, stated explicitly:

  1. Row volume. Storing 51.8 million individual sample rows per long recording would make video_view_events (already the largest table, see 5.6) look small by comparison, multiplied across every recording ever made. Postgres is not the right engine for write-once, sequentially- read, never-filtered-by-SQL time series of this volume.
  2. Access pattern. The auto-edit engine (Section 10) reads a telemetry blob exactly once, start to finish, to compute the zoom timeline. There is no query that needs "cursor position at timestamp X" via SQL WHERE — it is a sequential scan by a worker process, which object storage with range-read support serves at least as well as Postgres and at a fraction of the storage cost per byte.
  3. Cost. Object storage is roughly an order of magnitude cheaper per GB than provisioned Postgres storage/IOPS, and telemetry blobs are large relative to everything else in the schema.
  4. Lifecycle. Telemetry blobs are only needed until the auto-edit render that consumes them completes (plus a retention window for re-render-on-preset-upgrade). They are natural candidates for an object-storage lifecycle rule (Section 19), which is trivial to configure on a bucket prefix and awkward to replicate as a Postgres row-expiry job at this volume.

5.4.7 Transcription, Captions, Chapters & AI Metadata #

CREATE TABLE transcripts (
  id           uuid PRIMARY KEY,
  workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
  video_id     uuid NOT NULL REFERENCES videos(id) ON DELETE CASCADE,
  language     text NOT NULL DEFAULT 'en',
  status       text NOT NULL DEFAULT 'pending',   -- 'pending' | 'processing' | 'ready' | 'failed'
  provider     text NOT NULL,                     -- transcription vendor identifier
  full_text    text NULL,
  created_at   timestamptz NOT NULL DEFAULT now(),
  updated_at   timestamptz NOT NULL DEFAULT now(),

  CONSTRAINT transcripts_status_check CHECK (status IN ('pending', 'processing', 'ready', 'failed'))
);
CREATE UNIQUE INDEX transcripts_video_language_unique ON transcripts (video_id, language);
CREATE INDEX transcripts_full_text_search_idx ON transcripts USING gin (to_tsvector('english', coalesce(full_text, '')));
-- serves: cross-library transcript search (Section 18)

CREATE TABLE transcript_segments (
  id            uuid PRIMARY KEY,
  transcript_id uuid NOT NULL REFERENCES transcripts(id) ON DELETE CASCADE,
  sequence      integer NOT NULL,
  start_ms      integer NOT NULL,
  end_ms        integer NOT NULL,
  speaker_label text NULL,           -- e.g. 'Speaker 1', diarization best-effort
  text          text NOT NULL,
  confidence    real NULL,
  is_filler     boolean NOT NULL DEFAULT false,   -- flagged filler word/phrase, feeds Section 11 EDL suggestions

  CONSTRAINT transcript_segments_time_order CHECK (end_ms > start_ms)
);
CREATE UNIQUE INDEX transcript_segments_transcript_sequence_unique ON transcript_segments (transcript_id, sequence);
CREATE INDEX transcript_segments_transcript_time_idx ON transcript_segments (transcript_id, start_ms);
-- serves: rendering the transcript panel scrubbed to playback position

CREATE TABLE captions (
  id            uuid PRIMARY KEY,
  video_id      uuid NOT NULL REFERENCES videos(id) ON DELETE CASCADE,
  transcript_id uuid NOT NULL REFERENCES transcripts(id) ON DELETE CASCADE,
  language      text NOT NULL DEFAULT 'en',
  format        text NOT NULL DEFAULT 'vtt',
  storage_key   text NOT NULL,        -- generated VTT file in object storage
  storage_bucket text NOT NULL,
  created_at    timestamptz NOT NULL DEFAULT now(),

  CONSTRAINT captions_format_check CHECK (format IN ('vtt'))
);
CREATE UNIQUE INDEX captions_video_language_unique ON captions (video_id, language);

CREATE TABLE chapters (
  id         uuid PRIMARY KEY,
  video_id   uuid NOT NULL REFERENCES videos(id) ON DELETE CASCADE,
  sequence   integer NOT NULL,
  title      text NOT NULL,
  start_ms   integer NOT NULL,
  source     text NOT NULL DEFAULT 'ai',   -- 'ai' | 'manual'
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now(),

  CONSTRAINT chapters_source_check CHECK (source IN ('ai', 'manual')),
  CONSTRAINT chapters_title_len CHECK (char_length(title) BETWEEN 1 AND 200)
);
CREATE UNIQUE INDEX chapters_video_sequence_unique ON chapters (video_id, sequence);
CREATE INDEX chapters_video_start_idx ON chapters (video_id, start_ms);

CREATE TABLE ai_metadata_suggestions (
  id                   uuid PRIMARY KEY,
  workspace_id         uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
  video_id             uuid NOT NULL REFERENCES videos(id) ON DELETE CASCADE,
  kind                 text NOT NULL,          -- 'chapters' | 'summary' | 'title' | 'description'
  status               text NOT NULL DEFAULT 'pending',
  -- 'pending' | 'accepted' | 'edited_and_accepted' | 'rejected' | 'superseded' | 'failed'
  content              jsonb NOT NULL,
  -- ChapterSuggestion[] | { summary } | { titles: [string,string,string] } | { description },
  -- shaped per `kind` — the exact contract is owned by Section 12.9.1; this column is immutable
  -- once written (the trigger below rejects any UPDATE that changes it after insert)
  edited_content        jsonb NULL,
  -- populated only when status = 'edited_and_accepted'; same per-kind shape as `content`
  model_provider        text NOT NULL,
  model_name            text NOT NULL,
  prompt_version         text NOT NULL,        -- e.g. 'chapters.v1'
  generation_batch_id    uuid NULL,
  -- links paired title+description generations produced by the same generation call (Section 12.8.2)
  generated_at           timestamptz NOT NULL DEFAULT now(),
  reviewed_at            timestamptz NULL,
  reviewed_by_user_id    uuid NULL REFERENCES users(id) ON DELETE SET NULL,

  CONSTRAINT ai_metadata_suggestions_kind_check
    CHECK (kind IN ('chapters', 'summary', 'title', 'description')),
  CONSTRAINT ai_metadata_suggestions_status_check CHECK (status IN (
    'pending', 'accepted', 'edited_and_accepted', 'rejected', 'superseded', 'failed'
  )),
  -- The human-acceptance invariant (Section 12.9), enforced at the database layer, not just in
  -- the API handler: a suggestion can only be marked accepted/edited_and_accepted alongside a
  -- recorded human reviewer, and edited_content exists if and only if the status says a human
  -- edited it. Both are structurally impossible to violate via a direct SQL write, not merely
  -- discouraged by application code.
  CONSTRAINT ai_metadata_suggestions_reviewed_consistency CHECK (
    (status IN ('accepted', 'edited_and_accepted')
      AND reviewed_at IS NOT NULL AND reviewed_by_user_id IS NOT NULL)
    OR (status NOT IN ('accepted', 'edited_and_accepted'))
  ),
  CONSTRAINT ai_metadata_suggestions_edited_content_consistency CHECK (
    (status = 'edited_and_accepted' AND edited_content IS NOT NULL)
    OR (status != 'edited_and_accepted' AND edited_content IS NULL)
  )
);
CREATE INDEX ai_metadata_suggestions_video_pending_idx ON ai_metadata_suggestions (video_id) WHERE status = 'pending';
CREATE INDEX ai_metadata_suggestions_video_kind_idx ON ai_metadata_suggestions (video_id, kind, generated_at DESC);
-- serves: "the current/most recent suggestion of this kind for this video" and full suggestion history

Enforcing the human-acceptance invariant at the database layer. The two CHECK constraints above are necessary but not sufficient on their own — they constrain what a single row can say about itself, but the invariant that actually matters ("nothing an LLM produces becomes public metadata without a human accepting it") is about the transition between states and about the generation fields never being silently rewritten after the fact. A BEFORE UPDATE trigger closes both gaps:

CREATE OR REPLACE FUNCTION enforce_ai_metadata_suggestion_transition()
RETURNS trigger AS $$
BEGIN
  -- The generation record itself is immutable once written. Only status, edited_content,
  -- reviewed_at, and reviewed_by_user_id may ever change after insert.
  IF NEW.content IS DISTINCT FROM OLD.content
     OR NEW.kind IS DISTINCT FROM OLD.kind
     OR NEW.video_id IS DISTINCT FROM OLD.video_id
     OR NEW.model_provider IS DISTINCT FROM OLD.model_provider
     OR NEW.model_name IS DISTINCT FROM OLD.model_name
     OR NEW.prompt_version IS DISTINCT FROM OLD.prompt_version
     OR NEW.generated_at IS DISTINCT FROM OLD.generated_at THEN
    RAISE EXCEPTION 'ai_metadata_suggestions: generation fields are immutable after insert (id=%)', OLD.id;
  END IF;

  -- Status may only move along the transitions 12.9.2 actually defines. 'pending' is the only
  -- state anything else can be reached from; every other state is terminal for this row (a
  -- regenerate creates a NEW row and marks this one 'superseded', it never reopens a resolved row).
  IF OLD.status <> NEW.status THEN
    IF OLD.status <> 'pending' THEN
      RAISE EXCEPTION 'ai_metadata_suggestions: cannot transition out of terminal status % (id=%)', OLD.status, OLD.id;
    END IF;
    IF NEW.status NOT IN ('accepted', 'edited_and_accepted', 'rejected', 'superseded', 'failed') THEN
      RAISE EXCEPTION 'ai_metadata_suggestions: illegal transition % -> % (id=%)', OLD.status, NEW.status, OLD.id;
    END IF;
  END IF;

  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER ai_metadata_suggestions_transition_guard
  BEFORE UPDATE ON ai_metadata_suggestions
  FOR EACH ROW EXECUTE FUNCTION enforce_ai_metadata_suggestion_transition();

The accepted value for each kind lives on its owning entity, never inside the suggestion row itself — this is the other half of the invariant, and it is why content/edited_content alone being present is never sufficient to change what a viewer sees: accepted chapters become rows in the chapters table (5.4.7, source = 'ai_accepted'); accepted title and description are written to videos.title / videos.description; accepted summary is written to videos.summary (5.4.4). The copy from content/edited_content into the owning entity happens exactly once, inside the same transaction as the status → accepted/edited_and_accepted update, in the API handler for PATCH /v1/videos/{videoId}/ai-metadata-suggestions/{id} (Section 12.9.2) — the trigger above guarantees that transaction is the only place status can legally move off pending, but the copy-into-owning-entity step itself is application logic, not a second trigger, because the owning entity differs by kind (a table row for chapters, a column for the rest) in a way that is more maintainable as one handler than as per-kind trigger branches.

5.4.8 Redaction & Screenshots #

CREATE TABLE redaction_regions (
  id           uuid PRIMARY KEY,
  workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
  video_id     uuid NOT NULL REFERENCES videos(id) ON DELETE CASCADE,
  edl_id       uuid NOT NULL REFERENCES edit_decision_lists(id) ON DELETE CASCADE,
  region_type  text NOT NULL,             -- 'static' | 'tracked'
  status       text NOT NULL DEFAULT 'pending',   -- 'pending' | 'active'
  start_ms     integer NOT NULL,
  end_ms       integer NOT NULL,
  geometry     jsonb NOT NULL,            -- static rect {x,y,w,h} OR keyframe track array (Section 11)
  blur_strength smallint NOT NULL DEFAULT 25,
  created_by   uuid NULL REFERENCES users(id) ON DELETE SET NULL,
  created_at   timestamptz NOT NULL DEFAULT now(),

  CONSTRAINT redaction_regions_type_check CHECK (region_type IN ('static', 'tracked')),
  CONSTRAINT redaction_regions_status_check CHECK (status IN ('pending', 'active')),
  CONSTRAINT redaction_regions_time_order CHECK (end_ms > start_ms),
  CONSTRAINT redaction_regions_blur_range CHECK (blur_strength BETWEEN 1 AND 100)
);
CREATE INDEX redaction_regions_video_id_idx ON redaction_regions (video_id);
CREATE INDEX redaction_regions_edl_id_idx ON redaction_regions (edl_id);
-- serves: render worker loading all regions to burn in for a given EDL version
CREATE INDEX redaction_regions_status_idx ON redaction_regions (video_id, status);
-- serves: computing videos.share_gate_status ("any row not yet reflected in a verified rendition")

status tracks a region independently of any single rendition's redaction_verified flag (5.4.4): a region is pending from creation until the post-render verification pass (Section 22.2.4) confirms it is actually burned in on a produced rendition, and flips to active once confirmed. videos.share_gate_status is computed from the presence of any pending region combined with the EDL-version alignment check described in 22.2.5 — this column is what makes that computation a plain indexed query rather than a re-derivation from renditions on every request.

Redaction rows live under edit_decision_lists (an edl_id FK, ON DELETE CASCADE) because a redaction region is itself an EDL operation type conceptually — its geometry is duplicated into the EDL document JSONB at render time for the render worker's single source of truth, but is also kept as first-class relational rows here so the dashboard's redaction-management UI can list, filter, and paginate regions without parsing JSONB. Section 11 owns the exact reconciliation rule between this table and the EDL document; Section 22 owns the security properties (burn-in enforcement, restricted-bucket storage of the unredacted original via media_assets.restricted).

CREATE TABLE screenshots (
  id             uuid PRIMARY KEY,
  workspace_id   uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
  video_id       uuid NULL REFERENCES videos(id) ON DELETE SET NULL,
  -- NULL when captured standalone (not extracted from a recording), Section 13
  created_by     uuid NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
  media_asset_id uuid NOT NULL REFERENCES media_assets(id) ON DELETE CASCADE,
  source_frame_ms integer NULL,          -- playback position if extracted from a video
  beautify_preset text NULL,             -- background/frame preset applied, Section 13
  created_at     timestamptz NOT NULL DEFAULT now(),
  deleted_at     timestamptz NULL
);
CREATE INDEX screenshots_workspace_idx ON screenshots (workspace_id, created_at DESC) WHERE deleted_at IS NULL;
CREATE INDEX screenshots_video_id_idx ON screenshots (video_id) WHERE video_id IS NOT NULL;

Note: screenshots is not in the canonical soft-delete list in Section 7.6/5.3 (which names videos, workspaces, users, comments); it is treated the same as media for lifecycle purposes except that the small preview row itself is retained briefly with deleted_at set to support an "undo" affordance in the UI for a short window (Section 19 specifies the exact undo window), after which a hard-delete sweep removes both the row and its media_assets bytes. This is a UX convenience layered on top of the hard-delete-for-media rule, not an exception to it — the underlying media_assets row for the image is what actually carries the bytes, and it is purged on the same schedule as any other media asset once the undo window closes.

5.4.9 Sharing & Access Control #

CREATE TABLE share_links (
  id                  uuid PRIMARY KEY,
  workspace_id        uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
  video_id            uuid NOT NULL REFERENCES videos(id) ON DELETE CASCADE,
  slug                varchar(48) NOT NULL,
  is_custom_slug      boolean NOT NULL DEFAULT false,
  -- true when a Business-plan workspace supplied its own vanity slug (4-48 chars) rather than
  -- accepting the server-generated 12-char base58 random slug; does not change how playback
  -- tokens are issued/verified, purely a display/branding distinction.
  created_by          uuid NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
  visibility          text NOT NULL DEFAULT 'link',
  password_hash       text NULL,
  expires_at          timestamptz NULL,
  domain_allowlist    text[] NULL,
  disable_download    boolean NOT NULL DEFAULT false,
  disable_comments    boolean NOT NULL DEFAULT false,
  require_email       boolean NOT NULL DEFAULT false,
  playback_key_version integer NOT NULL DEFAULT 1,
  -- incremented on revoke or a visibility downgrade to link/public -> private/workspace
  -- (Section 14.8.4); every signed playback URL and poster/thumbnail key embeds the version it
  -- was minted against, so rotating this integer invalidates every previously issued signed URL
  -- and cached poster/thumbnail for this link in one write, without walking and revoking them
  -- individually.
  view_count          bigint NOT NULL DEFAULT 0,     -- cached, reconciled by rollup job
  created_at          timestamptz NOT NULL DEFAULT now(),
  updated_at          timestamptz NOT NULL DEFAULT now(),
  revoked_at          timestamptz NULL,
  -- THE sole revocation column for a share link — a link is either live (revoked_at IS NULL) or
  -- revoked (revoked_at set). There is no separate `deleted_at`/`disabled_at`; a share link is
  -- never hard-deleted while its video exists (it cascades only with the video, 5.4.4), and
  -- "disabled" and "deleted" are the same state as "revoked" from every consumer's perspective.

  CONSTRAINT share_links_visibility_check CHECK (visibility IN ('private', 'workspace', 'link', 'public')),
  CONSTRAINT share_links_slug_len CHECK (char_length(slug) BETWEEN 4 AND 48)
);
CREATE UNIQUE INDEX share_links_slug_unique ON share_links (slug);
-- global uniqueness (not per-workspace): slugs are looked up with no workspace context on the watch page
CREATE INDEX share_links_video_id_idx ON share_links (video_id) WHERE revoked_at IS NULL;
CREATE INDEX share_links_workspace_idx ON share_links (workspace_id, created_at DESC);

CREATE TABLE share_link_recipients (
  id             uuid PRIMARY KEY,
  share_link_id  uuid NOT NULL REFERENCES share_links(id) ON DELETE CASCADE,
  email          citext NOT NULL,
  recipient_token text NOT NULL,        -- embedded in personalized URL, Section 14
  first_viewed_at timestamptz NULL,
  created_at     timestamptz NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX share_link_recipients_token_unique ON share_link_recipients (recipient_token);
CREATE INDEX share_link_recipients_link_idx ON share_link_recipients (share_link_id);

CREATE TABLE share_audit_events (
  id            uuid PRIMARY KEY,
  workspace_id  uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
  share_link_id uuid NOT NULL REFERENCES share_links(id) ON DELETE CASCADE,
  actor_id      uuid NULL REFERENCES users(id) ON DELETE SET NULL,
  action        text NOT NULL,          -- 'created' | 'visibility_changed' | 'password_set' | ... (Section 14)
  before_state  jsonb NULL,
  after_state   jsonb NOT NULL,
  ip_address    inet NULL,
  created_at    timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX share_audit_events_link_idx ON share_audit_events (share_link_id, created_at DESC);
CREATE INDEX share_audit_events_workspace_idx ON share_audit_events (workspace_id, created_at DESC);
-- serves: the workspace-level audit log screen (Section 6.8: admin/owner-only access)

share_audit_events hard-deletes only via the workspace's own cascade at final purge; it is never individually deleted, since it is the record of exactly the failure mode ("a video made public that was meant to be internal") this spec calls out as the named risk to guard against — mutating or pruning it would defeat its purpose.

5.4.10 Analytics: Partitioned Events & Rollups #

video_view_events is the highest-volume table in the system (heartbeats every 5 seconds of playback across every view, per Section 16) and is monthly-partitioned on received_at (the server-insert clock, not the client-reported occurred_at — see the rationale below the DDL) from day one.

CREATE TABLE video_view_events (
  id            uuid NOT NULL,
  event_id      text NOT NULL,
  -- the client-generated idempotency key (`evt_` + 22-char base58, Section 16.1) — distinct from
  -- `id`, which is the row's own internally minted UUIDv7. The client mints event_id, not the
  -- server, specifically so retried/duplicate delivery of the SAME logical event (a sendBeacon
  -- retry after a flaky network) is detectable at write time via the unique index below.
  workspace_id  uuid NOT NULL,
  video_id      uuid NOT NULL,
  share_link_id uuid NULL,
  viewer_id     uuid NULL,               -- FK enforced via trigger, not a declarative FK (see below)
  viewer_token  text NULL,               -- the raw anonymous vwr_ token carried on the request, always present pre-identification
  session_id    uuid NOT NULL,           -- generated once per player mount, stable for that playback session
  event_type    text NOT NULL,
  -- 'view_start' | 'heartbeat' | 'play' | 'pause' | 'seek' | 'quality_change' | 'complete' |
  -- 'cta_click' | 'email_submit' | 'reaction' | 'comment_open' | 'transcript_open' | 'download' |
  -- 'share' — the complete 14-value catalogue, Section 16.1
  position_ms   integer NULL,
  payload       jsonb NOT NULL DEFAULT '{}',
  is_bot        boolean NOT NULL DEFAULT false,
  is_prefetched boolean NOT NULL DEFAULT false,
  -- both set from request-time signals (Sec-Purpose/Purpose: prefetch header, maintained bot
  -- signature list, Section 16.2) — accepted and flagged, never hard-rejected, so rollups can
  -- exclude them without the collection endpoint becoming a bot-detection oracle an attacker can
  -- probe against
  country       text NULL,               -- ISO 3166-1 alpha-2, derived server-side from request IP; the IP itself is never stored here
  occurred_at   timestamptz NOT NULL,     -- client clock, clamped to received_at if skewed > 24h (Section 16.4.2)
  received_at   timestamptz NOT NULL DEFAULT now(),  -- server clock; authoritative for partition routing

  CONSTRAINT video_view_events_event_type_check CHECK (event_type IN (
    'view_start', 'heartbeat', 'play', 'pause', 'seek', 'quality_change', 'complete',
    'cta_click', 'email_submit', 'reaction', 'comment_open', 'transcript_open', 'download', 'share'
  )),
  PRIMARY KEY (id, received_at)
) PARTITION BY RANGE (received_at);

CREATE INDEX video_view_events_workspace_video_idx ON video_view_events (workspace_id, video_id, occurred_at);
-- serves: the hourly rollup job's per-video scan window
CREATE INDEX video_view_events_share_link_idx ON video_view_events (share_link_id, occurred_at) WHERE share_link_id IS NOT NULL;
-- serves: per-link analytics breakdown
CREATE UNIQUE INDEX video_view_events_video_event_unique ON video_view_events (video_id, event_id, received_at);
-- dedup constraint for retried delivery of the same client-minted event_id, Section 16.2/16.10

video_view_events deliberately has no declarative foreign keys to workspaces, videos, or viewers. The ingestion endpoint (POST /v1/collect, Section 7 and 16) is public and unauthenticated by design — a malformed or replayed video_id must never be able to throw a constraint-violation error back to an anonymous viewer's browser, and FK constraint checks on a table taking this much insert volume would add lock contention against the parent tables' own hot paths. Referential integrity is instead enforced at the application layer: the ingestion handler validates video_id and workspace_id against a short-TTL Redis-cached lookup (populated from Postgres) before accepting an event, and rejects unknown IDs with a 202 Accepted-but-discarded response (never a 4xx that would let an attacker probe for valid video IDs by observing status code differences — this mirrors the enumeration-resistance principle in Section 6.1).

The primary key is composite (id, received_at), and the table is partitioned on received_at rather than occurred_at — Postgres requires the partition key to be part of any unique constraint including the primary key on a partitioned table, and the partition key is deliberately the server-clock column, not the client-clock one: occurred_at is attacker/clock-skew influenced (a client can report any timestamp it likes), so routing partition placement off it would let a malicious or misconfigured client target an already-detached, already-dropped partition, or scatter a single burst of real-time traffic across many old partitions. received_at is set server-side at insert time and is monotonic with actual write order, which is what "always insert into the partition matching the current month" (Section 16.4.2) requires. Rollups still bucket by the (clamped) occurred_at, since that is the analytically meaningful timestamp — only partition placement uses received_at.

The dedup unique index (video_id, event_id, received_at) necessarily includes the partition key (received_at) alongside the logical dedup key (video_id, event_id), because Postgres requires every unique index on a partitioned table to include the partition key — this is a structural Postgres limitation, not a design choice, and it means uniqueness is only truly enforced for retries landing in the same partition-month as the original. In practice this is not a gap: a sendBeacon retry happens within seconds of the original send (Section 16.2's flush triggers are all sub-5-second), so a retry crossing a month boundary is not a real-world case, and Section 16.4.2's late-event handling already treats anything arriving after its own analytical window as a documented accuracy edge case rather than a correctness bug.

Partition DDL (one partition per calendar month, created ahead of need):

CREATE TABLE video_view_events_2026_08 PARTITION OF video_view_events
  FOR VALUES FROM ('2026-08-01') TO ('2026-09-01');

CREATE TABLE video_view_events_2026_09 PARTITION OF video_view_events
  FOR VALUES FROM ('2026-09-01') TO ('2026-10-01');

Automatic partition creation job: a BullMQ scheduled job (analytics.partition.ensure, cron 0 0 25 * * — the 25th of each month, giving a 5-6 day buffer before month-end) runs the following idempotent procedure, which creates the next 3 months of partitions if they do not already exist (a buffer beyond just "next month," so a missed run does not cause an ingestion failure):

CREATE OR REPLACE FUNCTION ensure_video_view_events_partition(target_month date)
RETURNS void AS $$
DECLARE
  partition_name text := 'video_view_events_' || to_char(target_month, 'YYYY_MM');
  start_date date := date_trunc('month', target_month);
  end_date date := start_date + interval '1 month';
BEGIN
  IF NOT EXISTS (
    SELECT 1 FROM pg_class WHERE relname = partition_name
  ) THEN
    EXECUTE format(
      'CREATE TABLE %I PARTITION OF video_view_events FOR VALUES FROM (%L) TO (%L)',
      partition_name, start_date, end_date
    );
    EXECUTE format(
      'CREATE INDEX %I ON %I (workspace_id, video_id, occurred_at)',
      partition_name || '_workspace_video_idx', partition_name
    );
  END IF;
END;
$$ LANGUAGE plpgsql;

The job calls ensure_video_view_events_partition for now(), now() + 1 month, and now() + 2 months on every run. Job idempotency key: analytics:partition-ensure:<YYYY-MM> per the queue conventions in Section 9/24.

Detach and drop retention behaviour: raw events are retained for 13 months (one month beyond the 12-month rollup window the product exposes in the dashboard, so month-boundary queries never show a gap). A second scheduled job (analytics.partition.retire, monthly) runs:

-- Step 1: detach concurrently (non-blocking, Postgres 14+ feature) once a partition
-- is fully outside the retention window.
ALTER TABLE video_view_events DETACH PARTITION video_view_events_2025_06 CONCURRENTLY;

-- Step 2 (separate transaction, after detach completes): drop the now-standalone table.
DROP TABLE video_view_events_2025_06;

Detach is run as its own job invocation, and the drop is deferred to a subsequent job run at least 24 hours later, never in the same transaction — this gives a manual-intervention window (an operator can re-attach a detached-but-not-yet-dropped partition if a retention-window miscalculation is caught in time) before data is irrecoverably dropped. Both steps are logged to jobs_audit (Section 24) with the partition name and row-count-at-detach.

Rollup tables — derived from video_view_events, never written to directly by the ingestion path:

CREATE TABLE video_view_daily (
  id             uuid PRIMARY KEY,
  workspace_id   uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
  video_id       uuid NOT NULL REFERENCES videos(id) ON DELETE CASCADE,
  day            date NOT NULL,
  view_count     integer NOT NULL DEFAULT 0,
  unique_viewer_count integer NOT NULL DEFAULT 0,
  avg_watch_ms   integer NOT NULL DEFAULT 0,
  completion_rate real NOT NULL DEFAULT 0,     -- 0..1
  cta_click_count integer NOT NULL DEFAULT 0,
  email_capture_count integer NOT NULL DEFAULT 0,
  computed_at    timestamptz NOT NULL DEFAULT now(),

  CONSTRAINT video_view_daily_completion_rate_range CHECK (completion_rate BETWEEN 0 AND 1)
);
CREATE UNIQUE INDEX video_view_daily_video_day_unique ON video_view_daily (video_id, day);
CREATE INDEX video_view_daily_workspace_day_idx ON video_view_daily (workspace_id, day DESC);
-- serves: workspace-level analytics dashboard time range queries

CREATE TABLE video_engagement_curve (
  id           uuid PRIMARY KEY,
  workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
  video_id     uuid NOT NULL REFERENCES videos(id) ON DELETE CASCADE,
  bucket_index integer NOT NULL,          -- 0-based, up to 1799 (1800-bucket cap, Section 16)
  bucket_start_ms integer NOT NULL,
  viewers_remaining integer NOT NULL DEFAULT 0,
  viewers_total integer NOT NULL DEFAULT 0,
  computed_at  timestamptz NOT NULL DEFAULT now(),

  CONSTRAINT video_engagement_curve_bucket_range CHECK (bucket_index BETWEEN 0 AND 1799)
);
CREATE UNIQUE INDEX video_engagement_curve_video_bucket_unique ON video_engagement_curve (video_id, bucket_index);
-- serves: rendering the drop-off curve chart for a single video (full-table read per video, capped at 1800 rows)

Both rollup tables are fully recomputed-and-replaced (DELETE for the video/day or video, then bulk INSERT) by their respective jobs (analytics.rollup.daily hourly, analytics.rollup.curve hourly) rather than incrementally updated — recomputation from the source partition is cheap relative to the correctness risk of incremental aggregate drift, and both jobs are naturally idempotent this way (safe to re-run after a failure with no double-counting).

CREATE TABLE viewers (
  id               uuid PRIMARY KEY,
  workspace_id     uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
  viewer_token     text NOT NULL,          -- anonymous rotating token, 30-day, Section 16
  email            citext NULL,            -- set once identified
  user_id          uuid NULL REFERENCES users(id) ON DELETE SET NULL,  -- set if an authenticated workspace user
  first_seen_at    timestamptz NOT NULL DEFAULT now(),
  last_seen_at     timestamptz NOT NULL DEFAULT now(),
  identified_at    timestamptz NULL
);
CREATE UNIQUE INDEX viewers_token_unique ON viewers (viewer_token);
CREATE INDEX viewers_workspace_email_idx ON viewers (workspace_id, email) WHERE email IS NOT NULL;
-- serves: per-viewer analytics lookup by email (Pro/Business "full + per-viewer" tier, Section 21)

5.4.11 Engagement: Comments, Reactions, CTAs #

CREATE TABLE comments (
  id            uuid PRIMARY KEY,
  workspace_id  uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
  video_id      uuid NOT NULL REFERENCES videos(id) ON DELETE CASCADE,
  parent_id     uuid NULL REFERENCES comments(id) ON DELETE CASCADE,
  author_user_id uuid NULL REFERENCES users(id) ON DELETE SET NULL,
  author_viewer_id uuid NULL REFERENCES viewers(id) ON DELETE SET NULL,
  timestamp_ms  integer NULL,             -- optional: comment pinned to a playback position
  body          text NOT NULL,
  created_at    timestamptz NOT NULL DEFAULT now(),
  updated_at    timestamptz NOT NULL DEFAULT now(),
  deleted_at    timestamptz NULL,

  CONSTRAINT comments_body_len CHECK (char_length(body) BETWEEN 1 AND 5000),
  CONSTRAINT comments_author_exactly_one CHECK (
    (author_user_id IS NOT NULL AND author_viewer_id IS NULL) OR
    (author_user_id IS NULL AND author_viewer_id IS NOT NULL)
  )
);
CREATE INDEX comments_video_idx ON comments (video_id, created_at) WHERE deleted_at IS NULL;
CREATE INDEX comments_parent_idx ON comments (parent_id) WHERE parent_id IS NOT NULL AND deleted_at IS NULL;

CREATE TABLE comment_reactions (
  id         uuid PRIMARY KEY,
  comment_id uuid NOT NULL REFERENCES comments(id) ON DELETE CASCADE,
  reactor_user_id uuid NULL REFERENCES users(id) ON DELETE CASCADE,
  reactor_viewer_id uuid NULL REFERENCES viewers(id) ON DELETE CASCADE,
  emoji      text NOT NULL,

  CONSTRAINT comment_reactions_reactor_exactly_one CHECK (
    (reactor_user_id IS NOT NULL AND reactor_viewer_id IS NULL) OR
    (reactor_user_id IS NULL AND reactor_viewer_id IS NOT NULL)
  )
);
CREATE UNIQUE INDEX comment_reactions_comment_user_unique
  ON comment_reactions (comment_id, reactor_user_id) WHERE reactor_user_id IS NOT NULL;
CREATE UNIQUE INDEX comment_reactions_comment_viewer_unique
  ON comment_reactions (comment_id, reactor_viewer_id) WHERE reactor_viewer_id IS NOT NULL;

CREATE TABLE ctas (
  id           uuid PRIMARY KEY,
  workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
  video_id     uuid NOT NULL REFERENCES videos(id) ON DELETE CASCADE,
  trigger_type text NOT NULL,             -- 'time' | 'end_of_video' | 'pause'
  trigger_ms   integer NULL,              -- required when trigger_type = 'time'
  style        text NOT NULL DEFAULT 'banner',   -- 'banner' | 'modal' | 'fullscreen'
  headline     text NOT NULL,
  button_label text NOT NULL,
  button_url   text NOT NULL,
  created_at   timestamptz NOT NULL DEFAULT now(),
  updated_at   timestamptz NOT NULL DEFAULT now(),

  CONSTRAINT ctas_trigger_type_check CHECK (trigger_type IN ('time', 'end_of_video', 'pause')),
  CONSTRAINT ctas_trigger_ms_required CHECK (
    (trigger_type = 'time' AND trigger_ms IS NOT NULL) OR (trigger_type != 'time')
  ),
  CONSTRAINT ctas_style_check CHECK (style IN ('banner', 'modal', 'fullscreen'))
);
CREATE INDEX ctas_video_idx ON ctas (video_id);

CREATE TABLE cta_events (
  id         uuid PRIMARY KEY,
  cta_id     uuid NOT NULL REFERENCES ctas(id) ON DELETE CASCADE,
  viewer_id  uuid NULL REFERENCES viewers(id) ON DELETE SET NULL,
  event_type text NOT NULL,              -- 'shown' | 'clicked' | 'dismissed'
  occurred_at timestamptz NOT NULL DEFAULT now(),

  CONSTRAINT cta_events_type_check CHECK (event_type IN ('shown', 'clicked', 'dismissed'))
);
CREATE INDEX cta_events_cta_idx ON cta_events (cta_id, occurred_at DESC);

CREATE TABLE email_captures (
  id           uuid PRIMARY KEY,
  workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
  video_id     uuid NOT NULL REFERENCES videos(id) ON DELETE CASCADE,
  share_link_id uuid NULL REFERENCES share_links(id) ON DELETE SET NULL,
  viewer_id    uuid NULL REFERENCES viewers(id) ON DELETE SET NULL,
  email        citext NOT NULL,
  captured_at  timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX email_captures_workspace_video_idx ON email_captures (workspace_id, video_id, captured_at DESC);

cta_events is high-volume but not partitioned like video_view_events: CTA impressions are orders of magnitude less frequent than 5-second heartbeats, and its growth profile at 100k workspaces (modeled in 5.12) stays within a single-table B-tree's comfortable range for the product's data lifetime under the retention rules in Section 19.

5.4.12 Library & Feature Extras #

Folders (5.4.3) and brand kits (5.4.2) cover most of "Video Library, Folders, Collections & Brand Kit" (Section 18); "Collections" in that section's title refers to saved filtered views composed from existing folders/tag metadata at the application layer — Section 18 owns that UI model and introduces no additional table here.

5.4.13 Billing #

CREATE TABLE plans (
  id              uuid PRIMARY KEY,
  code            text NOT NULL,             -- 'free' | 'pro' | 'business'
  name            text NOT NULL,
  stripe_price_id_monthly text NULL,
  stripe_price_id_yearly  text NULL,
  limits          jsonb NOT NULL,            -- the full limit table from Section 21, machine-readable
  is_active       boolean NOT NULL DEFAULT true,
  created_at      timestamptz NOT NULL DEFAULT now(),

  CONSTRAINT plans_code_check CHECK (code IN ('free', 'pro', 'business'))
);
CREATE UNIQUE INDEX plans_code_unique ON plans (code);

CREATE TABLE subscriptions (
  id                     uuid PRIMARY KEY,
  workspace_id           uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
  plan_id                uuid NOT NULL REFERENCES plans(id) ON DELETE RESTRICT,
  stripe_customer_id     text NULL,
  stripe_subscription_id text NULL,
  status                 text NOT NULL DEFAULT 'active',
  -- 'active' | 'trialing' | 'past_due' | 'canceled' | 'incomplete'
  seat_count             integer NOT NULL DEFAULT 1,
  current_period_start   timestamptz NULL,
  current_period_end     timestamptz NULL,
  cancel_at_period_end   boolean NOT NULL DEFAULT false,
  created_at             timestamptz NOT NULL DEFAULT now(),
  updated_at             timestamptz NOT NULL DEFAULT now(),

  CONSTRAINT subscriptions_status_check CHECK (
    status IN ('active', 'trialing', 'past_due', 'canceled', 'incomplete')
  ),
  CONSTRAINT subscriptions_seat_count_positive CHECK (seat_count > 0)
);
CREATE UNIQUE INDEX subscriptions_workspace_unique ON subscriptions (workspace_id);
CREATE UNIQUE INDEX subscriptions_stripe_subscription_unique ON subscriptions (stripe_subscription_id)
  WHERE stripe_subscription_id IS NOT NULL;

CREATE TABLE usage_counters (
  workspace_id   uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
  counter_type   text NOT NULL,
  -- 'storage_bytes' | 'video_count' | 'seats' | 'ai_minutes' | 'export_count' | 'api_calls' —
  -- the 6-value set consumed by Section 21.4's usage-metering table
  period_start   date NOT NULL,
  -- billing-period-aligned for period-scoped counters (ai_minutes, export_count, api_calls);
  -- all-time counters (storage_bytes, video_count, seats) use the sentinel '1970-01-01'
  value          bigint NOT NULL DEFAULT 0,
  updated_at     timestamptz NOT NULL DEFAULT now(),

  CONSTRAINT usage_counters_type_check CHECK (
    counter_type IN ('storage_bytes', 'video_count', 'seats', 'ai_minutes', 'export_count', 'api_calls')
  ),
  PRIMARY KEY (workspace_id, counter_type, period_start)
);

CREATE TABLE usage_events (
  id           uuid PRIMARY KEY,
  workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
  type         text NOT NULL,             -- 'ai_minutes' | 'export' | 'api_call'
  quantity     numeric NOT NULL,          -- minutes for ai_minutes; 1 for export/api_call
  source_id    uuid NULL,                 -- the video id, export job id, or api_keys id that caused this event
  occurred_at  timestamptz NOT NULL DEFAULT now(),

  CONSTRAINT usage_events_type_check CHECK (type IN ('ai_minutes', 'export', 'api_call'))
);
CREATE INDEX usage_events_workspace_type_idx ON usage_events (workspace_id, type, occurred_at);
-- serves: usage_counters reconciliation job (recompute from events if drift is detected, Section 21.4)

CREATE TABLE invoices (
  id                uuid PRIMARY KEY,
  workspace_id      uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
  stripe_invoice_id text NOT NULL,
  status            text NOT NULL,        -- mirrors Stripe invoice status
  amount_due_cents  integer NOT NULL,
  currency          text NOT NULL DEFAULT 'usd',
  period_start      timestamptz NULL,
  period_end        timestamptz NULL,
  hosted_invoice_url text NULL,
  created_at        timestamptz NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX invoices_stripe_invoice_unique ON invoices (stripe_invoice_id);
CREATE INDEX invoices_workspace_idx ON invoices (workspace_id, created_at DESC);

usage_counters is a fast-read cache, keyed by the composite (workspace_id, counter_type, period_start) rather than a synthetic id, because every read and write against it is already addressed by that exact triple (Section 21's enforcement checkpoint looks up "this workspace's current value for this counter," never "counter row #N") — a surrogate key would only add an extra unique index with no query this table needs to serve. usage_events is the append-only ledger usage_counters is derived from and periodically reconciled against; for the two counters that are not additive-event-driven (video_count, which is recomputed from videos directly, and seats, mirrored from Stripe) usage_events is not the source of truth — only ai_minutes, export_count, and api_calls accumulate through usage_events inserts (Section 21.4's counter table states which source query is authoritative for each). Section 21 owns the reconciliation job and enforcement logic that reads these tables, including the nightly drift-correction job and the 1% drift alert threshold.

5.4.14 Public API, Webhooks & Integrations #

CREATE TABLE api_keys (
  id             uuid PRIMARY KEY,
  workspace_id   uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
  created_by     uuid NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
  name           text NOT NULL,
  key_hash       text NOT NULL,           -- SHA-256 of the full secret key
  display_prefix text NOT NULL,           -- first 12 chars, e.g. 'sk_live_9K8B', shown in UI
  scopes         text[] NOT NULL DEFAULT '{}',
  rate_limit_per_minute integer NOT NULL DEFAULT 300,
  last_used_at   timestamptz NULL,
  created_at     timestamptz NOT NULL DEFAULT now(),
  revoked_at     timestamptz NULL,

  CONSTRAINT api_keys_name_len CHECK (char_length(name) BETWEEN 1 AND 100)
);
CREATE UNIQUE INDEX api_keys_key_hash_unique ON api_keys (key_hash);
CREATE INDEX api_keys_workspace_idx ON api_keys (workspace_id) WHERE revoked_at IS NULL;

CREATE TABLE webhook_endpoints (
  id             uuid PRIMARY KEY,
  workspace_id   uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
  url            text NOT NULL,
  secret_enc     bytea NOT NULL,          -- signing secret, encrypted at rest (Section 22)
  event_types    text[] NOT NULL,         -- subscribed event names, Section 20
  is_active      boolean NOT NULL DEFAULT true,
  created_by     uuid NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
  created_at     timestamptz NOT NULL DEFAULT now(),
  updated_at     timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX webhook_endpoints_workspace_idx ON webhook_endpoints (workspace_id) WHERE is_active = true;

CREATE TABLE webhook_deliveries (
  id           uuid PRIMARY KEY,
  endpoint_id  uuid NOT NULL REFERENCES webhook_endpoints(id) ON DELETE CASCADE,
  event_type   text NOT NULL,
  payload      jsonb NOT NULL,
  status       text NOT NULL DEFAULT 'pending',  -- 'pending' | 'delivered' | 'failed' | 'exhausted'
  attempt_count smallint NOT NULL DEFAULT 0,
  last_attempt_at timestamptz NULL,
  last_response_status smallint NULL,
  next_retry_at timestamptz NULL,
  created_at   timestamptz NOT NULL DEFAULT now(),

  CONSTRAINT webhook_deliveries_status_check CHECK (status IN ('pending', 'delivered', 'failed', 'exhausted'))
);
CREATE INDEX webhook_deliveries_endpoint_idx ON webhook_deliveries (endpoint_id, created_at DESC);
CREATE INDEX webhook_deliveries_retry_idx ON webhook_deliveries (next_retry_at) WHERE status = 'pending';

CREATE TABLE integration_connections (
  id             uuid PRIMARY KEY,
  workspace_id   uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
  provider       text NOT NULL,           -- 'slack' | 'notion' | 'hubspot'
  connected_by   uuid NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
  access_token_enc bytea NOT NULL,
  refresh_token_enc bytea NULL,
  external_workspace_id text NULL,        -- provider-side team/workspace id
  config         jsonb NOT NULL DEFAULT '{}',
  status         text NOT NULL DEFAULT 'active',  -- 'active' | 'error' | 'disconnected'
  created_at     timestamptz NOT NULL DEFAULT now(),
  updated_at     timestamptz NOT NULL DEFAULT now(),

  CONSTRAINT integration_connections_provider_check CHECK (provider IN ('slack', 'notion', 'hubspot')),
  CONSTRAINT integration_connections_status_check CHECK (status IN ('active', 'error', 'disconnected'))
);
CREATE UNIQUE INDEX integration_connections_workspace_provider_unique
  ON integration_connections (workspace_id, provider);

5.4.15 System, Audit & Operations #

CREATE TABLE jobs_audit (
  id           uuid PRIMARY KEY,
  job_id       text NOT NULL,             -- BullMQ job.id, e.g. 'video:transcode:3'
  queue_name   text NOT NULL,
  workspace_id uuid NULL,                 -- no FK: must survive workspace hard-delete for audit trail
  status       text NOT NULL,             -- 'started' | 'completed' | 'failed' | 'dlq'
  attempt      smallint NOT NULL DEFAULT 1,
  error_message text NULL,
  started_at   timestamptz NOT NULL DEFAULT now(),
  finished_at  timestamptz NULL,

  CONSTRAINT jobs_audit_status_check CHECK (status IN ('started', 'completed', 'failed', 'dlq'))
);
CREATE INDEX jobs_audit_job_id_idx ON jobs_audit (job_id, started_at DESC);
CREATE INDEX jobs_audit_queue_status_idx ON jobs_audit (queue_name, status, started_at DESC);
-- serves: operational dashboards (Section 24), dead-letter triage

CREATE TABLE audit_events (
  id           uuid PRIMARY KEY,
  workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
  actor_id     uuid NULL REFERENCES users(id) ON DELETE SET NULL,
  action       text NOT NULL,             -- e.g. 'member.role_changed', 'brand_kit.updated'
  resource_type text NOT NULL,
  resource_id  uuid NULL,
  before_state jsonb NULL,
  after_state  jsonb NULL,
  ip_address   inet NULL,
  created_at   timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX audit_events_workspace_idx ON audit_events (workspace_id, created_at DESC);
CREATE INDEX audit_events_resource_idx ON audit_events (resource_type, resource_id);

CREATE TABLE retention_policies (
  id           uuid PRIMARY KEY,
  workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
  applies_to   text NOT NULL,             -- 'inactive_videos' | 'deleted_workspace' | 'deleted_account'
  retain_days  integer NOT NULL,
  created_at   timestamptz NOT NULL DEFAULT now(),

  CONSTRAINT retention_policies_applies_to_check CHECK (
    applies_to IN ('inactive_videos', 'deleted_workspace', 'deleted_account')
  ),
  CONSTRAINT retention_policies_retain_days_positive CHECK (retain_days > 0)
);
CREATE UNIQUE INDEX retention_policies_workspace_applies_to_unique ON retention_policies (workspace_id, applies_to);

CREATE TABLE deletion_requests (
  id                   uuid PRIMARY KEY,
  workspace_id         uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
  target_type          text NOT NULL,
  -- 'video' | 'workspace' | 'account' | 'gdpr_erasure'
  target_id            uuid NOT NULL,
  -- the id of the video/workspace/user this request targets, per target_type; not a declarative
  -- FK (it points at different tables depending on target_type, and the row must survive even
  -- after its target is gone — this is itself the audit trail of a deletion)
  reason               text NOT NULL,
  -- 'retention_policy' | 'user_trash_purge' | 'workspace_deletion' | 'account_deletion' | 'gdpr_erasure'
  status               text NOT NULL DEFAULT 'pending',
  -- 'pending' | 'notified' | 'scheduled' | 'in_progress' | 'verifying' | 'completed' |
  -- 'verification_failed' | 'cancelled' — the 8-value lifecycle status, Section 19.3.2
  requested_by         uuid NULL REFERENCES users(id) ON DELETE SET NULL,
  -- NULL for system-initiated requests (retention-driven deletion has no human requester)
  scheduled_for        timestamptz NOT NULL,
  notice_30d_sent_at   timestamptz NULL,
  notice_7d_sent_at    timestamptz NULL,
  notice_1d_sent_at    timestamptz NULL,
  purge_progress       integer NOT NULL DEFAULT 0,
  -- index into the ordered ten-stage purge workflow, 0..10 (Section 19.3.2)
  verification_report  jsonb NULL,
  -- the structured per-artifact-category pass/fail result written by the final verify stage (19.3.3)
  started_at           timestamptz NULL,
  completed_at         timestamptz NULL,
  created_at           timestamptz NOT NULL DEFAULT now(),
  updated_at           timestamptz NOT NULL DEFAULT now(),

  CONSTRAINT deletion_requests_target_type_check
    CHECK (target_type IN ('video', 'workspace', 'account', 'gdpr_erasure')),
  CONSTRAINT deletion_requests_reason_check CHECK (reason IN (
    'retention_policy', 'user_trash_purge', 'workspace_deletion', 'account_deletion', 'gdpr_erasure'
  )),
  CONSTRAINT deletion_requests_status_check CHECK (status IN (
    'pending', 'notified', 'scheduled', 'in_progress', 'verifying',
    'completed', 'verification_failed', 'cancelled'
  ))
);
CREATE INDEX deletion_requests_due_idx ON deletion_requests (scheduled_for)
  WHERE status IN ('pending', 'notified', 'scheduled');
-- serves: Section 19's purge-execution sweep
CREATE INDEX deletion_requests_target_idx ON deletion_requests (target_type, target_id);
-- serves: "is there already an open deletion request for this target" idempotency checks

CREATE TABLE custom_domains (
  id             uuid PRIMARY KEY,
  workspace_id   uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
  hostname       text NOT NULL,
  verification_token text NOT NULL,
  status         text NOT NULL DEFAULT 'pending_verification',
  -- 'pending_verification' | 'verified' | 'failed'
  tls_status     text NOT NULL DEFAULT 'pending',   -- 'pending' | 'issued' | 'failed'
  created_at     timestamptz NOT NULL DEFAULT now(),
  verified_at    timestamptz NULL,

  CONSTRAINT custom_domains_status_check CHECK (status IN ('pending_verification', 'verified', 'failed')),
  CONSTRAINT custom_domains_tls_status_check CHECK (tls_status IN ('pending', 'issued', 'failed'))
);
CREATE UNIQUE INDEX custom_domains_hostname_unique ON custom_domains (hostname);

CREATE TABLE feature_flags (
  id           uuid PRIMARY KEY,
  key          text NOT NULL,
  description  text NOT NULL,
  default_enabled boolean NOT NULL DEFAULT false,
  workspace_overrides jsonb NOT NULL DEFAULT '{}',  -- { "<workspace_id>": true|false }
  created_at   timestamptz NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX feature_flags_key_unique ON feature_flags (key);

CREATE TABLE notification_preferences (
  id           uuid PRIMARY KEY,
  user_id      uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
  channel      text NOT NULL,             -- 'comment_reply' | 'video_ready' | 'share_activity' | 'billing' | 'mention'
  email_enabled boolean NOT NULL DEFAULT true,
  in_app_enabled boolean NOT NULL DEFAULT true,

  CONSTRAINT notification_preferences_channel_check CHECK (
    channel IN ('comment_reply', 'video_ready', 'share_activity', 'billing', 'mention')
  )
);
CREATE UNIQUE INDEX notification_preferences_user_workspace_channel_unique
  ON notification_preferences (user_id, workspace_id, channel);

CREATE TABLE email_log (
  id           uuid PRIMARY KEY,
  workspace_id uuid NULL REFERENCES workspaces(id) ON DELETE SET NULL,
  user_id      uuid NULL REFERENCES users(id) ON DELETE SET NULL,
  to_email     citext NOT NULL,
  template     text NOT NULL,
  status       text NOT NULL DEFAULT 'sent',   -- 'sent' | 'bounced' | 'failed'
  provider_message_id text NULL,
  sent_at      timestamptz NOT NULL DEFAULT now(),

  CONSTRAINT email_log_status_check CHECK (status IN ('sent', 'bounced', 'failed'))
);
CREATE INDEX email_log_to_email_idx ON email_log (to_email, sent_at DESC);

5.4.16 Upload Sessions (Resumable Multipart Upload State) #

upload_sessions and upload_parts track the client-resumable S3-compatible multipart upload described in Section 9 — a recording chunked client-side, uploaded in 8 MB parts across possibly many app restarts and network outages, is not durable until the server has independently confirmed CompleteMultipartUpload. These are implementation-internal pipeline tables (not part of the core domain-entity list in the front matter), but they are still owned here, not narrated ad hoc in Section 9, because the same rule applies to every table in this document: one owner, no duplicate column lists.

CREATE TABLE upload_sessions (
  id                    uuid PRIMARY KEY,
  -- its OWN identity, minted independently at session creation — deliberately NOT the recording
  -- id it will eventually be attached to. An upload session can exist, accept parts, and even be
  -- abandoned before any recording row is ever created (Section 7.11.9's flow allows
  -- `purpose = "screenshot_original"` sessions with no recording at all), so borrowing the
  -- recording's id would make the FK direction backwards for that case and would also mean the
  -- client has to mint (or be handed) a recording id before it has anything to attach one to.
  -- Public ID prefix `ups_` (5.1.2).
  workspace_id          uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
  recording_id          uuid NULL REFERENCES recordings(id) ON DELETE SET NULL,
  -- set once the client links this session to a recording (Section 7.11.8's finalize call);
  -- NULL for the screenshot-upload purpose, which never has a recording
  purpose               text NOT NULL,
  -- 'recording' | 'screenshot_original' — what the assembled object becomes; drives which
  -- media_assets.kind the finalize step writes (5.4.4)
  media_kind            text NOT NULL,
  -- 'video_screen' | 'video_camera' | 'telemetry' | 'image' — the finer-grained content type used
  -- to pick bucket/content-type validation independent of `purpose`
  s3_upload_id          text NOT NULL,
  bucket_key            text NOT NULL,
  status                text NOT NULL DEFAULT 'open',
  -- 'open' | 'uploading' | 'stalled' | 'assembling' | 'completed' | 'aborted'
  total_parts_expected  integer NULL,             -- NULL until the client's finalize call reports the true count
  parts_completed       integer NOT NULL DEFAULT 0,
  created_at            timestamptz NOT NULL DEFAULT now(),
  updated_at            timestamptz NOT NULL DEFAULT now(),
  completed_at          timestamptz NULL,

  CONSTRAINT upload_sessions_purpose_check CHECK (purpose IN ('recording', 'screenshot_original')),
  CONSTRAINT upload_sessions_media_kind_check
    CHECK (media_kind IN ('video_screen', 'video_camera', 'telemetry', 'image')),
  CONSTRAINT upload_sessions_status_check CHECK (status IN (
    'open', 'uploading', 'stalled', 'assembling', 'completed', 'aborted'
  ))
);
CREATE INDEX upload_sessions_workspace_idx ON upload_sessions (workspace_id, created_at DESC);
CREATE INDEX upload_sessions_recording_idx ON upload_sessions (recording_id) WHERE recording_id IS NOT NULL;
CREATE INDEX upload_sessions_stalled_idx ON upload_sessions (status, updated_at) WHERE status IN ('open', 'uploading', 'stalled');
-- serves: the 7-day idle abort sweep (Section 9.6) and app-relaunch "resume this upload" lookup

CREATE TABLE upload_parts (
  upload_session_id uuid NOT NULL REFERENCES upload_sessions(id) ON DELETE CASCADE,
  part_number        integer NOT NULL,
  etag               text NULL,
  size_bytes         integer NOT NULL,
  status             text NOT NULL DEFAULT 'pending',   -- 'pending' | 'uploaded' | 'failed'
  attempts           integer NOT NULL DEFAULT 0,
  updated_at         timestamptz NOT NULL DEFAULT now(),

  CONSTRAINT upload_parts_status_check CHECK (status IN ('pending', 'uploaded', 'failed')),
  PRIMARY KEY (upload_session_id, part_number)
);

State machine (unchanged from the pipeline behavior in Section 9, restated here as the canonical column-level contract):

Status Meaning Entered from Exits to
open Multipart upload created server-side, no parts confirmed yet (initial) uploading
uploading At least one part confirmed, more expected open, stalled stalled, assembling
stalled A part exhausted its retry budget; awaiting network recovery or app relaunch uploading uploading (on retry), aborted (after 7 days idle)
assembling Client called .../complete; server is calling CompleteMultipartUpload and verifying uploading completed, stalled (assembly failed — client re-uploads the disputed part)
completed Server confirmed multipart assembly; local chunks may now be deleted client-side assembling (terminal)
aborted Abandoned by user or expired; storage reclaimed stalled, open (terminal)

Local chunks are deleted client-side only after the server's .../complete response reports status: "completed" — never on a successful-looking part PUT alone — which is what backs the stated product invariant that a recording is never lost to a network failure (Section 9.1/9.6): local data persists through any number of failed attempts or restarts, and is removed only once the server has independently verified durable assembly.

5.5 Entity-Relationship Overview #

erDiagram
  USERS ||--o{ WORKSPACE_MEMBERS : "has"
  WORKSPACES ||--o{ WORKSPACE_MEMBERS : "has"
  WORKSPACES ||--o{ WORKSPACE_INVITES : "issues"
  WORKSPACES ||--|| BRAND_KITS : "has one"
  WORKSPACES ||--o{ FOLDERS : "contains"
  FOLDERS ||--o{ FOLDER_PERMISSIONS : "grants"
  USERS ||--o{ FOLDER_PERMISSIONS : "may be principal of"
  WORKSPACES ||--o{ VIDEOS : "owns"
  FOLDERS ||--o{ VIDEOS : "organizes"
  VIDEOS ||--o{ RECORDINGS : "sourced from"
  RECORDINGS ||--o| CURSOR_TELEMETRY_BLOBS : "captured with"
  RECORDINGS ||--o| UPLOAD_SESSIONS : "assembled from"
  VIDEOS ||--o{ EDIT_DECISION_LISTS : "versioned edits"
  EDIT_DECISION_LISTS ||--o{ REDACTION_REGIONS : "burns in"
  VIDEOS ||--o{ RENDITIONS : "delivered as"
  VIDEOS ||--o{ MEDIA_ASSETS : "stores"
  VIDEOS ||--o| TRANSCRIPTS : "transcribed"
  TRANSCRIPTS ||--o{ TRANSCRIPT_SEGMENTS : "contains"
  VIDEOS ||--o{ CHAPTERS : "chaptered"
  VIDEOS ||--o{ SHARE_LINKS : "shared via"
  SHARE_LINKS ||--o{ SHARE_LINK_RECIPIENTS : "personalized for"
  SHARE_LINKS ||--o{ SHARE_AUDIT_EVENTS : "audited"
  VIDEOS ||--o{ VIDEO_VIEW_EVENTS : "viewed"
  VIDEO_VIEW_EVENTS }o--|| VIDEO_VIEW_DAILY : "rolls up to"
  VIDEOS ||--o{ COMMENTS : "discussed"
  VIDEOS ||--o{ CTAS : "prompts"
  WORKSPACES ||--|| SUBSCRIPTIONS : "subscribes"
  SUBSCRIPTIONS }o--|| PLANS : "on plan"
  WORKSPACES ||--o{ API_KEYS : "issues"
  WORKSPACES ||--o{ WEBHOOK_ENDPOINTS : "configures"

5.6 Growth Estimates & Scale Implications #

Modeled assumptions: 2 workspaces average per user account; average workspace records 15 videos/month after month 1; each video averages 40 views over its lifetime; each view generates ~24 heartbeat events (a 2-minute average watch time at 5s intervals) plus ~3 discrete events.

| Table | @ 1k workspaces | @ 10k workspaces | @ 100k workspaces | Implication | |---|---|---|---| | workspaces | 1,000 | 10,000 | 100,000 | trivial; b-tree on owner_id/slug stays shallow | | workspace_members | ~4,500 | ~45,000 | ~450,000 | trivial | | videos | ~180,000 | ~1.8M | ~18M | videos_workspace_active_idx keeps per-workspace listing O(log n); partitioning NOT needed at this scale | | recordings + media_assets | ~200,000 / ~600,000 | ~2M / ~6M | ~20M / ~60M | object storage cost dominates, not Postgres row count; media_assets_storage_key_unique index sized ~9 GB at 100k tier, fits in shared_buffers on a mid-size instance | | edit_decision_lists | ~360,000 | ~3.6M | ~36M | JSONB documents average ~5 KB; table ~180 GB at 100k tier — largest non-partitioned table; candidate for time-based archival to cold storage past Section 19's retention window if it becomes an operational concern | | cursor_telemetry_blobs | 200,000 rows (metadata only) | 2M | 20M | rows are tiny (metadata only); actual bytes live in object storage, unbounded by Postgres capacity | | transcript_segments | ~7.2M | ~72M | ~720M | first table requiring partitioning consideration beyond video_view_events at the 100k tier; plan is to range-partition by created_at (inherited from parent transcript) if p99 query latency on transcript_segments_transcript_time_idx degrades past the Section 27 budget — not needed at 1k/10k | | video_view_events | ~4.3M/month | ~43M/month | ~430M/month | already partitioned monthly from day one (5.4.10); at 100k tier each monthly partition is ~430M rows / ~90 GB — within a single partition's comfortable operating range for the indexes defined, but this is the ceiling at which sub-partitioning by workspace_id hash would be evaluated | | video_view_daily | ~54,000/month | ~540,000/month | ~5.4M/month | trivial, rollup keeps this small permanently | | video_engagement_curve | ≤180,000 rows total (capped at 1800/video) | ≤1.8M | ≤18M | bounded by design (Section 16's 1800-bucket cap) — this table's size is a function of active video count, not view count, so it never grows unboundedly | | comments | ~90,000 | ~900,000 | ~9M | trivial | | api_keys | ~150 (Business only) | ~1,500 | ~15,000 | trivial |

Index footprint implication: at the 100k-workspace tier, the two largest index sets are on edit_decision_lists (GIN index on document) and video_view_events partitions. The GIN index on edit_decision_lists.document is created with jsonb_path_ops specifically (not the default jsonb_ops) because it produces a substantially smaller index (roughly 3-4x smaller in practice) at the cost of only supporting @> containment queries — which is the only query shape this index needs to serve (5.4.5).

Partition footprint implication: at the 100k tier, video_view_events carries roughly 12-13 live monthly partitions at any time (13-month retention, 5.4.10) at ~90 GB each — a working set of roughly 1.1-1.2 TB for this table alone, which is the primary driver of the storage-tier sizing guidance in Section 27.

5.7 Migration Strategy (drizzle-kit) #

File naming: drizzle-kit generates timestamped migration files (drizzle/migrations/0001_initial_schema.sql, 0002_add_redaction_regions.sql, ...) via drizzle-kit generate. The numeric prefix is drizzle-kit's own strictly-increasing sequence number — migrations are never renumbered or reordered after merge, and a migration file, once merged to the main branch, is never edited (a mistake is corrected by a new forward migration, not by rewriting history).

Ordering: migrations apply in filename order via drizzle-kit migrate, tracked in drizzle-kit's own __drizzle_migrations bookkeeping table. CI (Section 25/26) runs drizzle-kit migrate against a fresh database as part of every deploy pipeline; a migration that fails to apply blocks the deploy.

Expand/contract pattern for zero-downtime changes: because apps/api and apps/worker are deployed as multiple replicas with rolling restarts, a schema change and the code that depends on it are never deployed in the same instant across the fleet — for any window of a rolling deploy, old code and new code run against the same database. Every migration that changes a column's meaning follows three phases, each a separate migration/deploy cycle:

  1. Expand: add the new column/table alongside the old one, nullable or defaulted, written by both old and new code paths (dual-write). Old code is untouched and keeps working.
  2. Migrate: backfill the new column from the old one in batches (a one-off script, never a long-running transaction that locks the table), then deploy application code that reads from the new column exclusively while still dual-writing.
  3. Contract: once all replicas run code that no longer reads the old column, and a full retention-window's worth of time has passed with no rollback need, drop the old column in a final migration.

Example: renaming videos.title to videos.display_title would never be a single ALTER TABLE ... RENAME COLUMN deployed with the code that reads the new name — it would be ADD COLUMN display_title, a backfill, a deploy that dual-writes both, a deploy that reads only display_title, then a final migration dropping title.

Destructive migration gate: any migration containing DROP TABLE, DROP COLUMN, or a NOT NULL addition without a DEFAULT on a non-empty table is tagged -- DESTRUCTIVE in a header comment (a CI lint step, scripts/check-destructive-migrations.ts, greps for DROP COLUMN/DROP TABLE/ALTER COLUMN ... SET NOT NULL and fails the PR unless that header comment is present) and requires a second named approver on the pull request in addition to normal review, per Section 25's review policy. DROP TABLE/DROP COLUMN migrations may only ship as the "contract" phase of the expand/contract pattern above — never as a same-PR companion to the code change that stops using the column.

5.8 Seed Data for Local Development #

packages/db/src/seed.ts, run via pnpm --filter @reelay/db seed against a local database, inserts:

Plans (mirrors Section 21's limit table into plans.limits JSONB):

await db.insert(plans).values([
  {
    id: uuidv7(), code: "free", name: "Free",
    stripePriceIdMonthly: null, stripePriceIdYearly: null,
    limits: {
      seats: 1, maxRecordingLengthMs: 5 * 60 * 1000, libraryVideoCap: 25,
      storageQuotaBytes: 2 * 1024 ** 3, watermark: true, aiAutoEditTier: "basic",
      aiChaptersAndSummaries: false, fillerWordRemoval: false,
      viewerAnalyticsTier: "aggregate", customDomain: false,
      retentionDays: 90, transcodePriority: "standard", apiAccess: false,
      exportMaxResolution: "720p",
    },
  },
  {
    id: uuidv7(), code: "pro", name: "Pro",
    stripePriceIdMonthly: "price_pro_monthly_placeholder", stripePriceIdYearly: "price_pro_yearly_placeholder",
    limits: {
      seats: "per_seat", maxRecordingLengthMs: null, softCapMs: 4 * 60 * 60 * 1000,
      libraryVideoCap: null, storageQuotaBytesPerSeat: 250 * 1024 ** 3, watermark: false,
      aiAutoEditTier: "full", aiChaptersAndSummaries: true, fillerWordRemoval: true,
      viewerAnalyticsTier: "full_per_viewer", customDomain: false,
      retentionDays: 730, transcodePriority: "standard", apiAccess: false,
      exportMaxResolution: "4k",
    },
  },
  {
    id: uuidv7(), code: "business", name: "Business",
    stripePriceIdMonthly: "price_business_monthly_placeholder", stripePriceIdYearly: "price_business_yearly_placeholder",
    limits: {
      seats: "per_seat", maxRecordingLengthMs: null, softCapMs: 4 * 60 * 60 * 1000,
      libraryVideoCap: null, storageQuotaBytesPerSeat: 1024 ** 4, watermark: false,
      aiAutoEditTier: "full", aiChaptersAndSummaries: true, fillerWordRemoval: true,
      viewerAnalyticsTier: "full_per_viewer", customDomain: true,
      retentionDays: null, transcodePriority: "priority", apiAccess: true,
      exportMaxResolution: "4k",
    },
  },
]);

Default auto-edit presets (auto_edit_presets with is_system = true, workspace_id = NULL, parameters matching the defaults specified in Section 10). The three seed presets differ only in zoom.defaultLevel and zoom.maxLevel — every other zoom-timing field, and the entire motion/framing parameter set, is identical across all three and pinned to the engine-wide constants Section 10 fixes globally (omega0 9.0, zeta 1.0, oneEuroMinCutoff 1.0, oneEuroBeta 0.007, deadzonePx 2, safeRectPct 60, hysteresisBandPct 8). These are physics/framing constants that describe how the camera moves and stays composed — they are calibrated once for the engine as a whole, not a per-preset "personality" knob; only the zoom aggressiveness itself (how far in, how far the ceiling goes) is what actually differs between "Subtle," "Balanced," and "Energetic":

const SHARED_MOTION = { omega0: 9.0, zeta: 1.0, oneEuroBeta: 0.007, oneEuroMinCutoff: 1.0, deadzonePx: 2 };
const SHARED_FRAMING = { safeRectPct: 60, hysteresisBandPct: 8 };
const SHARED_ZOOM_TIMING = { minHoldMs: 1200, minGapMs: 800, leadInMs: 400, easeOutMs: 600, coalesceWindowMs: 900 };

await db.insert(autoEditPresets).values([
  {
    id: uuidv7(), workspaceId: null, name: "Subtle", version: 1, isSystem: true,
    parameters: {
      zoom: { defaultLevel: 1.3, maxLevel: 1.8, ...SHARED_ZOOM_TIMING },
      motion: SHARED_MOTION,
      framing: SHARED_FRAMING,
    },
  },
  {
    id: uuidv7(), workspaceId: null, name: "Balanced (Default)", version: 1, isSystem: true,
    parameters: {
      zoom: { defaultLevel: 1.6, maxLevel: 2.5, ...SHARED_ZOOM_TIMING },
      motion: SHARED_MOTION,
      framing: SHARED_FRAMING,
    },
  },
  {
    id: uuidv7(), workspaceId: null, name: "Energetic", version: 1, isSystem: true,
    parameters: {
      zoom: { defaultLevel: 1.9, maxLevel: 2.5, ...SHARED_ZOOM_TIMING },
      motion: SHARED_MOTION,
      framing: SHARED_FRAMING,
    },
  },
]);

Default background presets (stored as a feature_flags-adjacent static catalog seeded into a JSONB row consumed by packages/ui; not a dedicated table, since backgrounds are a closed, versioned set shipped with the app rather than user-editable rows — Section 10 owns their specification):

await db.insert(featureFlags).values({
  id: uuidv7(), key: "background_preset_catalog", description: "Seeded catalog of auto-edit background presets",
  defaultEnabled: true,
  workspaceOverrides: {},
});

A local dev workspace for manual testing: one seed users row (dev@reelay.local, password devpassword123! hashed with the Section 6.2 Argon2id parameters), one workspaces row owned by that user on the business plan (so every gated feature is exercisable locally), and a workspace_members row with role = 'owner'.

5.9 Summary #

This section is the single source of truth for storage. Every other section that persists data does so through the tables, constraints, and indexes defined above — a drafter or implementer who needs a new persisted field extends a table here (via the migration process in 5.7), never invents a parallel, undocumented store.


6. Identity, Workspaces, Roles & Permissions #

This section owns authentication, session management, workspace membership, and the complete permission model. The underlying tables (users, sessions, oauth_accounts, mfa_credentials, workspaces, workspace_members, workspace_invites, folder_permissions, api_keys) are defined in Section 5; this section defines the behavior built on top of them.

6.1 Email + Password Authentication #

6.1.1 Password hashing #

Passwords are hashed with Argon2id, parameters m=19456 KiB, t=2, p=1 (per the locked decision) — the OWASP-recommended baseline for Argon2id in 2025+, balancing brute-force resistance against acceptable request latency (~150-250 ms per hash on typical API server hardware). The node-argon2 (or equivalent WASM-backed) library is used server-side only; a password never reaches the database, logs, or any telemetry system in plaintext or as a weaker hash.

users.password_hash stores the full Argon2id encoded string (algorithm, version, parameters, salt, and hash all embedded per the standard $argon2id$v=19$m=19456,t=2,p=1$<salt>$<hash> format), so a future parameter upgrade can be detected and the hash transparently re-computed on next successful login (a "rehash on login if parameters are stale" check, comparing the stored string's embedded parameters against the current server-side constants).

6.1.2 Password rules #

Rule Value
Minimum length 10 characters
Maximum length 256 characters (bcrypt-era 72-byte limits do not apply to Argon2id, but an upper bound prevents hash-cost DoS via pathologically long inputs)
Composition requirement none (length-based policy per NIST SP 800-63B; complexity rules are not enforced)
Breach-list check REQUIRED at signup and password change
Common-password check rejected if the password appears in the breach list, regardless of complexity

Breach-list check: the password's SHA-1 hash is computed client-side-adjacent (server-side, never transmitting the plaintext further than the TLS-terminated request body) and the first 5 hex characters are sent to the Have I Been Pwned k-anonymity range API (GET https://api.pwnedpasswords.com/range/{first5}); the full hash suffix is matched against the returned list locally. If the check fails to complete (network error) within a 2-second timeout, the signup/change proceeds — breach-checking degrades open, because blocking account creation on a third-party dependency's availability is a worse failure mode than occasionally allowing a breached-but-otherwise-valid password through. A password found in the breach list is rejected with error code password_breach_detected (Section 7.6 error envelope) and the message instructs the user to choose a different password; the specific breach source is never disclosed.

6.1.3 Signup flow #

POST /v1/auth/signup{ email, password, displayName }. Signup does not create a workspace, and this endpoint takes no workspaceName field or any other workspace-shaped input. It creates exactly one thing: a users row. Workspace creation is a separate, subsequent call — POST /v1/workspaces (6.7.1) — made by the client only after the account exists (and, in the common case, after the user has verified they can log in). This separation is deliberate: it keeps the enumeration-resistant 202 contract below simple to reason about (a signup response never has to carry or omit workspace-shaped data depending on whether the account was new or pre-existing), and it means the same signup endpoint serves both "sign up and create your first workspace" and "sign up because you were invited to an existing workspace" (6.7.4) without a conditional body shape — the invite-acceptance flow never touches this endpoint's request contract at all. Server steps:

  1. Validate shape via the shared Zod schema (Section 7).
  2. Normalize email (lowercase handled transparently by citext; trim whitespace).
  3. Check breach list (6.1.2); reject password_breach_detected if hit.
  4. Check for an existing users row with this email (including soft-deleted — a soft-deleted account's email is NOT immediately reusable; see 6.1.7 for the account-deletion email-reuse rule).
  5. If an account already exists, respond with the SAME 202 Accepted shape and generic message as a successful signup ("Check your email to continue") — this endpoint is enumeration-resistant by design: an attacker cannot distinguish "new account created" from "email already registered" by response shape, status code, or timing (a constant minimum response latency of 300 ms is enforced via a floor on the handler, so the presence/absence of the Argon2id hashing step does not leak through timing). In the already-registered case, an email is sent to the existing address stating that a signup attempt was made and offering a password-reset link instead — this is the account owner's notification path, not the requester's.
  6. Otherwise, hash the password, create the users row (email_verified_at = NULL), send a verification email with a single-use time-limited token (6.4.1), and respond 202 Accepted with the same generic body.

6.1.4 Login flow #

POST /v1/auth/login{ email, password }.

  1. Look up users by email. If not found, or deleted_at IS NOT NULL, or the password does not match: respond 401 with error code invalid_credentialsthe same code and message in every one of these cases, so an attacker cannot distinguish "no such account" from "wrong password" from "deleted account."
  2. If locked_until is in the future: respond 401 invalid_credentials as well (never a distinct "account locked" message pre-authentication — that would itself be an enumeration signal that this account exists and has recently failed logins). A separate, authenticated-context surface (the login page can show a soft "too many attempts, try again later" hint keyed only off client-side rate-limit response headers, never off account-specific state).
  3. On password mismatch: increment failed_login_count. At 10 consecutive failures within a rolling 15-minute window, set locked_until = now() + 15 minutes and reset the counter.
  4. On success: reset failed_login_count to 0, clear locked_until, update last_login_at, and:
    • If mfa_credentials exists for this user, respond 200 with a short-lived (5-minute) mfaChallengeToken and no session yet — the client must complete 6.3.3 before a session is issued.
    • Otherwise, create a sessions row (6.3) and respond 200 with the session cookie set.
  5. Rate limiting: POST /v1/auth/login is limited to 10 requests/minute per IP AND 5 requests/minute per email, whichever is hit first. This section is the sole source for every /v1/auth/* rate limit in the product — these thresholds are keyed by IP and by email, never by plan: an unauthenticated caller has no plan to key against, and a plan-keyed limit on an auth endpoint would itself be an enumeration signal (a response that varies by which workspace's plan a given email happens to belong to leaks account existence). Section 7.9's general rate-limit mechanism (token buckets, X-RateLimit-* headers, 429 + Retry-After) supplies the transport; the numbers themselves live only here, are never restated as a plan-tier row in Section 7.9's per-plan limit table, and every other section that touches auth rate limiting references this subsection by number rather than repeating the figures.

6.1.5 Password reset flow #

POST /v1/auth/password-reset/request{ email }. Always responds 202 Accepted with an identical generic body regardless of whether the email is registered (enumeration-resistant, same principle as 6.1.3). If registered and not soft-deleted, a reset token is generated: a cryptographically random 32-byte value, SHA-256 hashed for storage (reusing the sessions-style pattern but as a one-off row — reset tokens are stored inline on a lightweight internal table not listed among the core entities because it is purely transient bookkeeping, implemented as a Redis key password-reset:<sha256(token)> = userId with a 1-hour TTL rather than a Postgres row, since it needs no durability beyond that window and benefits from Redis's native expiry).

POST /v1/auth/password-reset/confirm{ token, newPassword }.

  1. Look up the Redis key; if missing or expired, respond 400 invalid_or_expired_token.
  2. Validate newPassword against 6.1.2 rules including breach check.
  3. Update users.password_hash, delete the Redis key immediately (single-use — a second confirm with the same token always fails, even within the TTL window).
  4. Revoke every existing session for this user (UPDATE sessions SET revoked_at = now(), revoked_reason = 'password_change' WHERE user_id = $1 AND revoked_at IS NULL) — a password reset is a security event that must invalidate any session an attacker may have established.
  5. Send a confirmation email to the account's address noting the password was changed, with a "this wasn't me" link that starts a support-mediated recovery flow (out of scope for this document beyond noting its existence).

6.1.6 Enumeration resistance summary #

Endpoint Behavior that would leak account existence What this spec requires instead
Signup 409 Conflict on duplicate email Always 202, generic message, side-channel email to existing owner
Login Distinct "no account" vs "wrong password" errors Single invalid_credentials code for both
Login "Account locked" message pre-auth Same invalid_credentials code; lockout only visible via rate-limit headers
Password reset request 404 on unregistered email Always 202, generic message
Email verification resend 404 on unregistered/already-verified email Always 202, generic message

6.1.7 Account deletion and email reuse #

When a users row is soft-deleted (Section 19 owns the request/grace-period/purge lifecycle), its email is NOT released for immediate re-signup — the unique index users_email_unique is scoped WHERE deleted_at IS NULL, meaning a soft-deleted row does not block a new signup with the same email even before final purge. This is intentional: it allows a genuinely departed user's address to be reclaimed by a new signup (e.g. a company email being reassigned) without waiting for the full purge cycle, while the soft-deleted row itself remains for the audit and grace-period-restore flow in Section 19.

6.2 Google OAuth 2.0 (PKCE) #

6.2.1 Flow #

  1. GET /v1/auth/google/start — server generates a PKCE code_verifier (43-128 char random string) and code_challenge (BASE64URL(SHA256(code_verifier))), and a random state value. Both code_verifier and state are stored server-side in a short-lived (10-minute) Redis key keyed by a session-scoped nonce cookie (oauth_flow_id, httpOnly, Secure, SameSite=Lax, 10-minute expiry) — never in a client-readable cookie, and never round-tripped through the client beyond the opaque oauth_flow_id. The server redirects to Google's authorization endpoint with code_challenge, code_challenge_method=S256, state, scope=openid email profile, and the registered redirect_uri.
  2. Google redirects back to GET /v1/auth/google/callback?code=...&state=....
  3. The server reads oauth_flow_id from the cookie, looks up the stored state/code_verifier in Redis, and rejects the callback outright (400 invalid_oauth_state) if: the cookie is missing, the Redis key has expired, or the returned state does not exactly match the stored value. This closes CSRF on the OAuth callback — an attacker cannot trick a victim into linking the attacker's Google account to the victim's session, because the flow can only complete with the exact code_verifier this specific server-side flow generated.
  4. The server exchanges code + code_verifier for tokens at Google's token endpoint, validates the returned ID token's signature, issuer, audience, and expiry, and extracts sub, email, email_verified, name, picture.
  5. The Redis key and oauth_flow_id cookie are deleted immediately (single-use, mirroring 6.1.5).

6.2.2 Account linking rules #

Situation Behavior
No oauth_accounts row for this (provider, provider_account_id), no users row for this email Create a new users row (email_verified_at = now() if Google reports email_verified: true, else NULL — see 6.2.3) and a new oauth_accounts row linked to it. Create a session, log in.
No oauth_accounts row for this (provider, provider_account_id), but a users row already exists with this email Auto-link: create the oauth_accounts row against the existing users.id. This is safe specifically because Google is the identity source of truth for the email and the flow only reaches this branch after Google's own verification — the user is not asked to additionally confirm via a password prompt, since proving control of the Google account that owns this verified email is itself sufficient proof. If the existing account has email_verified_at IS NULL (e.g. it was created via password signup and never verified), this auto-link sets email_verified_at = now() as a side effect.
oauth_accounts row already exists for this (provider, provider_account_id) Log in as the linked user directly; update provider_email if it has changed on Google's side.
Google returns email_verified: false The account is created (or the login proceeds) but treated as unverified exactly as in 6.4 — email_verified_at stays NULL. The user is shown the same unverified-account restrictions (6.4.2) until they complete Reelay's own email verification flow, which for an OAuth-only account sends a verification email to the same address and accepts either that link or a future Google login where Google itself reports the email as verified.

Auto-linking by verified email is a deliberate, narrow exception to "never derive access from implicit matching" (6.10): it is scoped to exactly one email, proven by a live OAuth assertion from the specific provider at the specific moment of login, not a standing domain-wide policy — it is categorically different from SSO/SCIM's domain-based auto-provisioning, which this product explicitly defers.

6.3 Session Model #

  • sessions.token_hash stores SHA-256(opaque_token); the opaque token itself (256 bits of crypto.randomBytes, base64url-encoded) is set in a cookie named reelay_session.
  • Cookie attributes: httpOnly; Secure; SameSite=Lax; Path=/; Max-Age=2592000 (30 days). SameSite=Lax (not Strict) because top-level navigation into a shared video link or an OAuth-callback redirect must carry the cookie for the dashboard's own domain; Strict would break the "click a link in Slack, land logged-in" experience. The cookie is never readable by JavaScript (httpOnly), so it is not a viable target for XSS token theft even if a script injection vulnerability existed elsewhere.
  • Sliding expiry: every authenticated request that hits the API updates sessions.last_seen_at and, if more than 24 hours have elapsed since expires_at was last extended, re-issues expires_at = now() + 30 days (both server-side row and cookie Max-Age refreshed). This bounds the write amplification of updating the session row on every single request while still keeping active users perpetually logged in and inactive sessions expiring on schedule.

6.3.2 Server-side session table, revocation, "log out all devices" #

Every session is a durable sessions row (Section 5.4.1), not a stateless signed token — this is what makes server-side revocation possible at all. DELETE /v1/auth/logout sets revoked_at = now(), revoked_reason = 'logout' on the current session only. POST /v1/auth/logout-all-devices revokes every session for the user (revoked_reason = 'logout_all') except, optionally, the requesting session if the client passed keepCurrent: true in the request body. A revoked or expired session fails auth on its very next use (401 session_expired) — there is no grace window, because the whole point of revocation is immediate effect.

The dashboard's "active sessions" settings screen lists sessions WHERE user_id = $1 AND revoked_at IS NULL ORDER BY last_seen_at DESC, showing user_agent, a coarse location derived from ip_address (city-level geo lookup, never stored beyond the IP itself), and last_seen_at, with a per-row "revoke this device" action.

6.3.3 MFA step-up during login #

When POST /v1/auth/login succeeds password-wise but the user has an mfa_credentials row (6.5), no sessions row is created yet. The client receives mfaChallengeToken (JWT, 5-minute expiry, sub = user_id, purpose = mfa_challenge, signed with the API's session-signing key) and must call POST /v1/auth/mfa/verify with { mfaChallengeToken, code } (a 6-digit TOTP code, or a recovery code — see 6.5). Only on success is the sessions row created and the cookie set. This token is never a valid Authorization: Bearer credential for any other endpoint — the API's auth middleware rejects any token whose purpose claim is not appropriate for the route it is presented to.

6.3.4 Short-lived JWT for the editor client #

The timeline editor (Section 11) and other latency-sensitive client-side code paths (the desktop app, Section 8) do not want to hit the session table on every action. The API issues a short-lived JWT (POST /v1/auth/editor-token, requires a valid session cookie) with a 10-minute expiry, sub = user_id, workspaceId (the currently active workspace), and the resolved role for that workspace at issuance time. This JWT is used as a Bearer token for high-frequency editor/desktop calls (e.g. autosave, telemetry upload progress) where the marginal cost of a session-table lookup per request would add latency; it carries no more authority than the session it was derived from, expires quickly enough that a stale cached role is never a meaningful exposure window (Section 27's performance budget assumes it is refreshed silently by the client every 8 minutes), and is re-verified against the live session and membership on any action with billing, deletion, or permission-management side effects — the JWT is a fast path for low-stakes, high-frequency actions, never the sole authority for a consequential one.

6.4 Email Verification Gate #

6.4.1 Verification token #

Sent at signup (6.1.3) and resendable via POST /v1/auth/verify-email/resend (enumeration-resistant per 6.1.6). The token is a single-use, time-limited value: 32 random bytes, SHA-256 hashed and stored as a Redis key email-verify:<sha256(token)> = userId with a 24-hour TTL. GET /v1/auth/verify-email?token=... looks up the key, sets users.email_verified_at = now(), deletes the key, and redirects to the dashboard with a success toast. An expired or already-used token yields 400 invalid_or_expired_token and a "resend" affordance.

6.4.2 What an unverified user can do #

Email verification exists specifically as an anti-spam gate on public sharing — it is deliberately not a gate on product exploration, because forcing verification before any use increases signup friction for no security benefit (an unverified account cannot spam anyone until it can create a public artifact).

Action Unverified user
Sign up, log in, use the dashboard allowed
Record and edit videos allowed
Create a workspace allowed
Invite members allowed
Create a share link with visibility private or workspace allowed
Create a share link with visibility link or public blocked403 email_unverified
Embed a video (which implies a public-reachable player) blocked — same code
Use the public API blocked (also gated separately by plan, 6.6)
Enable a custom domain blocked

The block is enforced server-side in the share-link creation/visibility-change handler (Section 14 owns the endpoint), not merely hidden in the UI — a direct API call from an unverified account attempting link/public visibility receives the same 403 email_unverified. email_unverified is the one and only error code for this condition anywhere in the product — Section 7.6 owns the error catalogue and lists no email_verification_required variant; that name never existed as a second code, only as this one, spelled consistently everywhere it is returned.

6.5 Multi-Factor Authentication (TOTP) #

  • Enrolment: POST /v1/auth/mfa/enroll generates a random 160-bit TOTP secret, returns it as both a otpauth:// URI (for QR-code rendering client-side) and the raw base32 secret (manual entry fallback). The secret is held in a pending state (not yet written to mfa_credentials) until confirmed: POST /v1/auth/mfa/enroll/confirm with a valid current code writes the encrypted secret and 10 freshly generated recovery codes (10 random 10-character alphanumeric codes, each individually hashed with SHA-256 and stored as the recovery_codes_enc JSON array, encrypted at rest per Section 22) to mfa_credentials, and returns the recovery codes to the client exactly once — they are never retrievable again after this response; a "regenerate recovery codes" action invalidates and replaces the full set.
  • Verification: TOTP codes use the standard 30-second window, 6 digits, with a ±1 step tolerance (accepts the previous and next 30-second window to absorb clock drift) — no wider, since a larger tolerance meaningfully weakens the brute-force resistance of a 6-digit code.
  • Recovery codes: each is single-use; using one immediately invalidates it (removed from the stored array) and triggers an email notification ("a recovery code was used to sign in") since this is a signal worth the account owner's attention.
  • Step-up requirements: beyond initial login (6.3.3), a fresh MFA code (or recovery code) is required — even for an already-authenticated session — before: disabling MFA, changing the account password from within settings (as opposed to the reset-token flow, which is its own proof of control), generating an API key, transferring workspace ownership, and deleting a workspace. This "step-up" check is a requireStepUp(action) middleware that looks for a recent (last 5 minutes) successful MFA verification recorded in the session's Redis-cached state; if absent, it returns 403 step_up_required with a stepUpChallenge: true flag the client uses to prompt for a fresh code without a full re-login.
  • MFA is available to all plans and both roles-with-billing-access and not — it is a security feature, never plan-gated.

6.6 API Keys (Public API, Business Plan Only) #

Property Value
Format sk_live_<32 random base62 chars> (sk_test_ prefix reserved for a future sandbox mode, not implemented at launch)
Storage key_hash = SHA-256(full key); the full key is shown to the user exactly once at creation, identical single-reveal semantics to MFA recovery codes
Display prefix first 12 characters of the full key (sk_live_9K8B), stored in display_prefix, shown in the keys list UI so an admin can distinguish keys without ever re-exposing the secret
Scopes an array of snake_case scope strings (e.g. videos:read, videos:write, share_links:write, analytics:read, webhooks:manage) checked by authorize() (6.9) exactly like a workspace role, via a synthetic "API key actor"
Rotation creating a new key does not affect existing keys; "rotate" in the UI is implemented as create-new + a countdown before revoking the old one, so callers have a migration window
Revocation revoked_at set; immediate effect, checked on every request (no caching of key validity beyond the same short TTL as the Redis session cache, ≤60s)
Per-key rate limit rate_limit_per_minute, default 300, configurable per key by a workspace admin/owner up to a plan-wide ceiling (Section 7.9)
Plan gate key creation is blocked with 403 plan_upgrade_required on any plan other than Business; an existing Business workspace that downgrades has its keys disabled (requests 403 feature_not_available) but not deleted — reactivating the plan re-enables them without regenerating, since the underlying capability is a creation-time gate consistent with the iron rule in Section 21 (downgrades restrict new creation/access to the gated feature; they do not destroy the configuration)

API keys are created and managed only by owner and admin roles (permission matrix, 6.8).

Effective role is fixed at creation, not dynamically re-derived. An API key's authority is admin-equivalent, narrowed only by its scopes array, computed once when the key is created and never recomputed against the creator's current membership afterward. Concretely: authorize() (6.9) treats every api_key-typed Actor as carrying an implicit admin role ceiling — the same ceiling regardless of which role the creating user happened to hold at creation time, and regardless of any role change, demotion, or removal that user later experiences — with the actual permitted action set narrowed down from that ceiling by apiKeyScopeCovers(actor.apiKeyScopes, action). This is a deliberate, explicit design choice, not an oversight: keys are workspace-level credentials used by external systems (Section 20 integrations, customer-built automation against the public API), and those systems have no way to observe or react to "the human who created this key was later demoted to member." Re-deriving a key's authority from its creator's live membership would mean an external integration's behavior could silently change (or break) as a side effect of unrelated HR churn inside the workspace — a surprising and hard-to-debug failure mode. The security backstop is instead scope minimalism at creation and prompt revocation on departure: an owner/admin creating a key should grant only the scopes the integration actually needs (6.6's scopes row), and removing a member (6.7.5) does not auto-revoke keys they created — an owner/admin reviewing the keys list (which shows created_by) is expected to revoke or reassign a departed creator's keys as part of offboarding, exactly as they would rotate any other credential a departing employee had access to. A key's authority is never silently upgraded either: if the creator was later promoted, the key's ceiling was already admin-equivalent (narrowed by scopes) from the moment of creation, so there is nothing further to grant.

6.7 Workspaces: Lifecycle #

6.7.1 Creation #

Any authenticated, email-or-not-verified user may create a workspace (POST /v1/workspaces). The creator becomes owner (a workspace_members row with role = 'owner' is created in the same transaction as the workspaces row — a workspace never exists, even momentarily, without an owner). New workspaces start on the free plan (a subscriptions row is created pointing at the free plan's plans.id) unless created via an in-flow "start on Pro" checkout, in which case the Stripe checkout session must complete before the workspaces row commits (Section 21 owns the checkout flow).

6.7.2 The one-owner rule #

Exactly one owner per workspace at all times, enforced by the partial unique index workspace_members_one_owner_unique (Section 5.4.2). There is no code path that creates a workspace, or leaves a workspace in a settled state, with zero owners or more than one.

6.7.3 Ownership transfer #

POST /v1/workspaces/{workspaceId}/transfer-ownership{ toMemberId }. Requirements:

  1. Caller must be the current owner (only the owner can initiate transfer — an admin cannot transfer ownership to themselves or anyone else).
  2. Caller must pass MFA step-up (6.5) if MFA is enrolled.
  3. toMemberId must reference an existing, accepted workspace_members row in this workspace with role IN ('admin', 'member') — a viewer cannot receive ownership directly (they must first be promoted to at least member, a separate, ordinary role-change action).
  4. Executed as a single SERIALIZABLE transaction (5.4.2 explains why): UPDATE workspace_members SET role = 'admin' WHERE workspace_id = $1 AND role = 'owner' followed by UPDATE workspace_members SET role = 'owner' WHERE id = $toMemberId. If either statement would violate workspace_members_one_owner_unique, the whole transaction rolls back and the endpoint returns 409 ownership_transfer_conflict.
  5. An audit_events row (action = 'workspace.ownership_transferred') and notification emails to both parties are written in the same transaction.

6.7.4 Invites #

POST /v1/workspaces/{workspaceId}/invites{ email, role }, role IN ('admin', 'member', 'viewer') (never 'owner', per the workspace_invites_role_check constraint). Behavior:

  • If a pending invite already exists for this (workspace_id, email), it is revoked (status = 'revoked') and a new one created — re-inviting always supersedes rather than erroring, since the caller's intent ("get this person into the workspace") is served either way.
  • The invite token (32 random bytes, SHA-256 hashed, stored in token_hash) is embedded in an emailed link (https://app.reelay.app/invites/accept?token=...) AND made available as a shareable "invite link" the inviter can copy directly — both are the same token/row; there is no behavioral difference between "clicked from email" and "opened the copied link."
  • expires_at = now() + 7 days.
  • If the invited email already has a users account: accepting the invite (after login, or as part of a login-then-accept redirect chain if not currently authenticated) creates the workspace_members row with the preselected role and sets status = 'accepted'.
  • If the invited email has no account: the accept link routes through signup first (pre-filling the email, which becomes read-only on that signup form to prevent a bait-and-switch where the invite is accepted under a different identity), then completes the same membership-creation step.
  • An expired invite (now() > expires_at at accept-time) yields 410 invite_expired; the inviter can re-invite (which supersedes as above).

6.7.5 Member removal #

DELETE /v1/workspaces/{workspaceId}/members/{memberId} (admin/owner only, permission matrix 6.8). The owner cannot be removed via this endpoint (must transfer ownership first, 403 cannot_remove_owner). On removal:

  1. The workspace_members row is hard-deleted (membership is not in the soft-delete list).
  2. Every videos row where owner_id was the removed user, within this workspace, has owner_id reassigned to the workspace's current owner. This is a deliberate default: a removed member's work product stays with the workspace (it was recorded as work for that workspace) rather than becoming orphaned or deleted; the new owner (the workspace owner) can subsequently reassign or delete individual videos through ordinary video-management actions.
  3. Every share_links row created_by the removed user is untouched and continues to function (share links are never invalidated by their creator's departure — this follows directly from the iron rule in Section 21/19 that creation-time state never revokes existing playback).
  4. Every folder_permissions row granting this specific user access in this workspace is deleted explicitly by the removal handler (DELETE FROM folder_permissions WHERE workspace_id = $1 AND principal_type = 'user' AND principal_user_id = $2, Section 5.4.3) — this is not an automatic FK cascade, because folder_permissions.principal_user_id references users.id directly (a grant is a statement about a person, addressable across that person's whole account lifetime), and users.id survives a single workspace's membership removal. Role-typed grants (principal_type = 'role') are untouched by a member removal, since they apply to whichever users currently hold that role, not to any specific departed individual.
  5. An audit_events row records the removal, actor, and the video-reassignment count.
  6. The removed user retains their users account and any OTHER workspace memberships untouched; only this workspace's membership is affected.

6.7.6 Workspace deletion #

DELETE /v1/workspaces/{workspaceId} — owner only, MFA step-up required. This does not hard-delete immediately: it sets workspaces.deleted_at = now() and scheduled_deletion_at = now() + 30 days (the grace period), creates a deletion_requests row (target_type = 'workspace', status = 'grace_period'), and sends a confirmation email with an "undo" link valid for the full grace window. During the grace period the workspace is fully inaccessible to members (dashboard access 404s) but existing share links continue to serve playback — per the iron rule, deletion in progress is not yet purge, and purge is what stops playback, not the deletion request itself. Section 19 owns the exact purge execution (what happens at day 30, the media hard-delete order, and the final deletion_requests.status = 'purged' transition).

6.8 Roles & the Complete Permission Matrix #

Four workspace roles, exactly as locked: owner, admin, member, viewer. The table below is the exhaustive, authoritative list of gated actions in the product. Y = allowed, N = not allowed. Where a cell says own, the action is allowed only on resources the actor themselves created/owns (see the column note).

Action owner admin member viewer
Record a video Y Y Y N
Edit any video's EDL (own videos) Y Y Y (own) N
Edit any video's EDL (others' videos in workspace) Y Y N N
Delete a video (own) Y Y Y (own) N
Delete a video (others') Y Y N N
Create a share link (own videos) Y Y Y (own) N
Create a share link (others' videos) Y Y N N
Change a share link's visibility Y Y Y (own) N
Set/clear a share link password, expiry, domain allowlist Y Y Y (own) N
View share audit log (share_audit_events) Y Y N N
Comment on a video (workspace/shared access) Y Y Y Y
React to a comment Y Y Y Y
Watch a shared/workspace video Y Y Y Y
View analytics for own videos Y Y Y N (viewers never see analytics)
View analytics for all workspace videos Y Y N N
Manage brand kit Y Y N N
Manage custom domains Y Y N N
Manage retention settings Y Y N N
Manage folders (create/rename/delete) Y Y Y N
Set folder-level permission overrides Y Y N N
Capture/upload a screenshot (own videos) Y Y Y (own) N
Edit/delete a screenshot (own) Y Y Y (own) N
Edit/delete a screenshot (others') Y Y N N
Edit a transcript segment (own videos) Y Y Y (own) N
Edit a transcript segment (others' videos) Y Y N N
Add/remove a caption track (own videos) Y Y Y (own) N
Add/edit/remove a chapter (own videos) Y Y Y (own) N
Accept/reject an AI metadata suggestion (own videos) Y Y Y (own) N
Create/update/delete a CTA (own videos) Y Y Y (own) N
Moderate comments — delete another author's comment Y Y N N
Invite members Y Y N N
Change a member's role (not to/from owner) Y Y N N
Remove a member Y Y N N
Transfer ownership Y (initiator only) N N N
Delete the workspace Y N N N
View/manage billing, plan changes Y N N N
Create/revoke API keys Y Y N N
View audit log (audit_events) Y Y N N
Connect/disconnect integrations (Slack, Notion, HubSpot) Y Y N N
Manage webhook endpoints Y Y N N
Edit own notification preferences Y Y Y Y

This matrix governs workspace members only — every row above marked own follows the exact same ownership-resolution rule (resolved against videos.owner_id at authorize()-check time, never cached). It does not govern anonymous share-link viewers: an anonymous visitor watching a link/public video, entering a password, or posting a comment via a share link is never a workspace_members row and is never evaluated against this table or against ROLE_CAPABILITIES['viewer'] — that population is authorized through the separate share_viewer allow-list defined in 6.9. Conflating the two is exactly the bug 6.9 exists to make structurally impossible.

Notes:

  • "Own" columns are resolved against videos.owner_id / share_links.created_by at authorize()-check time (6.9), never cached — an owner reassignment (6.7.5) immediately changes who "own" applies to.
  • Analytics visibility is the rule called out explicitly in the assignment: member sees analytics scoped to videos WHERE owner_id = actor.userId; owner/admin see workspace-wide analytics with no owner_id filter. This is enforced in the analytics query layer (Section 16) by the same authorize() call, not by a separate ad hoc check — see 6.9.
  • viewer never appears in a workspace_members row with billable-seat consequences (6.10) and never gains recording capability regardless of any folder-level grant (a folder grant can only ever raise a folder's visibility to a viewer, never hand them a capability the role does not structurally have — recording is never expressible as a folder permission level, see 6.8.1).

6.8.1 Folder-level access: default deny #

Every folder is deny-by-default. Access to a folder's contents is governed by exactly two things: the folder's own visibility (5.4.3: private or workspace) and any explicit folder_permissions grant — there is no third state where "no grant exists, so the member's plain workspace role applies with no restriction." That fallback existed in an earlier draft of this model and was a real vulnerability: it meant every private folder became readable by every workspace member the instant it was created, before anyone had explicitly decided who should see it. It is deleted. The rule now:

  1. If the requesting user's workspace role is owner or admin: always allow, at manage level, regardless of the folder's visibility or any folder_permissions row. Owner/admin have workspace-wide "all videos" access as a role capability (permission matrix, 6.8); folder grants exist to scope down member/viewer access for collaboration-control purposes (e.g. a client-facing folder only certain members should see), never to fence off content from the people whose job is to administer the whole workspace.
  2. Else if the folder's visibility = 'workspace': allow at view level (or higher, if a folder_permissions row grants more) — a workspace-visible folder is readable by every member by design, that is what visibility = 'workspace' means.
  3. Else if an explicit folder_permissions grant applies to this user — either a user-typed grant naming them directly, or a role-typed grant naming the role they currently hold — allow at the granted permission_level (view | comment | edit | manage, Section 5.4.3's single enum, used identically here and in Section 18.2.1's folder/collection browsing rule; there is no second, differently-shaped access enum anywhere in the product).
  4. Else: deny. No role, no visibility match, no grant — the folder (and everything under it) is invisible to this user, full stop. This is what makes "a private folder is not visible to everyone in the workspace by default" actually true, including at the instant of creation, with no window where a missing grant defaults to allow.
  5. visibility is never inherited; folder_permissions grants are. These are two separate mechanisms and conflating them is a security bug. A folder's visibility column is NOT NULL DEFAULT 'private' (Section 5.4.3), so every folder always has an explicit value of its own and that value alone governs it — a folder never "falls back" to an ancestor's visibility, because there is no unset state for it to fall back from. Grants are different: for folder_permissions, the closest ancestor in the chain carrying an explicit grant for the actor governs. If no folder in the chain carries a grant, nothing is granted — which, per rule 4, means deny for anyone who is not owner/admin. Section 18.2.1 states this same rule from the library side; the two are identical by construction and neither may be changed without the other.
  6. A folder grant can only ever raise access relative to the deny-by-default baseline, never grant a capability the role does not structurally have. A viewer with a manage grant on a folder still cannot record new videos into it — recording is a role capability the viewer role does not have, full stop, and no folder-level permission level is capable of expressing "grant the recording capability." What a folder grant on a viewer DOES do is let them see (view), comment on (comment), or — if a workspace explicitly chooses to — edit (edit) the EDLs of videos already inside that folder, which are the specific capabilities those permission levels denote.
  7. A member always retains access to videos they personally own regardless of the containing folder's grant state — ownership is a separate access path from folder browsing (permission matrix, 6.8's "own" columns), matching the member role's baseline capability to manage their own videos everywhere in the workspace.

Computing effective folder access is a single function, not scattered checks — and it now takes the folder's visibility as an explicit input rather than assuming an implicit allow:

// packages/shared/src/authz/folder-access.ts
export type FolderPermissionLevel = "view" | "comment" | "edit" | "manage";
export type FolderAccess = FolderPermissionLevel | "deny";

export interface FolderAccessInput {
  actorRole: "owner" | "admin" | "member" | "viewer";
  actorUserId: string;
  /** Visibility + grant state walked from the target folder up through ancestors —
   * index 0 is the target folder itself; the first entry with an explicit setting wins. */
  folderChain: Array<{
    visibility: "private" | "workspace" | null; // null = no explicit setting on this folder
    grants: Array<{ principalType: "user" | "role"; principalId: string; level: FolderPermissionLevel }>;
  }>;
}

export function resolveFolderAccess(input: FolderAccessInput): FolderAccess {
  if (input.actorRole === "owner" || input.actorRole === "admin") {
    return "manage"; // rule 1: never restricted
  }

  for (const folder of input.folderChain) {
    const hasExplicitSetting = folder.visibility !== null || folder.grants.length > 0;
    if (!hasExplicitSetting) continue; // rule 5: keep walking up to the nearest explicit ancestor

    const grant = folder.grants.find(
      (g) =>
        (g.principalType === "user" && g.principalId === input.actorUserId) ||
        (g.principalType === "role" && g.principalId === input.actorRole),
    );
    if (grant) return grant.level; // rule 3

    if (folder.visibility === "workspace") return "view"; // rule 2

    return "deny"; // rule 4: explicit private setting, no matching grant
  }

  return "deny"; // rule 4/5 fallback: no explicit setting anywhere in the chain => private => deny
}

6.9 Authorization Enforcement Pattern #

This subsection is the sole definition of authorize() in the entire document. Every other section that touches permission checking (Section 22.4.1's security-property narration included) describes behavior built on top of what is defined here and cites this subsection by number; none of them redefines the interface, the Actor shape, or the Action enum. Every route handler that touches workspace-scoped data, and every route that serves anonymous share-link traffic, calls this one function — there is no second way to check permissions, and no route is permitted to query scoped data without passing through it first (enforced by the same no-unscoped-table-query ESLint rule referenced in Section 5.2, extended to also require a preceding authorize() call in the same handler, detected via static call-graph analysis in the lint rule).

// packages/shared/src/authz/authorize.ts
export type Role = "owner" | "admin" | "member" | "viewer";

export type Action =
  | "video.record" | "video.edit" | "video.delete" | "video.view"
  | "screenshot.capture" | "screenshot.manage"
  | "transcript.edit" | "caption.manage" | "chapter.manage"
  | "ai_suggestion.resolve"
  | "cta.manage"
  | "comment.moderate"
  | "share_link.create" | "share_link.update_visibility" | "share_link.view_audit_log"
  | "share_link.view"
  | "analytics.view_own" | "analytics.view_all"
  | "brand_kit.manage" | "custom_domain.manage" | "retention.manage"
  | "folder.manage" | "folder.set_overrides"
  | "member.invite" | "member.change_role" | "member.remove"
  | "workspace.transfer_ownership" | "workspace.delete"
  | "billing.manage" | "api_key.manage" | "audit_log.view"
  | "integration.manage" | "webhook.manage";

Every Action uses one vocabulary, resource.verb form, and only this form — video.edit, never video.write; there is no second spelling convention anywhere this enum is consumed (Section 22.4.1 narrates the security property this enforces but never introduces a competing name). This list covers every permission-gated verb enumerated across the full endpoint reference (Section 7.11): recording is video.record; screenshots are screenshot.capture/screenshot.manage; transcript corrections are transcript.edit; caption and chapter management are caption.manage and chapter.manage; AI metadata suggestion accept/reject is ai_suggestion.resolve; CTA create/update/delete is cta.manage; deleting another author's comment is comment.moderate; and anonymous share-link playback resolution is share_link.view (see the third Actor variant below).

export type Actor =
  | { type: "user"; userId: string; workspaceId: string; role: Role }
  | { type: "api_key"; apiKeyId: string; apiKeyScopes: string[]; workspaceId: string; role: "admin" }
  | { type: "share_viewer"; viewerToken: string; shareLinkId: string; videoId: string };

export type Resource =
  | { type: "video"; id: string; ownerId: string; folderId: string | null }
  | { type: "share_link"; id: string; createdBy: string }
  | { type: "workspace" }
  | { type: "folder"; id: string };

export class ForbiddenError extends Error {
  constructor(public action: Action, public reason: string) {
    super(`Forbidden: ${action} (${reason})`);
  }
}

/**
 * The ONLY sanctioned authorization check in the codebase, for both workspace-member
 * traffic and anonymous share-link traffic. THROWS ForbiddenError (mapped to HTTP 403,
 * error code `forbidden`, Section 7.6) on any denial — it never returns a boolean or an
 * `{ allowed: false }` object a caller might forget to check. A thrown exception cannot be
 * silently ignored by a handler that forgets to branch on the result; a returned value can.
 * This is a deliberate, load-bearing design choice, not an implementation detail.
 */
export function authorize(actor: Actor, action: Action, resource: Resource): void {
  // Third Actor variant: an anonymous share-link viewer. This branch is evaluated against
  // its OWN allow-list and is NEVER evaluated against ROLE_CAPABILITIES['viewer'] — the
  // workspace role `viewer` (a `workspace_members` row, billable-seat-adjacent, invited by
  // an admin, permission matrix in 6.8) and an anonymous `share_viewer` (no account, no
  // membership row, authorized purely by possession of a valid share link + its access
  // rules) are different populations with different trust levels, and sharing a code path
  // between them is exactly the class of bug this three-way Actor union exists to prevent.
  if (actor.type === "share_viewer") {
    if (!SHARE_VIEWER_ALLOWED_ACTIONS.has(action)) {
      throw new ForbiddenError(action, "share_viewer_action_not_allowed");
    }
    if (resource.type !== "share_link" && resource.type !== "video") {
      throw new ForbiddenError(action, "share_viewer_resource_not_allowed");
    }
    // Delegates to the share-link access chain (visibility, password, expiry, domain
    // allowlist, email-verification gate) owned by Section 14.8 — authorize() calls into
    // it rather than re-implementing it, but this IS the single entry point a route handler
    // calls; there is no route that checks share-link access without going through here.
    if (!shareLinkGrantsAccess(actor.shareLinkId, actor.viewerToken, resource)) {
      throw new ForbiddenError(action, "share_link_access_denied");
    }
    return;
  }

  if (actor.type === "api_key" && !apiKeyScopeCovers(actor.apiKeyScopes, action)) {
    throw new ForbiddenError(action, "api_key_scope_insufficient");
  }
  // Per 6.6: an api_key Actor's `role` is always the literal string "admin" — fixed at the
  // key's creation, never re-derived from the creator's live membership — so this lookup is
  // the same ROLE_CAPABILITIES['admin'] ceiling every admin user gets, narrowed by scopes above.
  if (!ROLE_CAPABILITIES[actor.role].has(action)) {
    if (!(OWN_RESOURCE_ACTIONS.has(action) && isOwnResource(actor, resource))) {
      throw new ForbiddenError(action, "role_insufficient");
    }
  }
  if (resource.type === "video" && needsFolderCheck(action)) {
    const access = resolveFolderAccess(/* actor role, actor userId, folder chain — 6.8.1 */);
    if (access === "deny" || (action === "video.edit" && !["edit", "manage"].includes(access))) {
      throw new ForbiddenError(action, "folder_access_denied");
    }
  }
}

SHARE_VIEWER_ALLOWED_ACTIONS is a small, explicit set — at launch, exactly { "share_link.view" } — covering share-link/video resolution for playback, poster/thumbnail delivery, and signed playback token issuance (Section 14.8). It is deliberately not a place where new capabilities accumulate by default: the comment/reaction endpoints available to an anonymous viewer (Section 7.11.17) use a separate, narrower "viewer token" bearer-auth check scoped only to those endpoints, precisely because comments and reactions have their own author-identity model (comments.author_viewer_id, Section 5.4.11) that does not fit the Actor/Resource shape above — that boundary is intentional, not an oversight, and a future capability that genuinely needs the full authorize() treatment for anonymous viewers is added to this set explicitly, never inferred.

The ROLE_CAPABILITIES table and OWN_RESOURCE_ACTIONS set are the machine-readable form of the permission matrix in 6.8 — every row of that matrix has a corresponding entry here, and a change to one without the other is caught by a contract test (authorize.matrix.test.ts, Section 25) that walks every (role, action) pair in the matrix table and asserts authorize() agrees.

Every route handler's shape is therefore: resolve actor from the authenticated session/API key/ share-link context and the workspaceId (or shareLinkId) in the request (never trusting a client-supplied role — role is always looked up fresh from workspace_members or derived from the editor JWT per 6.3.4's freshness rule; an anonymous request never supplies a role at all), call authorize(actor, action, resource), and only then perform the scoped query via scoped() (Section 5.2). This two-function pairing (authorize() decides if, scoped() ensures the query can't leak even if it tried) is the complete enforcement pattern for the whole product, for every caller population — workspace members, API keys, and anonymous share-link viewers alike.

6.10 Why SSO/SCIM Is Additive Later #

SSO (SAML/OIDC federation) and SCIM (automated provisioning/deprovisioning) are explicitly out of scope for this build. This is a deliberate architectural commitment, not a gap: membership in a workspace lives exclusively in the workspace_members table (Section 5.4.2), populated only by two paths — accepting an explicit workspace_invites row (6.7.4), or the narrow, per-login OAuth auto-link in 6.2.2 which is scoped to one proven email at one moment, never a standing rule. Membership is never derived from an implicit signal — not email domain, not organizational unit, not any inference. There is no code path anywhere in the system that says "this user's email ends in @acme.com, therefore they belong to Acme's workspace."

This matters because it is exactly the property that makes adding SSO/SCIM later cheap rather than a rearchitecture:

  • SSO (a workspace admin configuring "sign in via Okta/Azure AD for our domain") only needs to become a new authentication method that, upon successful federation, resolves to a users row and either finds an existing workspace_members row (login) or funnels into the exact same workspace_invites acceptance path that already exists (first-time access) — no new membership table, no migration of the membership model, because the target shape already assumes membership is an explicit, auditable row rather than a login-time side effect.
  • SCIM (an IdP pushing user provisioning/deprovisioning events) only needs to become a new, authenticated caller of the same invite-creation and member-removal endpoints (6.7.4, 6.7.5) that a human admin already uses through the dashboard — the permission matrix, the one-owner rule, and the video-reassignment-on-removal behavior all already exist and require zero changes to serve a SCIM-driven caller instead of a UI-driven one.

Because the hard part — "what does it mean to be a member, and what happens when membership changes" — is already fully specified and enforced at the workspace_members/authorize() layer, SSO/SCIM in a future release is purely an additional authentication and provisioning front door onto an unchanged membership model, not a redesign of it.

7. API Design — REST, Public API & Webhooks #

This section is the canonical source for the HTTP API surface: the response envelope, the error envelope, pagination, filtering, idempotency, rate limits, headers, inbound webhook verification, the full endpoint reference, the public API subset, and outbound webhooks. Every other section that mentions an endpoint, an error code, a header, or a pagination shape refers back to this section by number rather than restating the shape.

7.1 Base URL, Versioning & Deprecation #

  • Base URL: https://api.reelay.app/v1. The version is a path segment, not a header. There is exactly one API surface; the "public API" (Section 7.12) is the same surface scoped by an API key rather than a separate host or path prefix.
  • The web app, the desktop app, and third-party integrators all call the same /v1 surface. There is no undocumented internal API — internal callers use the same contracts documented here, which keeps the surface honest and prevents drift.
  • Versioning policy: the major version (v1) changes only for breaking changes to a resource shape, an error code's meaning, or an auth mechanism. Additive changes (new optional field, new endpoint, new error code, new event type) ship within v1 without a version bump. Clients MUST ignore unknown JSON fields and unknown webhook event types.
  • Deprecation policy: a field or endpoint marked for removal is annotated in the OpenAPI document (Section 7.14) with deprecated: true and a x-sunset-date extension at least 180 days in the future. Deprecated endpoints continue to function unchanged until the sunset date.
  • Sunset headers: during the deprecation window, responses from a deprecated endpoint include:
    • Deprecation: true
    • Sunset: <HTTP-date> — RFC 9110 format, e.g. Sunset: Wed, 15 Apr 2026 00:00:00 GMT
    • Link: <https://developers.reelay.app/changelog/2026-04-endpoint-name>; rel="deprecation" After the sunset date, the endpoint returns 410 Gone with error code endpoint_sunset for a minimum of 90 additional days before the route is removed entirely, so integrators get a hard failure they can alert on rather than a silent disappearance.
  • v2 is not scoped in this document. If a future breaking change is required, it is introduced as a new path prefix (/v2/...) that coexists with /v1 for the deprecation window above.

7.2 Authentication #

Two credential types, never interchangeable:

Credential Header Issued to Lifetime Accepted by
Session JWT Authorization: Bearer <jwt> Web dashboard/editor, desktop app, after login 15 min, silently refreshed via the session cookie (Section 6) All endpoints except public-API-only routes
API key Authorization: Bearer sk_live_<base58> Business-plan workspace, created in Settings → API Keys Until revoked Only endpoints marked "public API" in the reference below (Section 7.12)
  • The session JWT is derived server-side from the httpOnly session cookie described in Section 6; it is never persisted client-side beyond memory and carries sub (user id), wsId (active workspace id), role, and a 15-minute exp. The web app silently exchanges it for a fresh JWT using the session cookie on a 401 with error code token_expired; the caller retries the original request once with the new token before surfacing a failure.
  • API keys are workspace-scoped, not user-scoped: every request authenticated with an API key acts as the workspace itself, subject to the key's granted scopes (Section 7.12), and is attributed to the key in the audit log (audit_events.actor_type = 'api_key').
  • Endpoints under /v1/collect (analytics ingestion, Section 7.11.19) and the inbound webhook receivers (Section 7.10) accept no bearer credential at all — they use a different verification scheme documented in their own subsections.
  • A request with no Authorization header, an unparseable header, or a credential that fails verification returns 401 Unauthorized with error code unauthenticated (Section 7.6). A request with a valid credential but insufficient role or scope returns 403 Forbidden with error code forbidden — the distinction between 401 and 403 is never blurred.
  • Every authenticated request additionally resolves a workspace context. Web/session requests carry wsId in the JWT; if the caller needs to act on a different workspace than their active one, they pass X-Workspace-Id: <ws_id>. On every such switch, the server re-resolves the actor's role fresh from workspace_members for the target workspace — it never carries over the role claim minted in the JWT for the caller's original active workspace, since that claim describes a different membership row entirely and trusting it across workspaces would let a role granted in one workspace leak into another. A caller with no workspace_members row for the requested X-Workspace-Id gets 403 Forbidden / workspace_mismatch; a caller who IS a member, but holds a different role there than in their home workspace (e.g. admin in workspace A, viewer in workspace B), is authorized strictly as workspace B's role for the duration of that request. This fresh-resolution rule is what makes it safe for authorize() (Section 6.9) to be called with a role resolved per-request rather than trusted from a token minted earlier for a different context. API-key requests are pinned to the workspace that created the key and never re-resolve a role (an API key acts as the workspace itself, above); X-Workspace-Id is rejected with 403 Forbidden / workspace_mismatch if sent with an API key and it disagrees with the key's owning workspace.

7.3 The Success Envelope #

Every successful response (2xx) is a JSON object with exactly two top-level keys:

{
  "data": { "...": "the resource or array of resources" },
  "meta": null
}
  • data — the primary payload. A single object for item endpoints (GET /v1/videos/{id}), an array for list endpoints (GET /v1/videos), or null for actions with no return payload other than confirmation (rare; most actions return the affected resource).
  • metanull unless the endpoint carries pagination (Section 7.4), a rate-limit summary, or an endpoint-specific side channel (e.g. a count). When present, meta is always a flat JSON object, never an array.
  • Resource identifiers in data always use the prefixed base58 public ID form defined in Section 5's ID scheme (e.g. vid_2h9K..., ws_8pQr...) — internal UUIDv7 primary keys are never exposed in any response.
  • All object keys are camelCase. All timestamp values are ISO 8601 UTC with a trailing Z, stored on keys suffixed At (createdAt, publishedAt). All durations are integer milliseconds on keys suffixed Ms (durationMs). All byte sizes are integer bytes on keys suffixed Bytes (sizeBytes). No field is ever a float representing seconds or a human-formatted duration string.
  • 204 No Content is used only for actions that genuinely return nothing and cannot fail partially (e.g. DELETE of an already-idempotent resource state) — the response body is empty and callers must not attempt to parse it as JSON.

7.4 Pagination #

Cursor-based pagination is used for every list endpoint in this API. There is no offset pagination anywhere in the product — no page, offset, or skip parameter exists on any endpoint.

  • Request: ?limit=25&cursor=<opaque>.
    • limit — integer, default 25, maximum 100. A value above 100 is clamped to 100, not rejected (keeps naive integrators working); a value below 1 returns 400 / validation_error.
    • cursor — opaque, omitted on the first page.
  • Response meta on a list endpoint:
{
  "data": [ { "...": "..." }, { "...": "..." } ],
  "meta": { "nextCursor": "eyJvIjoiMjAyNi0wOC0xOVQxMjowMDowMFoiLCJpZCI6InZpZF8yaDlLIn0", "hasMore": true }
}
  • nextCursornull when hasMore is false. Otherwise an opaque, base64url-encoded string.
  • hasMore — boolean, always present.
  • Cursor encoding: the cursor is base64url(JSON.stringify({ o: <orderValue>, id: <publicId> })) where o is the value of the endpoint's primary sort column for the last row of the current page (e.g. an ISO timestamp, or a numeric rank) and id is that row's public ID, used as a tiebreaker for rows with identical o values. The server decodes the cursor, validates its shape with the shared Zod schema CursorSchema, and translates it into a keyset predicate (WHERE (created_at, id) < (:o, :id) or the ascending equivalent), never an SQL OFFSET. This keeps list queries O(log n) via the existing (created_at, id) composite index regardless of how deep the caller pages, and keeps results stable under concurrent inserts — a guarantee OFFSET cannot make.
  • A cursor is opaque and versioned: the encoded JSON includes a v: 1 field. A cursor from a future API version, a tampered cursor, or a cursor that fails to decode returns 400 / invalid_cursor. Cursors are never guaranteed valid indefinitely — a cursor pointing at a since hard-deleted row simply resumes from the nearest surviving row in sort order; this is not an error, since keyset pagination degrades gracefully.
  • Default sort direction is descending by createdAt (newest first) unless an endpoint documents a different default (e.g. GET /v1/videos/{id}/comments defaults ascending by timeline position).
  • Filtering: query parameters named after the field they filter, e.g. GET /v1/videos?folderId=fld_...&status=ready. Multiple values for the same field are comma-separated and OR'd: ?status=ready,processing. Filters on different fields are AND'd. Unrecognized filter parameters are rejected with 400 / validation_error (not silently ignored) so integrators discover typos immediately rather than getting unfiltered results.
  • Date-range filters: fields ending in After/Before accept ISO 8601 timestamps, e.g. ?createdAfter=2026-01-01T00:00:00Z&createdBefore=2026-06-01T00:00:00Z. Ranges are inclusive of After and exclusive of Before.
  • Sorting: ?sort=<field> for ascending, ?sort=-<field> for descending (leading hyphen). Only fields explicitly documented as sortable for that endpoint are accepted; others return 400 / validation_error. Multi-key sort is not supported in v1 — every documented sortable field is itself a stable, unique-enough ordering when combined with the id tiebreaker from Section 7.4.
  • Search: endpoints that support free-text search expose ?q=<string>, 1–200 characters, matched against a Postgres tsvector generated column (GIN index) covering the fields documented per endpoint (typically title + transcript text for videos). Search is combinable with filters and sort; when q is present and no sort is given, results default to relevance rank rather than createdAt.

7.6 The Error Envelope #

Every non-2xx response uses exactly this shape:

{
  "error": {
    "code": "video_not_found",
    "message": "No video was found with the given ID.",
    "details": [ { "field": "title", "issue": "too_long" } ],
    "requestId": "req_01J8ZK3QANRB8G3F7WQXM4T9CV"
  }
}
  • code — stable, machine-readable snake_case string. This is the field integrators branch on; it never changes meaning once shipped, and new codes are additive.
  • message — human-readable, English, safe to display to a developer (never to an end user directly; client apps map codes to localized user-facing copy).
  • details — array, [] when not applicable (never null, so callers can always safely .forEach). Populated for validation_error (one entry per failing field, field as a dot-path like recipients[0].email, issue a stable sub-code such as required, too_long, invalid_format, out_of_range, invalid_enum_value) and for a small set of other multi-cause errors noted in the catalogue below.
  • requestId — matches the X-Request-Id response header (Section 7.8) and the correlation ID logged server-side; always present, always safe to show in error UI and support tickets.
  • HTTP status carries the error class; code carries the specific cause. Clients should branch on code, not on status, for anything beyond generic retry/auth handling.

7.6.1 Error Code Catalogue #

Generic, cross-cutting codes (apply to any endpoint):

Code HTTP Fires when Message template
validation_error 400 Request body/query fails Zod schema validation "The request failed validation." (see details)
invalid_cursor 400 Pagination cursor fails to decode or is version-mismatched "The pagination cursor is invalid or expired."
unauthenticated 401 Missing, malformed, or unverifiable credential "Authentication is required for this request."
token_expired 401 Session JWT exp has passed "The session token has expired; refresh and retry."
invalid_api_key 401 API key hash not found, or key revoked "The provided API key is invalid or has been revoked."
forbidden 403 Valid credential, insufficient role or scope "You do not have permission to perform this action."
workspace_mismatch 403 X-Workspace-Id conflicts with the credential's bound workspace "This credential is not valid for the requested workspace."
email_unverified 403 Action requires a verified email (Section 6) and the caller has none "Verify your email address before performing this action."
mfa_required 403 Workspace policy requires MFA and the session was not established with it "This action requires multi-factor authentication."
resource_not_found 404 Generic fallback for an unrecognized/nonexistent path with a valid ID shape "The requested resource was not found."
method_not_allowed 405 Verb not supported on an otherwise valid path "This HTTP method is not supported on this endpoint."
conflict 409 Generic optimistic-concurrency or state conflict without a more specific code "The resource was modified by another request; reload and retry."
idempotency_key_reused 409 Same Idempotency-Key reused with a different request body (Section 7.7) "This idempotency key was already used with a different request."
idempotency_in_progress 409 Same Idempotency-Key request is still being processed "A request with this idempotency key is already in progress."
precondition_failed 412 If-Match ETag mismatch on a conditional update "The resource has changed since it was last read."
payload_too_large 413 Request body exceeds the endpoint's documented byte limit "The request body exceeds the maximum allowed size."
unsupported_media_type 415 Content-Type not accepted by the endpoint "This endpoint does not accept the given content type."
rate_limited 429 Token bucket exhausted (Section 7.9) "Rate limit exceeded; retry after the time in Retry-After."
internal_error 500 Unhandled server exception "An unexpected error occurred. It has been logged."
upstream_unavailable 502 A dependency (Mux, transcription vendor, Stripe) failed or timed out "A dependent service is temporarily unavailable."
service_unavailable 503 Planned maintenance or health-check failure "The service is temporarily unavailable."
endpoint_sunset 410 Deprecated endpoint past its sunset date (Section 7.1) "This endpoint has been removed. See the migration guide."

Resource-specific codes (grouped by owning resource; each still uses the generic shape above):

Code HTTP Fires when
video_not_found 404 No video with the given ID visible to the caller
video_not_ready 409 Action requires status = ready but the video is still processing
video_processing_failed 409 Action requires a successful transcode and the pipeline terminally failed
folder_not_found 404 No folder with the given ID
folder_not_empty 409 Delete requested on a folder that still contains videos or subfolders and force=false
folder_cycle 400 parentFolderId would create a cycle in the folder tree
folder_access_denied 403 A folderId update on a video targets a destination folder the caller lacks edit-or-manage access to (Section 6.8.1)
workspace_not_found 404 No workspace with the given ID, or caller is not a member
member_not_found 404 No workspace member with the given user ID
cannot_remove_last_owner 409 Attempt to remove/demote the workspace's sole owner
invite_not_found 404 Invite token invalid, consumed, or expired
invite_expired 410 Invite token past its 14-day validity window
seat_limit_exceeded 402 Adding a recording seat would exceed the plan's purchased seat count
plan_limit_exceeded 402 A creation action would exceed a Section 21 plan cap (library size, storage, recording length)
recording_too_long 400 Recording duration exceeds the plan's max length (Section 21)
upload_session_not_found 404 No multipart upload session with the given ID
upload_session_expired 410 Upload session inactive past its 24h expiry
upload_part_out_of_order 400 partNumber skips ahead of the next expected part for a session requiring sequential parts
upload_part_size_invalid 400 Part is smaller than 5 MiB and is not the final part, or exceeds 8 MiB target with no override
upload_incomplete 409 complete called before all declared parts were uploaded
checksum_mismatch 422 Reported part or whole-object checksum does not match server-computed value
edl_not_found 404 No edit decision list with the given ID
edl_invalid_operation 400 An EDL op references a source-time range outside the recording's duration, or overlaps disallowed
render_not_found 404 No render job with the given ID
render_already_in_progress 409 A render was requested while an equivalent render job is already queued/running
export_not_found 404 No export with the given ID
export_format_unsupported 400 Requested export container/codec is not in the supported set
transcript_not_found 404 No transcript exists yet for the video (still processing or transcription disabled)
caption_not_found 404 No caption track with the given language/ID
chapter_not_found 404 No chapter with the given ID
suggestion_not_found 404 No AI metadata suggestion with the given ID
suggestion_already_resolved 409 Accept/reject called on a suggestion already accepted or rejected
screenshot_not_found 404 No screenshot with the given ID
share_link_not_found 404 No share link with the given ID/slug
share_link_expired 410 Link's expiresAt has passed
share_link_password_required 401 Link requires a password and none/incorrect was supplied
share_link_domain_forbidden 403 Viewer's referer/email domain is not in the link's allowlist
share_link_email_required 403 Link requires an email capture before playback and none was submitted
recipient_not_found 404 No share-link recipient with the given ID/token
comment_not_found 404 No comment with the given ID
comments_disabled 403 Comment action attempted on a share link with disableComments = true
reaction_invalid 400 Reaction emoji not in the supported set
cta_not_found 404 No CTA with the given ID
email_capture_invalid 400 Malformed email or blocked by disposable-domain filter
brand_kit_not_found 404 No brand kit with the given ID
custom_domain_not_found 404 No custom domain with the given ID
custom_domain_verification_failed 422 DNS TXT/CNAME check failed at verification time
custom_domain_requires_business 402 Custom domain action attempted on a non-Business workspace
api_key_not_found 404 No API key with the given ID
api_key_requires_business 402 API key creation attempted on a non-Business workspace
webhook_endpoint_not_found 404 No webhook endpoint with the given ID
webhook_url_unreachable 422 Test delivery to a new endpoint URL failed validation ping
webhook_signature_invalid 401 Inbound vendor webhook signature failed verification (Section 7.10)
webhook_timestamp_stale 401 Inbound vendor webhook timestamp outside tolerance (Section 7.10)
subscription_not_found 404 No active subscription for the workspace
payment_required 402 Action blocked because the workspace subscription is past-due
invoice_not_found 404 No invoice with the given ID
usage_query_range_too_large 400 Usage/analytics query spans more than the endpoint's max window
analytics_query_invalid 400 Analytics query references an unknown metric or invalid grouping
audit_log_requires_admin 403 Audit log read attempted by a member/viewer role
integration_not_connected 409 Action on an integration (Slack/Notion/HubSpot) that has no active connection
integration_token_revoked 401 Vendor OAuth token was revoked externally; reconnection required
retention_policy_conflict 409 Retention policy change conflicts with a pending deletion request

7.7 Idempotency Keys #

  • Applies to every POST that creates a billable or otherwise side-effecting resource: video creation from recording, upload session init, render requests, export requests, invite sends, API key creation, webhook endpoint creation, CTA creation, subscription/plan-change actions, and any endpoint explicitly marked "idempotent-capable" in Section 7.11. GET, PATCH, DELETE do not use this mechanism — PATCH/DELETE are naturally idempotent by resource ID, and GET has no side effects.
  • Client sends Idempotency-Key: <opaque client-generated string, 1–255 chars> (a UUID is the recommended value, but any unique string works).
  • Storage: the idempotency_keys table schema — including the (workspace_id, key) composite unique index and the status (in_progress | completed | failed) enum referenced throughout this subsection — is owned by Section 5.4.x. A scheduled job purges rows past their expires_at (created_at + 24h) hourly.
  • Replay semantics:
    1. First request with a given key: server inserts a row with status = in_progress inside the same transaction as the resource creation begins, computes request_hash (a hash of method + path + body), proceeds normally.
    2. A second request with the same key and an identical request_hash while the first is still in_progress returns 409 / idempotency_in_progress — the client should back off and retry after a short delay, not assume failure.
    3. A second request with the same key and identical request_hash after the first completed returns the stored response verbatim (same status code, same body) — this is the core replay guarantee: retrying a network-timed-out request never double-creates the resource.
    4. A second request with the same key but a different request_hash returns 409 / idempotency_key_reused — reusing a key for a different logical request is a client bug the API refuses to guess around.
    5. If the original request failed with a 4xx/5xx that is NOT a transient server fault (i.e. validation errors), the failure response is also cached and replayed identically on retry with the same key; a genuinely transient 5xx/502/503 is NOT cached, so a retry with the same key after a server-side failure is allowed to actually re-attempt the operation.
  • The key is scoped per-workspace, so two different workspaces (or two different API keys) may reuse the same literal key string without collision.

7.8 Headers, Request IDs & Correlation #

Request headers the API reads:

Header Required Purpose
Authorization Yes (except public/unauthenticated endpoints) Bearer credential, Section 7.2
X-Workspace-Id Conditional Explicit workspace context when it differs from the credential's default (Section 7.2)
Idempotency-Key Conditional Section 7.7
Content-Type Yes on bodies application/json; charset=utf-8 for all JSON endpoints; application/octet-stream for multipart upload part bytes (Section 7.11.9)
If-Match Optional ETag-based optimistic concurrency on PATCH for resources that expose etag (videos, EDLs, share links)
X-Request-Id Optional Client-supplied correlation ID; echoed back if present and well-formed (^[A-Za-z0-9_-]{1,64}$), otherwise the server generates one
X-Client-Version Recommended e.g. reelay-web/2026.08.1, reelay-desktop/1.4.0; logged for support/diagnostics, never used for access control

Response headers on every response:

Header Description
X-Request-Id Matches error.requestId on failures; a ULID (req_<ULID>) when server-generated
X-RateLimit-Limit Section 7.9
X-RateLimit-Remaining Section 7.9
X-RateLimit-Reset Section 7.9
ETag Present on item GET/PATCH responses for resources supporting conditional updates
Deprecation, Sunset, Link Present only on deprecated endpoints (Section 7.1)
Cache-Control: no-store On all authenticated endpoints; player manifest/asset delivery (Section 15) uses its own CDN caching, not this API
  • Every request is tagged server-side with a correlation ID equal to X-Request-Id, propagated through BullMQ job payloads (correlationId field), log lines, and any downstream vendor call headers, so a single ID traces a request end-to-end across the API, worker, and vendor logs (Section 24).

7.9 Rate Limits #

  • Algorithm: token bucket, one bucket per (subject, endpointClass) pair, implemented in Redis 8 with a Lua script (EVAL) for atomic check-and-decrement. Buckets refill continuously at limit / windowSeconds tokens/sec, capped at limit tokens (standard token-bucket burst behavior — a caller that has been idle can burst up to the full limit, then is throttled to the steady rate). subject depends on the class: the workspace ID for session-authenticated read/ write/upload/analytics requests, the API key ID for public-API requests, the signed playback token for collect requests (Section 7.11.19), and — for the auth class specifically — two independent buckets keyed by caller IP and by the request's email field, both of which must have an available token for the request to proceed (Section 6.1.4).
  • Endpoint classes:
Class Includes
read All GET endpoints except analytics/usage queries
write All POST/PATCH/DELETE except upload-part and collect
upload POST /v1/upload-sessions, POST /v1/upload-sessions/{id}/parts/{n}
collect POST /v1/collect
auth POST /v1/auth/login, /v1/auth/signup, /v1/auth/refresh, /v1/auth/mfa/verify
analytics POST /v1/analytics/query, GET /v1/usage
  • Per-plan quotas (requests per minute unless noted; per workspace for session auth):
Plan read write upload collect (per playback token) analytics
Free 60 30 10 120 20
Pro 300 120 30 600 60
Business 1000 300 60 2000 120

The auth class is deliberately not in this per-plan table: /v1/auth/* is never plan-keyed. It is IP/email keyed exactly per Section 6.1.4 — 10 requests/min per IP and 5 requests/min per email — identically across Free, Pro, and Business, since a plan-tiered auth limit would let a higher-paying attacker brute-force credentials faster, which is never the intent.

  • Public API keys (Business only, Section 7.12): each key gets its own bucket, independent of the workspace's session-auth buckets, default 600 req/min blended across read+write, burst capacity 60. A workspace with multiple keys has independent buckets per key. Support can raise a specific key's limit; the raised value is stored on api_keys.rate_limit_override and read by the Lua script at check time.
  • Response headers on every rate-limited request, success or failure:
    • X-RateLimit-Limit — the bucket's max tokens (the plan/key quota for that class).
    • X-RateLimit-Remaining — tokens left after this request, floored at 0.
    • X-RateLimit-Reset — Unix seconds until the bucket would be back at full capacity at the current refill rate (not "until next token," to give callers a stable planning number).
  • 429 behavior: when a bucket has fewer than 1 token available, the request is rejected before any handler logic runs, with 429 / rate_limited and an additional Retry-After: <seconds> header (the seconds until at least 1 token is available — always ≤ X-RateLimit-Reset). Rejected requests do not consume a token (they are free to retry after the backoff). Clients are expected to honor Retry-After with jittered backoff; the SDKs shipped for the public API (Section 7.12) implement this automatically.
  • Rate limiting is applied at the Fastify layer via a shared @reelay/rate-limit plugin reading the bucket config above from packages/shared, so limits are enforced identically regardless of which apps/api route handler is hit — there is no route that accidentally bypasses it.

7.10 Inbound Webhook Verification (Vendor Callbacks) #

Three vendor callback receivers exist: the streaming vendor's asset/upload webhook (POST /v1/webhooks/mux), the transcription vendor's callback (POST /v1/webhooks/transcription), and the billing vendor's event stream (POST /v1/webhooks/stripe, Section 7.11.24). All three are public and unauthenticated by bearer credential (vendors cannot present one) and instead verified by signature — but Stripe's verification path is deliberately different from the other two, as detailed below.

7.10.1 Mux and Transcription Vendor (Shared Scheme) #

  • Signature scheme: each vendor signs its payload with HMAC-SHA256 over {timestamp}.{rawRequestBody} using a per-integration secret stored in the deployment's secret manager (never in the database). The signature arrives in a vendor-specific header (Mux-Signature for the streaming vendor, in the form t=<unix_ts>,v1=<hex_hmac>; the transcription vendor's equivalent header follows the same t=,v1= convention configured at integration setup). The receiver:
    1. Extracts t and v1 from the header.
    2. Recomputes hex_hmac_sha256(secret, "${t}.${rawBody}") using the raw, unparsed request body (the Fastify route registers a raw-body content-type parser for these paths specifically, since re-serialized JSON would break the signature).
    3. Compares the computed digest to v1 using a constant-time comparison (crypto.timingSafeEqual). Mismatch → 401 / webhook_signature_invalid, logged, not retried by us (the vendor's own retry policy governs redelivery).
  • Timestamp tolerance: t must be within 300 seconds of the server's current time (both directions, to tolerate clock skew and network delay). Outside that window → 401 / webhook_timestamp_stale, even if the signature is otherwise valid — this bounds the replay window before the next protection layer even engages.
  • Replay protection: the tuple (vendor, eventId) from the payload is inserted into a Redis set with a 24h TTL (webhook:seen:{vendor}:{eventId}) using SETNX. If the key already exists, the handler returns 200 OK immediately without reprocessing (vendors treat any 2xx as delivered; returning an error would trigger unnecessary vendor retries) and logs a duplicate_delivery metric. This combines with idempotent handler design (below) as defense in depth.
  • Idempotent handling: every effect the handler performs is keyed on the vendor event, not merely guarded by the replay check — e.g. the streaming vendor's "asset.ready" handler upserts the renditions row keyed on (video_id, mux_asset_id) rather than blindly inserting, so even a race between two near-simultaneous deliveries (replay-set check included) cannot double-process. The BullMQ job enqueued from the webhook handler uses job.id = video:transcode-callback:{muxAssetId} (Section 9), so BullMQ's own dedup provides a third layer.

7.10.2 Stripe #

  • Signature scheme: Stripe signs with its own convention, delivered in the Stripe-Signature header in the same shape as the two vendors above (t=<unix_ts>,v1=<hex_hmac>), but verification runs through the official Stripe Node SDK's stripe.webhooks.constructEvent(rawBody, signatureHeader, endpointSecret) rather than the hand-rolled comparator in 7.10.1 — the SDK owns the HMAC recomputation and the timestamp-tolerance check internally (its default tolerance is 300 seconds, matching the value used for the other two receivers, so no special-casing of the window is needed). A StripeSignatureVerificationError thrown by the SDK is mapped to the same 401 / webhook_signature_invalid code used by the other two receivers — a caller inspecting the error catalogue never needs to branch on which vendor failed verification.
  • Timestamp tolerance: identical semantics to 7.10.1 (±300s), enforced inside the SDK call rather than by hand; a stale timestamp raises the same SDK error, mapped to 401 / webhook_timestamp_stale.
  • Replay protection: the same Redis SETNX pattern as 7.10.1, keyed webhook:seen:stripe:{event.id} using Stripe's own evt_... event ID namespace (distinct from Reelay's internal evt_ prefix used for outbound webhook events, Section 7.13.2 — the two never collide because they live in different Redis key prefixes and are never compared to each other).
  • Idempotent handling: Stripe event handlers upsert subscriptions/invoices rows keyed on Stripe's own object IDs (stripe_subscription_id, stripe_invoice_id), the same upsert-on-vendor-id pattern used for the streaming vendor's asset callbacks — a redelivered or raced Stripe event can never double-apply a plan change or double-record an invoice.

7.10.3 Common to All Three #

Every receiver always returns within 2 seconds: signature/timestamp/replay checks are synchronous and cheap, and the actual work (updating rows, enqueuing follow-on jobs) is handed to BullMQ before responding 200. A receiver that cannot enqueue the follow-on job (Redis unavailable) returns 503 so the vendor retries — this is the one case an inbound webhook handler is allowed to fail loudly.

7.11 The Full Endpoint Reference #

All resource identifiers below use the prefixed base58 public ID form defined in Section 5. All list endpoints accept the pagination parameters of Section 7.4 and, where noted, the filter/sort parameters of Section 7.5. All error codes referenced below are defined in Section 7.6.1. "Auth" values are session (bearer JWT only), apiKey (Business-plan API key only), either, or none.

7.11.1 Auth #

Method & Path Auth Role Description
POST /v1/auth/signup none Create a user account
POST /v1/auth/login none Email/password login, returns session
POST /v1/auth/logout session any Revoke current session
POST /v1/auth/refresh session (cookie) any Exchange session cookie for a fresh JWT
POST /v1/auth/oauth/google/start none Begin Google OAuth PKCE flow
GET /v1/auth/oauth/google/callback none OAuth redirect target
POST /v1/auth/mfa/enroll session any Begin TOTP enrollment, returns QR seed
POST /v1/auth/mfa/verify session any Confirm TOTP code, activates MFA
POST /v1/auth/mfa/challenge none Submit TOTP code during login step-up
POST /v1/auth/password/reset-request none Send password reset email
POST /v1/auth/password/reset none Consume reset token, set new password
POST /v1/auth/email/verify none Consume email verification token

POST /v1/auth/signup body: { email: string (RFC 5322, ≤254 chars), password: string (>= 10 chars), displayName: string (1–120 chars) }. Password policy is a 10-character minimum with no composition requirement — no mandated uppercase, lowercase, or digit class (NIST SP 800-63B; Section 6.1.2 owns the full rationale) — length dominates guessing resistance, and mandatory composition rules push users toward predictable substitutions without measurably improving it. This endpoint does not take a workspaceName field: creating a workspace is a separate, authenticated call, POST /v1/workspaces (Section 7.11.3), made after the account exists.

Success is 202 Accepted, with a generic, enumeration-resistant body identical regardless of whether the email is already registered:

{ "data": { "message": "If this email can be used to create an account, check your inbox for next steps." }, "meta": null }

No user or workspace object is ever echoed back from this endpoint — not the submitted email, not a generated user ID, not a workspace. A 201 that echoed the submitted email (or returned a distinct 409/email_already_registered error for a taken address, as an earlier draft of this endpoint did) is the account-existence oracle Section 6.1.3 exists to prevent: an attacker could enumerate which emails already have accounts simply by watching whether the response differs. Accordingly:

  • Errors: validation_error only (malformed email shape, password under 10 chars, or displayName out of range). There is no email_already_registered code and no other code that reveals account existence.
  • Side effects when the email is new: creates a users row (display_name, emailVerifiedAt = null), sends a verification email (email_log row). No workspaces or workspace_members row is created at signup time — that happens on the caller's first authenticated POST /v1/workspaces call, after login.
  • Side effects when the email is already registered: none visible to the caller beyond the identical 202 response above; internally, a "you already have an account" notice email is sent to the existing address instead of a signup-confirmation email, so a legitimate owner who forgot they had signed up is still helped, without the API itself ever confirming or denying the account's existence.
  • No session is started by this endpoint in either case — the caller authenticates separately via POST /v1/auth/login once their email is verified.

POST /v1/auth/login body: { email, password }. Success 200 returns the current user profile (Section 7.11.2's shape) plus sets the session cookie; if MFA is enrolled, returns 202 with { "data": { "mfaChallengeToken": "..." }, "meta": null } instead, consumed by /v1/auth/mfa/challenge. Errors: validation_error, 401 unauthenticated (code: "invalid_credentials"), 403 forbidden (code: "account_locked" after 10 failed attempts in 15 minutes, unlocked automatically after 15 minutes or via support).

The remaining auth endpoints follow the same pattern (body validated with the shared Zod schema, 200/202 on success, validation_error/unauthenticated on failure) and are detailed fully in the OpenAPI document (Section 7.14); this reference does not restate every field to avoid duplicating Section 6's auth model.

7.11.2 Users #

Method & Path Auth Role Description
GET /v1/users/me session any Current user profile
PATCH /v1/users/me session any Update display name, avatar, notification preferences
DELETE /v1/users/me session any Self-service account deletion request (Section 19)
GET /v1/users/me/sessions session any List active sessions (device/IP/last-seen)
DELETE /v1/users/me/sessions/{sessionId} session any Revoke a specific session

PATCH /v1/users/me body (all optional): { displayName?: string(1-120), avatarUrl?: string(url), notificationPreferences?: { [channel in 'comment_reply'|'video_ready'|'share_activity'|'billing'| 'mention']?: { email?: boolean; inApp?: boolean } } }. notificationPreferences is a partial, per-channel map — the five keys are the notification channels owned by Section 5's notification_preferences schema; a caller sends only the channels it wants to change, and within a given channel object, an omitted email/inApp key retains its current stored value. There is no "replace the whole map" shortcut; sending all five channels explicitly is required to fully overwrite the preference set in one call. Example body setting a single channel:

{ "notificationPreferences": { "video_ready": { "email": false, "inApp": true } } }

Success 200: { "data": { "id": "usr_3mQpXeR8", "email": "hello@cascady.ai", "displayName": "A. User", "avatarUrl": null, "createdAt": "2026-01-04T10:00:00Z" }, "meta": null }. Errors: validation_error, unauthenticated. DELETE /v1/users/me creates a deletion_requests row per Section 19 rather than deleting synchronously; returns 202.

7.11.3 Workspaces #

Method & Path Auth Role Description
POST /v1/workspaces session any Create an additional workspace
GET /v1/workspaces session any List workspaces the caller belongs to
GET /v1/workspaces/{id} session member+ Get workspace details
PATCH /v1/workspaces/{id} session admin+ Update name, slug, settings
DELETE /v1/workspaces/{id} session owner Delete workspace (Section 19 lifecycle)
POST /v1/workspaces/{id}/transfer-ownership session owner Transfer owner to another member

POST /v1/workspaces body: { name: string(1-80) }. Success 201:

{ "data": { "id": "ws_9fTz2Lm1", "name": "Acme", "slug": "acme", "plan": "free", "createdAt": "2026-08-19T09:00:00Z" }, "meta": null }

This is the endpoint that creates a workspace, including a caller's very first one — signup (Section 7.11.1) never does this implicitly; the caller creates their workspace with this endpoint after authenticating, and the calling user is enrolled as role = owner in workspace_members. PATCH body (all optional): { name?: string(1-80), slug?: string(3-40, ^[a-z0-9-]+$, unique) }. Errors: validation_error, forbidden, workspace_not_found, 409 conflict (code: "slug_taken"). DELETE requires role = owner and the caller to re-confirm via Idempotency-Key; returns 202, enqueues the retention/deletion flow of Section 19. POST .../transfer-ownership body: { toUserId: string }; errors add member_not_found, cannot_remove_last_owner is not applicable here (ownership moves, doesn't vacate) but 403 forbidden fires if toUserId is not an existing admin or member.

7.11.4 Workspace Members #

Method & Path Auth Role Description
GET /v1/workspaces/{wsId}/members session member+ List members
PATCH /v1/workspaces/{wsId}/members/{userId} session admin+ Change a member's role
DELETE /v1/workspaces/{wsId}/members/{userId} session admin+ Remove a member

PATCH body: { role: "admin" | "member" | "viewer" }owner is never settable via this endpoint (only via transfer-ownership above). Success 200:

{ "data": { "userId": "usr_7bKq", "workspaceId": "ws_9fTz2Lm1", "role": "member", "updatedAt": "2026-08-19T09:10:00Z" }, "meta": null }

Errors: validation_error, forbidden (an admin cannot promote to/demote an owner), member_not_found, cannot_remove_last_owner. DELETE errors add cannot_remove_last_owner; a removed member's videos are reassigned to the workspace's audit trail but not deleted (Section 19 owns retention on member departure).

7.11.5 Workspace Invites #

Method & Path Auth Role Description
POST /v1/workspaces/{wsId}/invites session admin+ Invite by email
GET /v1/workspaces/{wsId}/invites session admin+ List pending invites
DELETE /v1/workspaces/{wsId}/invites/{id} session admin+ Revoke a pending invite
POST /v1/invites/{token}/accept session any (auth'd user) Accept an invite

POST /v1/workspaces/{wsId}/invites body: { email: string, role: "admin"|"member"|"viewer" }. Success 201:

{ "data": { "id": "wsi_4kLp", "email": "new@acme.com", "role": "member", "status": "pending", "expiresAt": "2026-09-02T09:00:00Z" }, "meta": null }

Errors: validation_error, forbidden, seat_limit_exceeded (only when role != "viewer" and the plan's purchased recording seats are exhausted — viewer invites never hit this per Section 21), 409 conflict (code: "already_member"). Idempotency-capable. Invite expiry is 14 days. POST /v1/invites/{token}/accept errors: invite_not_found, invite_expired, 409 conflict (code: "email_mismatch" if the authenticated user's email differs from the invite).

7.11.6 Folders #

Method & Path Auth Role Description
POST /v1/folders session member+ Create a folder
GET /v1/folders session member+ List folders (tree, filterable by parentFolderId)
GET /v1/folders/{id} session member+ (viewer if shared) Get folder + permission summary
PATCH /v1/folders/{id} session member+ (creator/admin+) Rename, move, reorder
DELETE /v1/folders/{id} session member+ (creator/admin+) Delete (query force=true to cascade)

POST /v1/folders body: { name: string(1-120), parentFolderId?: string|null }. Success 201:

{ "data": { "id": "fld_2xQ", "name": "Onboarding Demos", "parentFolderId": null, "workspaceId": "ws_9fTz2Lm1", "createdAt": "2026-08-19T09:15:00Z" }, "meta": null }

Errors: validation_error, forbidden, folder_not_found (bad parentFolderId), folder_cycle. DELETE without force=true on a non-empty folder returns 409 / folder_not_empty; with force=true, videos move to the workspace's root (never deleted by a folder delete).

7.11.7 Videos #

Method & Path Auth Role Description
GET /v1/videos session/apiKey member+ List videos (filter folderId, status, q search)
GET /v1/videos/{id} session/apiKey member+ (viewer via share) Get video detail
PATCH /v1/videos/{id} session/apiKey owner-of-video or admin+ Update title, description, folder, thumbnail
DELETE /v1/videos/{id} session/apiKey owner-of-video or admin+ Soft-delete
POST /v1/videos/{id}/restore session admin+ Undo a soft delete within the recovery window
POST /v1/videos/{id}/duplicate session member+ Duplicate video metadata + EDL (new media assets referenced, not re-encoded)

GET /v1/videos/{id} success 200:

{
  "data": {
    "id": "vid_7hRnKcQ2",
    "workspaceId": "ws_9fTz2Lm1",
    "folderId": "fld_2xQ",
    "title": "Q3 onboarding walkthrough",
    "description": "",
    "status": "ready",
    "durationMs": 184230,
    "sizeBytes": 48213899,
    "posterUrl": "https://cdn.reelay.app/p/vid_7hRnKcQ2/v3/9f1c2e8b7a4d.jpg?sig=eyJhbGciOiJIUzI1NiJ9",
    "thumbnailUrl": "https://cdn.reelay.app/t/vid_7hRnKcQ2/v3/4b7e9a2f10c3.jpg?sig=eyJhbGciOiJIUzI1NiJ9",
    "playbackId": "mux_abc123",
    "autoEditPresetVersion": 2,
    "createdAt": "2026-08-18T14:02:00Z",
    "updatedAt": "2026-08-18T14:10:00Z",
    "etag": "W/\"vid_7hRnKcQ2-7\""
  },
  "meta": null
}

status enum: pending_upload | processing | ready | failed. As specified in full by Sections 14 and 22.2 (this endpoint only surfaces the resulting URL shape), posterUrl and thumbnailUrl are never a permanent, guessable URL. Each is a signed CDN path whose v3 segment above is the asset's current playbackKeyVersion and whose remaining path segment is a content hash — the combination gives the object a Cache-Control: public, max-age=31536000, immutable CDN policy (content-hashed keys never need revalidation) while still being revocable: a visibility downgrade or link revocation (Section 14.1.1) bumps the version, which both changes the path (busting any downstream cache) and invalidates the sig query parameter for the old path at the CDN edge. A client that receives a 403 fetching a previously-cached poster/thumbnail URL should re-GET this endpoint to obtain the current signed path rather than treating it as a hard failure. PATCH body (all optional): { title?: string(1-200), description?: string(0-5000), folderId?: string|null, posterAssetId?: string }. Supports If-Match; mismatch → 412 precondition_failed. Moving a video via folderId requires the caller to hold edit-or-manage access to the destination folder (the folder-permission enum of Section 6.8.1/18.2.1), independent of the caller's access to the video's current folder — a member who can edit a video sitting in a folder they own cannot use this call to drop it into a private folder they only have view access to; that returns 403 / folder_access_denied. Errors: validation_error, forbidden, video_not_found, folder_access_denied, precondition_failed. DELETE errors add nothing (idempotent — deleting an already-deleted video returns 200 with the same body, deletedAt populated); per Section 5, deleted_at is a soft-delete column, playback via existing share links is unaffected by this call per the Section 21 iron rule (a DELETE here is an explicit user action, not a plan cap — the iron rule governs cap-driven restrictions, not user deletion, and Section 19 governs the resulting purge timeline). POST .../restore errors: video_not_found, 409 conflict (code: "restore_window_expired", 30-day window per Section 19).

7.11.8 Recordings #

Recordings represent a single capture session prior to becoming a published video; a recording is promoted to a videos row once its upload completes and probing succeeds.

Method & Path Auth Role Description
POST /v1/recordings session member+ Start a recording session (device metadata, not media bytes)
GET /v1/recordings/{id} session member+ Recording status
POST /v1/recordings/{id}/finalize session member+ Mark capture complete, link to an upload session, trigger ingest
POST /v1/recordings/{id}/cursor-telemetry session member+ Upload a batch of cursor telemetry samples (Section 10)

POST /v1/recordings body: { source: "browser"|"desktop", captureType: "screen"|"camera"|"screen_camera", mimeType: string, expectedDurationMs?: integer }. Success 201:

{ "data": { "id": "rec_9pLk", "status": "capturing", "source": "desktop", "createdAt": "2026-08-19T09:20:00Z" }, "meta": null }

Errors: validation_error, recording_too_long (only when expectedDurationMs is supplied and exceeds the plan cap — the authoritative check is re-run server-side on finalize against actual duration). POST .../finalize body: { uploadSessionId: string, actualDurationMs: integer, mimeType: string }. Errors: validation_error, resource_not_found (bad uploadSessionId), recording_too_long. Side effect: creates the videos row (status = processing), enqueues video.ingest (Section 9). POST .../cursor-telemetry body: { samples: [{ tMs: integer, x: number, y: number, event: "move"|"click"|"drag_start"|"drag_end"|"scroll"|"key"|"focus_change", ... }], sampleRateHz: 60|120 }, batched every ~2s from the capture client; stored to cursor_telemetry_blobs (one row per recording, append-only, compressed) for the AI auto-edit engine (Section 10). Max batch size 5000 samples / 2 MB; larger batches return 413 payload_too_large.

7.11.9 Upload Sessions (Multipart) #

Method & Path Auth Role Description
POST /v1/upload-sessions session member+ Initialize a multipart upload
PUT /v1/upload-sessions/{id}/parts/{partNumber} session member+ Upload one part's bytes
POST /v1/upload-sessions/{id}/complete session member+ Finalize (assemble) the multipart object
POST /v1/upload-sessions/{id}/abort session member+ Cancel and release storage
GET /v1/upload-sessions/{id} session member+ Status + list of received parts (for resume)

POST /v1/upload-sessions body: { purpose: "recording"|"screenshot_original", sizeBytes: integer, mimeType: string, sha256?: string }. Success 201:

{
  "data": {
    "id": "ups_4tRq",
    "status": "open",
    "partSizeBytes": 8388608,
    "maxConcurrentParts": 4,
    "expiresAt": "2026-08-20T09:20:00Z",
    "createdAt": "2026-08-19T09:20:00Z"
  },
  "meta": null
}

Errors: validation_error, plan_limit_exceeded (storage quota, Section 21). Idempotency-capable. PUT .../parts/{partNumber}Content-Type: application/octet-stream, raw bytes as body, partNumber is a 1-based integer. Body size must be exactly partSizeBytes (8 MiB) except for the final part, which may be smaller (min 5 MiB unless it is also the only part). Success 200:

{ "data": { "partNumber": 3, "sizeBytes": 8388608, "etag": "\"9f86d0...\"", "receivedAt": "2026-08-19T09:21:04Z" }, "meta": null }

Errors: validation_error, upload_session_not_found, upload_session_expired, upload_part_size_invalid, payload_too_large (part > 8 MiB and not overridden), checksum_mismatch (when the client also sends Content-MD5 and it disagrees with the computed value — optional but recommended). Concurrency: up to 4 parts in flight per session (Section 13); the server accepts out-of-order part uploads (no upload_part_out_of_order restriction on this endpoint — sequential ordering is only enforced where explicitly noted, and multipart upload allows arbitrary part order per the S3 multipart model) and stores each part's ETag for the eventual complete call. POST .../complete body: { parts: [{ partNumber: integer, etag: string }] } (must list every part in ascending order). Errors: validation_error, upload_session_not_found, upload_incomplete (missing part or etag mismatch vs. what the server recorded), checksum_mismatch (whole-object sha256 provided at init disagrees with the assembled object). Success 200 returns the finalized media_assets row with a sizeBytes and storage key; for purpose = "recording", the caller then calls POST /v1/recordings/{id}/finalize referencing this session. POST .../abort releases the incomplete multipart upload on the object store and marks the session aborted; idempotent (aborting an already-aborted session is 200, not an error).

7.11.10 Edit Decision Lists (EDLs) #

Method & Path Auth Role Description
GET /v1/videos/{videoId}/edl session/apiKey member+ Get the current EDL
PUT /v1/videos/{videoId}/edl session owner-of-video or admin+ Replace the EDL wholesale
POST /v1/videos/{videoId}/edl/operations session owner-of-video or admin+ Append/patch a single operation
POST /v1/videos/{videoId}/edl/reset session owner-of-video or admin+ Restore to the original, unedited timeline

PUT body is the full EDL document (schema owned and defined in Section 11; this section only documents the transport). Errors: validation_error, video_not_found, edl_invalid_operation, forbidden. POST .../reset returns the EDL with zero operations — per Section 11's invariant, this is always achievable losslessly because the original recording is immutable and operations are non-destructive references into it, never in-place edits.

7.11.11 Renders #

Method & Path Auth Role Description
POST /v1/videos/{videoId}/renders session/apiKey member+ Request a render from the current EDL
GET /v1/videos/{videoId}/renders/{id} session/apiKey member+ Render job status
GET /v1/videos/{videoId}/renders session/apiKey member+ List render history

POST body: { } (renders always target the video's current EDL + current auto-edit preset; there are no ad hoc render parameters — this keeps renders deterministic and reproducible per Section 10's determinism requirement). Success 202:

{ "data": { "id": "rjb_8mNq", "status": "queued", "edlVersion": 4, "requestedAt": "2026-08-19T09:30:00Z" }, "meta": null }

Errors: validation_error, video_not_found, render_already_in_progress (a queued/running render already targets the same edlVersion; the existing job's ID is returned instead in data, HTTP 200 not 409, since this is a benign convergence, not a client error — this is the one documented exception where a "conflict" is resolved as a success). Idempotency-capable via Idempotency-Key as an additional guard against double-submission from flaky clients. status enum: queued | rendering | ready | failed. A ready render's detail response includes renditionIds: string[] referencing the resulting renditions rows.

7.11.12 Exports #

Method & Path Auth Role Description
POST /v1/videos/{videoId}/exports session/apiKey member+ Request a downloadable export
GET /v1/videos/{videoId}/exports/{id} session/apiKey member+ Export status + download URL
GET /v1/videos/{videoId}/exports session/apiKey member+ List export history

POST body: { format: "mp4"|"webm"|"gif", resolution: "480p"|"720p"|"1080p"|"1440p"|"4k", aspectRatio?: "16:9"|"9:16"|"1:1"|"4:5" }. Success 202:

{ "data": { "id": "exp_1qWs", "status": "queued", "format": "mp4", "resolution": "1080p" }, "meta": null }

Errors: validation_error, video_not_found, export_format_unsupported, plan_limit_exceeded (Free plan is capped at 720p + forced watermark per Section 21 — a resolution above 720p or an attempt to suppress the watermark on Free returns this code rather than silently downgrading). Idempotency-capable. status enum matches renders; ready responses include downloadUrl (a short-TTL signed CDN URL, 1h expiry, regenerable via GET on the same endpoint which reissues a fresh signed URL without re-encoding).

7.11.13 Transcripts, Captions & Chapters #

Method & Path Auth Role Description
GET /v1/videos/{videoId}/transcript session/apiKey member+/viewer via share Full transcript with segment timestamps
PATCH /v1/videos/{videoId}/transcript/segments/{segmentId} session owner-of-video or admin+ Correct a transcript segment's text
GET /v1/videos/{videoId}/captions session/apiKey/none-via-share member+/viewer List caption tracks
POST /v1/videos/{videoId}/captions session member+ Add/generate a caption track for a language
GET /v1/videos/{videoId}/captions/{lang}.vtt none (share-gated) viewer Raw WebVTT file
DELETE /v1/videos/{videoId}/captions/{lang} session owner-of-video or admin+ Remove a caption track
GET /v1/videos/{videoId}/chapters session/apiKey member+/viewer List chapters
POST /v1/videos/{videoId}/chapters session member+ Add a chapter manually
PATCH /v1/videos/{videoId}/chapters/{id} session member+ Edit title/start time
DELETE /v1/videos/{videoId}/chapters/{id} session member+ Remove a chapter

GET .../transcript success 200:

{
  "data": {
    "id": "trs_5pQm",
    "videoId": "vid_7hRnKcQ2",
    "language": "en",
    "status": "ready",
    "segments": [
      { "id": "tseg_1", "startMs": 0, "endMs": 3200, "text": "Hey team, quick walkthrough of the new flow." }
    ]
  },
  "meta": null
}

PATCH .../segments/{id} body: { text: string(1-2000) }. Per Section 12, an edited transcript segment corrects the displayed/captioned text only — it never rewrites the underlying audio, and edits are tracked with editedAt/editedByUserId for audit purposes. Errors: validation_error, transcript_not_found, forbidden. POST .../captions body: { language: string(BCP-47), source: "auto_translate"|"upload", vttContent?: string }vttContent required when source = "upload". Errors: validation_error, video_not_ready, caption_not_found not applicable here; plan_limit_exceeded if auto-translate is requested beyond the plan's included language count (2 languages on Free, unlimited on Pro/Business). Chapters: POST/PATCH body { title: string(1-100), startMs: integer }; errors validation_error, chapter_not_found, 400 (code: "validation_error", detail issue: "overlaps_existing_chapter" when startMs falls within 1000ms of another chapter's start).

7.11.14 AI Metadata Suggestions #

Method & Path Auth Role Description
GET /v1/videos/{videoId}/suggestions session member+ List pending AI suggestions (title, summary, chapters)
POST /v1/videos/{videoId}/suggestions/{id}/accept session member+ Apply a suggestion
POST /v1/videos/{videoId}/suggestions/{id}/reject session member+ Dismiss a suggestion

Success 200 on list:

{ "data": [ { "id": "sug_2kLw", "type": "title", "proposedValue": "Q3 Onboarding Walkthrough — 3 min", "status": "pending", "createdAt": "2026-08-18T14:05:00Z" } ], "meta": null }

type enum: title | summary | chapters. Errors on accept/reject: suggestion_not_found, suggestion_already_resolved, forbidden. Accepting a chapters suggestion creates the corresponding chapters rows atomically (all-or-nothing) rather than one at a time.

7.11.15 Screenshots #

Method & Path Auth Role Description
POST /v1/videos/{videoId}/screenshots session member+ Capture a frame at a timestamp, or upload a standalone screenshot
GET /v1/videos/{videoId}/screenshots session/apiKey member+ List screenshots for a video
GET /v1/screenshots/{id} session/apiKey member+ Get one screenshot
PATCH /v1/screenshots/{id} session member+ Update beautification settings (background, padding, shadow)
DELETE /v1/screenshots/{id} session member+ Delete

POST body: { sourceVideoTimestampMs?: integer, uploadSessionId?: string, background?: { preset: "gradient"|"solid"|"image"|"blurred_screenshot"|"macos_desktop", paddingPercent?: number(0-20), cornerRadiusPx?: integer(0-64) } } — exactly one of sourceVideoTimestampMs/uploadSessionId is required. Errors: validation_error, video_not_found, resource_not_found (bad uploadSessionId). Success 201 returns the screenshots row with a imageUrl once beautified (the beautification pipeline shares the background/padding/shadow specification of Section 10).

Method & Path Auth Role Description
POST /v1/videos/{videoId}/share-links session/apiKey member+ Create a share link
GET /v1/videos/{videoId}/share-links session/apiKey member+ List share links for a video
GET /v1/share-links/{id} session/apiKey member+ Get one link's settings
PATCH /v1/share-links/{id} session member+ (creator or admin+) Update visibility/password/expiry/etc.
DELETE /v1/share-links/{id} session member+ (creator or admin+) Revoke a link
POST /v1/share-links/{id}/recipients session member+ Add personalized recipients
GET /v1/share-links/{id}/recipients session member+ List recipients + open status
DELETE /v1/share-links/{id}/recipients/{recipientId} session member+ Revoke one recipient's access
POST /v1/watch/{slug}/verify-password none viewer Verify a link password, returns a short-TTL viewer token

POST /v1/videos/{videoId}/share-links body: { visibility: "private"|"workspace"|"link"|"public", password?: string(8-72), expiresAt?: string(ISO8601)|null, domainAllowlist?: string[], disableDownload?: boolean, disableComments?: boolean, requireEmailToWatch?: boolean }. Success 201:

{
  "data": {
    "id": "lnk_9pXk",
    "videoId": "vid_7hRnKcQ2",
    "slug": "k7m2Qp9RtLxa",
    "url": "https://reelay.link/k7m2Qp9RtLxa",
    "visibility": "link",
    "passwordProtected": false,
    "expiresAt": null,
    "disableDownload": false,
    "disableComments": false,
    "requireEmailToWatch": false,
    "createdAt": "2026-08-19T09:40:00Z"
  },
  "meta": null
}

Errors: validation_error, video_not_found, plan_limit_exceeded (domainAllowlist/custom-domain-bound links require Business, Section 21). domainAllowlist enforcement differs by how the video is watched: on the standalone watch page it is a server-side Referer check (share_link_domain_forbidden on mismatch, above); for an embedded player it is enforceable only in loader-script embed mode, via the postMessage-based host-origin handshake owned by Section 15 — the iframe-fallback embed path has no reliable way to identify the host page's origin (the Origin header inside the iframe always reads the embed subdomain, never the host page's) and therefore a domain-restricted link simply refuses to play there, surfacing an explanatory message rather than either silently allowing or silently failing. This is a stated product limitation, not a bug. Every PATCH here writes a share_audit_events row (actor, before, after, timestamp, IP) per Section 17 — this is non-optional and cannot be disabled, including by API-key callers. The 12-char base58 slug is generated server-side with a CSPRNG and a unique index; it is never derived from the video ID or any sequential counter. POST .../recipients body: { recipients: [{ email: string, name?: string }] } (max 500 per call). Success 201 returns recipient rows each with a recipientToken (opaque, embedded in personalized URLs, used for named-viewer analytics per Section 16). Errors: validation_error, share_link_not_found, plan_limit_exceeded (recipient lists require Pro+). POST /v1/watch/{slug}/verify-password body: { password: string }. Errors: share_link_not_found, share_link_expired, 401 (code: "share_link_password_required" reused for "incorrect password" too, since revealing which is true would aid brute force) with a 10 attempts/hour per-IP limit beyond which 429 rate_limited applies.

7.11.17 Comments & Reactions #

Method & Path Auth Role Description
GET /v1/videos/{videoId}/comments session/apiKey/viewer-via-share member+/viewer List comments (threaded, paginated)
POST /v1/videos/{videoId}/comments session/viewer-via-share member+/viewer Post a comment (optionally timestamped)
PATCH /v1/comments/{id} session/viewer-via-share (author only) any Edit own comment
DELETE /v1/comments/{id} session/viewer-via-share (author) or admin+ any/admin+ Delete (soft)
POST /v1/comments/{id}/reactions session/viewer-via-share any React with an emoji
DELETE /v1/comments/{id}/reactions/{reactionId} session/viewer-via-share (own reaction) any Remove own reaction

Viewer (non-workspace) callers authenticate to comment endpoints with the viewer token established by share-link access (Section 14/17), passed as Authorization: Bearer <viewerToken> — distinct from both the session JWT and API keys, and accepted ONLY on the comment/reaction endpoints in this subsection plus GET on videos/transcripts/captions/chapters reached via a valid share link.

POST /v1/videos/{videoId}/comments body: { body: string(1-4000), timestampMs?: integer, parentCommentId?: string }. Success 201:

{ "data": { "id": "cmt_6vDq", "videoId": "vid_7hRnKcQ2", "authorType": "viewer", "authorName": "Jamie", "body": "Great walkthrough!", "timestampMs": 42000, "parentCommentId": null, "createdAt": "2026-08-19T09:45:00Z" }, "meta": null }

Errors: validation_error, video_not_found, comments_disabled, share_link_email_required (when the link requires email capture before any interaction, not just playback), comment_not_found (bad parentCommentId). Reactions body: { emoji: string }, restricted to a fixed set (👍 ❤️ 😂 🎉 😮 👏); other values → reaction_invalid.

7.11.18 CTAs & Email Captures #

Method & Path Auth Role Description
POST /v1/videos/{videoId}/ctas session member+ Create a CTA overlay
GET /v1/videos/{videoId}/ctas session/apiKey member+ List CTAs
PATCH /v1/ctas/{id} session member+ Update a CTA
DELETE /v1/ctas/{id} session member+ Delete a CTA
POST /v1/ctas/{id}/events none (share-gated) viewer Record a click/impression (also flows through /v1/collect, Section 7.11.19)
GET /v1/videos/{videoId}/email-captures session/apiKey member+ List captured emails for a video

POST /v1/videos/{videoId}/ctas body: { type: "button"|"banner"|"end_card", label: string(1-60), targetUrl: string(url), showAtMs?: integer, showUntilMs?: integer }. Success 201 returns the CTA row with id: "cta_3nRt". Errors: validation_error, video_not_found. email-captures list success 200:

{ "data": [ { "id": "ecp_8jKw", "email": "jamie@example.com", "shareLinkId": "lnk_9pXk", "capturedAt": "2026-08-19T09:44:10Z" } ], "meta": { "nextCursor": null, "hasMore": false } }

7.11.19 Analytics Queries & Collection #

Method & Path Auth Role Description
POST /v1/collect none Ingest a batch of viewer events (Section 16)
POST /v1/analytics/query session/apiKey member+ Ad hoc query over view/engagement data
GET /v1/videos/{videoId}/analytics/summary session/apiKey member+ Precomputed summary (views, avg watch %, drop-off)
GET /v1/videos/{videoId}/analytics/engagement-curve session/apiKey member+ Per-second retention buckets

POST /v1/collect body: { playbackToken: string, shareLinkId: string, videoId: string, events: [{ type: "heartbeat"|"play"|"pause"|"seek"|"complete"|"cta_click"|"email_submit"|"reaction"|"comment", tMs: integer, positionMs: integer, ...typeSpecificFields }] }, sent via navigator.sendBeacon, max 50 events/batch. playbackToken is the signed playback token minted by the Section 14.8.1 access chain at the moment the viewer's watch session was authorized — never a client-generated value. A client-generated viewer token cannot be substituted here: the token is verified against the same signature the player used to fetch the manifest, so a caller cannot forge analytics for a video it never actually had a valid playback session for. Every event's shareLinkId must match the share link the playbackToken was minted for; an event whose shareLinkId does not match the token's bound link is dropped from processing before it reaches storage — not persisted, not counted, and not surfaced as a per-event error, consistent with this endpoint's overall silent posture. No Authorization header is used. Rate limiting for this endpoint is stated once, in Section 7.9 (the collect class, keyed by playbackToken) — it is not restated here. Errors: validation_error only (malformed batch shape or unparseable playbackToken); an unknown videoId, an expired/invalid playbackToken, or a shareLinkId mismatch are all accepted and silently no-op'd rather than erroring, to avoid leaking video or share-link existence to unauthenticated probing, and because a late beacon for an already-deleted video or already-revoked link is expected, not exceptional. Always returns 202 with an empty data: null body.

POST /v1/analytics/query body: { videoIds?: string[], folderId?: string, metric: "views"| "uniqueViewers"|"avgWatchPercent"|"completionRate"|"ctaClicks"|"emailCaptures", groupBy?: "day"| "week"|"video", dateFrom: string(ISO8601), dateTo: string(ISO8601) }. Errors: validation_error, usage_query_range_too_large (max 400-day window), analytics_query_invalid, plan_limit_exceeded (per-viewer breakdowns require Pro+, Section 21 — the groupBy value viewer is rejected with this code on Free). Success 200 returns an array of { bucket: string, value: number } rows in data.

7.11.20 Brand Kits #

Method & Path Auth Role Description
POST /v1/workspaces/{wsId}/brand-kits session admin+ Create a brand kit
GET /v1/workspaces/{wsId}/brand-kits session member+ List brand kits
PATCH /v1/brand-kits/{id} session admin+ Update logo/colors/font/enforcement
DELETE /v1/brand-kits/{id} session admin+ Delete

POST body: { name: string(1-80), logoAssetId?: string, primaryColor?: string(hex), accentColor?: string(hex), fontFamily?: string, enforced?: boolean }. enforced: true requires Business plan (workspace-enforced branding, Section 21) — Free returns no brand kit access at all (403 forbidden), Pro may create one but enforced is rejected with plan_limit_exceeded. Errors: validation_error, forbidden, plan_limit_exceeded.

7.11.21 Custom Domains #

Method & Path Auth Role Description
POST /v1/workspaces/{wsId}/custom-domains session admin+ Register a CNAME domain
GET /v1/workspaces/{wsId}/custom-domains session admin+ List
POST /v1/custom-domains/{id}/verify session admin+ Trigger DNS verification check
DELETE /v1/custom-domains/{id} session admin+ Remove

POST body: { hostname: string(FQDN) }. Business plan only — custom_domain_requires_business on Free/Pro. Success 201 returns the domain row with status: "pending_verification" and the required DNS records (cnameTarget: "domains.reelay.app", txtRecordName, txtRecordValue). POST .../verify errors: custom_domain_not_found, custom_domain_verification_failed (re-checks DNS synchronously, times out at 5s and returns this code rather than hanging).

7.11.22 API Keys #

Method & Path Auth Role Description
POST /v1/workspaces/{wsId}/api-keys session admin+ Create a key
GET /v1/workspaces/{wsId}/api-keys session admin+ List keys (prefix only, never full value)
PATCH /v1/api-keys/{id} session admin+ Rename, change scopes
POST /v1/api-keys/{id}/rotate session admin+ Rotate the key's secret
DELETE /v1/api-keys/{id} session admin+ Revoke

Business plan only (api_key_requires_business otherwise). POST body: { name: string(1-60), scopes: string[] } — see Section 7.12 for the scope catalogue. Success 201 is the only response that ever includes the full secret:

{ "data": { "id": "api_5tRw", "name": "Zapier integration", "prefix": "sk_live_5tRw", "secret": "sk_live_5tRwK9mQpXeR8vNzL2hTgYcFdAbEjHkMnPq", "scopes": ["videos:read", "webhooks:manage"], "createdAt": "2026-08-19T10:00:00Z" }, "meta": null }

The secret is SHA-256 hashed at rest; only prefix (first 12 chars) is retrievable thereafter. PATCH /v1/api-keys/{id} body (all optional): { name?: string(1-60), scopes?: string[] } — a rename or scope change only; it never touches the secret. POST /v1/api-keys/{id}/rotate takes no request body: it issues a new secret (returned once, same response shape as creation) and invalidates the old one after a 24h grace window during which both work, to allow zero-downtime credential swap in integrator systems — the identical grace-window pattern used for webhook-secret rotation (Section 7.11.23), so the two mechanisms are consistent for anyone rotating both credential types in the same maintenance window. Errors (both PATCH and POST .../rotate): validation_error, api_key_requires_business, api_key_not_found, forbidden (a requested scope that is not present in the Section 7.12 catalogue is rejected outright — there is no scope for billing/subscription actions, since those endpoints are session-only per Section 7.12 and can never be granted to any key, regardless of the creating admin's own role).

7.11.23 Webhook Endpoints #

Method & Path Auth Role Description
POST /v1/workspaces/{wsId}/webhook-endpoints session/apiKey admin+ Register an outbound webhook endpoint
GET /v1/workspaces/{wsId}/webhook-endpoints session/apiKey admin+ List
PATCH /v1/webhook-endpoints/{id} session/apiKey admin+ Update URL/events/enabled state
POST /v1/webhook-endpoints/{id}/rotate session/apiKey admin+ Rotate the endpoint's signing secret
DELETE /v1/webhook-endpoints/{id} session/apiKey admin+ Delete
GET /v1/webhook-endpoints/{id}/deliveries session/apiKey admin+ Delivery log (Section 7.13)
POST /v1/webhook-endpoints/{id}/deliveries/{deliveryId}/redeliver session/apiKey admin+ Manually retry one delivery
POST /v1/webhook-endpoints/{id}/test session/apiKey admin+ Send a synthetic test event

Business plan only (Section 7.12/7.13). POST body: { url: string(https url), events: string[] (from the catalogue in 7.13.1), description?: string(0-200) }. Success 201:

{ "data": { "id": "whe_2kMn", "url": "https://hooks.acme.com/reelay", "events": ["video.ready", "comment.created"], "enabled": true, "secret": "whsec_3fJd...", "createdAt": "2026-08-19T10:05:00Z" }, "meta": null }

The secret is shown in full only on creation and on POST /v1/webhook-endpoints/{id}/rotate (no request body required; same 24h-grace-window pattern as API-key rotation, Section 7.11.22); subsequent GETs show a redacted whsec_...**** form. Creation performs a synchronous test ping to url; if it does not respond 2xx within 5s, creation still succeeds (the endpoint is stored enabled: true) but the response includes a warning field meta: { urlValidation: "unreachable" } rather than hard-failing — this avoids blocking setup before an integrator's receiver is deployed, at the cost of one avoidable initial failed delivery, a deliberate trade-off. Errors: validation_error, forbidden, webhook_endpoint_not_found.

7.11.24 Billing, Subscription & Usage #

Method & Path Auth Role Description
GET /v1/workspaces/{wsId}/subscription session admin+ (billing fields owner-only) Current plan/subscription
POST /v1/workspaces/{wsId}/subscription/checkout session owner Start a Stripe Checkout session for plan change
POST /v1/workspaces/{wsId}/subscription/portal session owner Start a Stripe Billing Portal session
POST /v1/workspaces/{wsId}/subscription/cancel session owner Cancel at period end
GET /v1/workspaces/{wsId}/invoices session owner List invoices
GET /v1/workspaces/{wsId}/usage session/apiKey admin+ Current usage vs. plan caps (Section 21)
POST /v1/webhooks/stripe none (Stripe-signed) Stripe billing event receiver (Section 7.10.2)

POST .../checkout body: { targetPlan: "pro"|"business", seats: integer(≥1), billingCycle: "monthly"|"annual" }. Success 200: { "data": { "checkoutUrl": "https://checkout.stripe.com/..." }, "meta": null }. Errors: validation_error, forbidden (non-owner). GET .../usage success 200:

{
  "data": {
    "seatsUsed": 4, "seatsPurchased": 5,
    "storageUsedBytes": 128849018880, "storageQuotaBytes": 1073741824000,
    "libraryVideoCount": 212, "libraryVideoCap": null,
    "warnAt": 0.8
  },
  "meta": null
}

null caps indicate "unlimited" for the current plan per Section 21's table. GET .../invoices returns Stripe-sourced invoice summaries mirrored into the invoices table by the Stripe webhook handler (Section 7.10.2), id prefixed inv_. Errors across this group: payment_required (a workspace in past_due gets this on any billing-mutating call except portal, which is exactly how the user resolves it).

7.11.25 Audit Log #

Method & Path Auth Role Description
GET /v1/workspaces/{wsId}/audit-events session admin+ Query the audit log
GET /v1/workspaces/{wsId}/share-audit-events session admin+ Share-link-specific audit trail (Section 17)

GET /v1/workspaces/{wsId}/audit-events query params: actorId?, action?, resourceType?, createdAfter?, createdBefore? (all combinable per Section 7.5). Success 200:

{ "data": [ { "id": "aud_7pKq", "actorType": "user", "actorId": "usr_3mQpXeR8", "action": "member.role_changed", "resourceType": "workspace_member", "resourceId": "usr_7bKq", "before": { "role": "viewer" }, "after": { "role": "member" }, "ip": "203.0.113.4", "createdAt": "2026-08-19T09:10:00Z" } ], "meta": { "nextCursor": null, "hasMore": false } }

Errors: forbidden (returned as audit_log_requires_admin specifically, not the generic forbidden, so integrators can distinguish "wrong role" from other permission failures here). Audit events are immutable and retained per Section 19's retention policy regardless of workspace plan tier.

7.12 The Public API Subset (Business Plan) #

  • The public API is not a different set of routes — it is the same /v1 surface, gated by two independent controls: (1) the workspace must be on the Business plan (Section 21; downgrading off Business immediately disables all API keys for creation calls, per the iron rule they never break already-delivered webhook history, but new calls return 402 / api_key_requires_business), and (2) the specific endpoint must accept apiKey auth per the "Auth" column in Section 7.11 — most write-heavy account/billing/member-management endpoints are session-only by design, since they represent human administrative actions Reelay does not want automatable by a leaked key.
  • What is exposed (an API key with the right scope may call): videos (read + create via recording/upload flow + update + delete), folders, EDLs (read + write), renders, exports, transcripts/captions/chapters (read + write), AI suggestions (read + accept/reject), screenshots, share links + recipients (full CRUD), comments (read + moderate/delete, not post-as-viewer), CTAs, email captures (read), analytics queries and usage, webhook endpoints and their delivery log, and the audit log (read).
  • What is never exposed to an API key, regardless of scope: authentication endpoints, user self-service endpoints (/v1/users/me/*), workspace member/invite/role management, billing and subscription mutation (checkout/portal/cancel — GET usage is allowed, mutation is not), brand kit and custom domain management, API key management itself (a key cannot create or rotate other keys), and workspace deletion. These stay session-only because they represent trust-boundary or billing decisions that require a human in a browser session, MFA-capable and cookie-bound. There is, correspondingly, no billing:* scope in the catalogue below or anywhere else in this document — billing/subscription actions are not a scoped capability at all, they are simply absent from the API-key-accessible surface.
  • Scope model: scopes are resource:action pairs, action ∈ {read, write} except a small number of manage scopes for endpoints with their own lifecycle (webhooks). Catalogue:
Scope Grants
videos:read GET on videos, EDLs, transcripts, captions, chapters, suggestions, screenshots
videos:write POST/PATCH/DELETE on videos, EDLs, renders, exports, screenshots, suggestion accept/reject
folders:read / folders:write Folder CRUD
share_links:read / share_links:write Share link + recipient CRUD
comments:read / comments:write Comment read / moderate-delete (never post-as-viewer)
analytics:read Analytics queries, usage, engagement curves
webhooks:manage Full CRUD on webhook endpoints + delivery log + redeliver + test + rotate
audit:read Audit log read

A key is created with an explicit subset of these scopes (Section 7.11.22); any call outside its granted scopes returns 403 forbidden even if the endpoint would otherwise accept apiKey auth.

  • SDKs: an official TypeScript/JavaScript SDK (@reelay/sdk) and a Python SDK (reelay) are generated directly from the OpenAPI document (Section 7.14), published to npm and PyPI on every v1 additive release. Both implement the rate-limit backoff behavior of Section 7.9 and idempotency-key generation for Section 7.7 automatically.

7.13 Outbound Webhooks #

Outbound webhooks notify a workspace's registered endpoints (Section 7.11.23) of events happening in that workspace. Available on Business plan only, matching the public API gate in Section 7.12.

7.13.1 Event Catalogue #

Event Fires when Payload highlights
video.created A recording finalizes into a new videos row videoId, title, folderId
video.ready Transcode/render pipeline completes and the video becomes playable videoId, durationMs, playbackId
video.processing_failed The pipeline terminally fails videoId, failureStage
video.deleted A video is soft-deleted videoId, deletedAt
video.viewed A video_view_events row records a new distinct viewer session past a 3s watch threshold (avoids notifying on bounce) videoId, viewerToken, shareLinkId
video.shared A new share link is created videoId, shareLinkId, visibility
share_link.visibility_changed A share link's visibility field changes via PATCH shareLinkId, before, after
comment.created A new top-level comment or reply is posted commentId, videoId, authorType
email.captured A viewer submits an email (gated link, or CTA) email, videoId, shareLinkId
cta.clicked A CTA click event is ingested ctaId, videoId, targetUrl
export.ready An export job completes exportId, videoId, format, downloadUrl

New event types are additive within v1; a receiver that has not opted into a new event type simply never receives it (subscription is per-endpoint via the events array on webhook_endpoints, Section 7.11.23).

7.13.2 Payload Envelope #

{
  "id": "evt_9nRp2LkQ",
  "type": "video.ready",
  "createdAt": "2026-08-19T10:15:00Z",
  "workspaceId": "ws_9fTz2Lm1",
  "apiVersion": "v1",
  "data": { "videoId": "vid_7hRnKcQ2", "durationMs": 184230, "playbackId": "mux_abc123" }
}
  • id is unique per delivery attempt-group (retries of the same logical event reuse this id, which is the receiver's dedup key — mirroring the inbound pattern of Section 7.10).
  • data shape per event type is documented in the OpenAPI document (Section 7.14) as named schema components (VideoReadyEventData, etc.), all built from the same shared Zod schemas as the REST responses, so a videoId in a webhook payload always matches the shape returned by GET /v1/videos/{id}.

7.13.3 Signing #

  • HMAC-SHA256 over {timestamp}.{rawJsonBody} using the endpoint's own secret (whsec_..., Section 7.11.23) — one secret per endpoint, never shared workspace-wide, so rotating one endpoint's secret cannot affect another.
  • Delivered headers: Reelay-Signature: t=<unix_ts>,v1=<hex_hmac>, Reelay-Event-Id: evt_..., Reelay-Event-Type: video.ready, Content-Type: application/json.
  • Receivers verify using the same algorithm documented for Reelay's own inbound verification in Section 7.10 (recompute over the raw body, constant-time compare, tolerate ±300s clock skew) — the outbound scheme is deliberately identical so integrators building both directions reuse one verification routine.
  • Every delivery attempt is re-signed at send time, including retries. The t= timestamp and therefore the v1= digest in Reelay-Signature are recomputed fresh for each attempt of a given logical event — attempt 1 at +0s and the retry at +30s (Section 7.13.4) carry two different, both-valid signatures for the same Reelay-Event-Id. Receivers MUST dedupe on Reelay-Event-Id (the payload's id), never on signature timestamp freshness: a receiver that rejects a second delivery merely because its timestamp differs from the first would reject every legitimate retry as if it were a replay attack, defeating the at-least-once guarantee in 7.13.4.

7.13.4 Delivery Guarantees & Retry #

  • At-least-once delivery, never at-most-once: a delivery is only marked delivered after the receiver responds 2xx within a 10-second timeout. Anything else (non-2xx, timeout, connection error, DNS failure) is a delivery failure and schedules a retry.
  • Retry schedule: exponential backoff with jitter — attempts at +0s (initial), +30s, +2m, +10m, +1h, +6h, +24h, 7 attempts total over roughly 32 hours, each delay randomized ±20% to avoid thundering-herd retries across many workspaces hitting the same receiver. Per 7.13.3, each of these 7 attempts is independently signed with its own send-time timestamp; only Reelay-Event-Id is stable across them.
  • After the final attempt fails, the delivery is marked failed permanently (no further automatic retry) and is visible with full request/response detail in the delivery log; the workspace admin can manually trigger POST .../redeliver (Section 7.11.23) at any time afterward, which creates a fresh delivery attempt (new id) rather than resuming the exhausted schedule.
  • Delivery log: the webhook_deliveries table schema is owned by Section 5.4.x (public ID prefix whd_; the status enum referenced throughout this subsection is pending|delivered| failed; response bodies are truncated to 4 KB before storage). Retained 30 days on all plans that have API access (Business), independent of the general retention policy in Section 19, since delivery history is an operational/debugging record rather than product data.
  • Auto-disable: an endpoint that fails its last retry attempt on 20 consecutive events is automatically set enabled: false, and the workspace admin who registered it receives an email notification with a link to the delivery log. A disabled endpoint receives no further delivery attempts (new events are recorded in the log as status: skipped_disabled for visibility, but no HTTP call is made) until an admin re-enables it via PATCH .../webhook-endpoints/{id} with { enabled: true }, at which point the consecutive-failure counter resets to zero.

7.14 OpenAPI Generation & Docs Surface #

  • The OpenAPI 3.1 document is generated at build time, not hand-maintained, via zod-to-openapi (or the then-current equivalent for the Zod major line in Section 3) driven directly from the request/response Zod schemas in packages/shared. Every Fastify route handler in apps/api registers its schema through the same shared Zod objects used for runtime validation, so the generated document and the runtime validator can never drift — there is exactly one source of truth per endpoint shape.
  • Generation is a pnpm turbo run openapi:generate task producing packages/shared/generated/openapi.json, run in CI on every merge to the default branch and diffed against the previous committed version; an unreviewed breaking change to the generated document (a required field removed, a type narrowed, a path removed) fails the build via the oasdiff-style breaking-change check, forcing an explicit version/deprecation decision per Section 7.1 rather than an accidental break.
  • Docs surface: https://developers.reelay.app is a static site (built from the same monorepo, its own apps/docs Next.js app or equivalent, out of scope for endpoint enumeration here) that renders the generated OpenAPI document with an interactive "try it" console scoped to the viewer's own API keys when they are logged in, plus the outbound webhook event catalogue (7.13.1), the SDK references (7.12), and versioned changelog entries tied to each deprecation (7.1). The docs site is generated from the same OpenAPI artifact CI validates, so documentation and implementation cannot silently diverge.

8. Capture — Browser & Desktop Recording #

8.1 Capture modes #

Reelay exposes four capture modes from a single recorder UI (web dashboard and desktop app share the same mode selector component):

Mode Video tracks Audio tracks Typical use
Screen only 1 (display/window/tab) 0–2 (mic, system) Product walkthroughs
Screen + camera bubble 2 (display + camera, separate tracks — see 8.7) 0–2 (mic, system) Narrated demos with presenter face
Camera only 1 (camera) 0–1 (mic) Talking-head updates
Audio only 0 1 (mic) Voice memos, async standups

Each mode is a distinct value of recordings.capture_mode: screen, screen_camera, camera, audio — exactly these four values, these spellings, matching the CHECK constraint Section 5 defines for that column. The value is chosen before the arming state (8.10) and is immutable for the lifetime of that recording — switching modes starts a new recording.

Display/window/tab selection. In the browser, source selection is delegated entirely to the native picker surfaced by getDisplayMedia() (8.2); Reelay cannot pre-filter or skip it. In the desktop app, Reelay renders its own source-picker grid backed by desktopCapturer.getSources() (8.5), showing live thumbnails for each screen and window, refreshed every 2 seconds while the picker is open.

Multi-monitor handling. Browser: getDisplayMedia() on a multi-monitor system still returns exactly one MediaStreamTrack for whichever single screen/window/tab the user picked in the OS/browser picker — Reelay cannot request two displays at once and cannot know which physical display was chosen beyond the displaySurface constraint hint. Desktop: desktopCapturer.getSources({ types: ['screen'] }) enumerates every physical display individually with a stable display_id; the source picker lists each one, and Reelay records display topology (position, resolution, scaleFactor) into the cursor_telemetry_blobs header (8.6) so multi-display cursor coordinates resolve to the correct physical display at render time.

Region capture. Desktop-only (the browser has no API surface for post-hoc crop-to-region before encode). The user selects a full display or window first, then drags a crop rectangle in a selection overlay (an always-on-top transparent BrowserWindow); the rectangle (in physical pixels, display-scale-corrected) is stored as recordings.capture_region_rect ({ x, y, width, height }) and applied in two places: (1) the native capture is still full-display for encoder stability, (2) the render worker crops to the stored rect during the mandatory pre-Mux normalization pass (9.3) so the delivered video is the cropped region, not the full display. Region capture is unavailable in screen mode from the browser; the UI hides the region tool and shows: "Region capture requires the Reelay desktop app."

8.2 Browser capture #

8.2.1 getDisplayMedia constraints #

const displayConstraints: DisplayMediaStreamOptions = {
  video: {
    displaySurface: 'monitor', // hint only; browser may still offer window/tab
    width: { ideal: 1920, max: 3840 },
    height: { ideal: 1080, max: 2160 },
    frameRate: { ideal: 30, max: 30 },
  },
  audio: {
    echoCancellation: false,   // system audio must not be echo-cancelled
    noiseSuppression: false,   // and must not be noise-suppressed
    autoGainControl: false,
    sampleRate: 48000,
    sampleSize: 16,
    channelCount: 2,
  },
  // Chrome/Edge only — hints the OS picker to default to "share tab audio" on
  preferCurrentTab: false,
  selfBrowserSurface: 'exclude',
  systemAudio: 'include',
} as DisplayMediaStreamOptions;

const screenStream = await navigator.mediaDevices.getDisplayMedia(displayConstraints);

frameRate.max is capped at 30 deliberately: browsers do not guarantee delivery above 30 fps under getDisplayMedia, and requesting 60 produces inconsistent frame pacing across Chrome/Firefox rather than a reliable 60 fps stream (8.4). Reelay never requests 60 fps from a browser tab.

8.2.2 getUserMedia constraints #

Camera:

const cameraConstraints: MediaStreamConstraints = {
  video: {
    deviceId: selectedCameraId ? { exact: selectedCameraId } : undefined,
    width: { ideal: 1280 },
    height: { ideal: 720 },
    frameRate: { ideal: 30, max: 30 },
    aspectRatio: { ideal: 16 / 9 },
  },
};
const cameraStream = await navigator.mediaDevices.getUserMedia(cameraConstraints);

Microphone:

const micConstraints: MediaStreamConstraints = {
  audio: {
    deviceId: selectedMicId ? { exact: selectedMicId } : undefined,
    echoCancellation: true,
    noiseSuppression: true,
    autoGainControl: true,
    sampleRate: 48000,
    sampleSize: 16,
    channelCount: 1,
  },
};
const micStream = await navigator.mediaDevices.getUserMedia(micConstraints);

Camera and microphone are requested independently from the screen share (two or three separate getUserMedia/getDisplayMedia calls), never combined into one constraint object, so that a denial of one (e.g. camera) does not block the others (8.8, 8.7 — separate tracks).

8.2.3 Permission flows and every denial path #

Trigger API Exception / condition Reelay UI response
User picks a source and confirms getDisplayMedia resolves Proceed to arming
User closes the picker / clicks Cancel getDisplayMedia rejects NotAllowedError "Screen sharing was cancelled." Return to idle, capture mode selector stays open
Camera permission previously denied at browser level getUserMedia({video}) rejects NotAllowedError "Camera access is blocked. Enable it in your browser's site settings and try again," with a deep link to chrome://settings/content/camera equivalent per browser (best-effort URL, falls back to generic instructions)
Mic permission previously denied getUserMedia({audio}) rejects NotAllowedError Same pattern, mic-specific copy; recording can still proceed in screen mode with audio disabled if the user explicitly continues
No camera device present getUserMedia({video}) rejects NotFoundError "No camera found. Plug one in or switch to screen-only mode."
No microphone device present getUserMedia({audio}) rejects NotFoundError "No microphone found. Recording will continue without audio." (non-blocking for screen/camera modes)
Device already claimed by another app (Windows exclusive-mode drivers, some webcams) getUserMedia rejects NotReadableError "Your camera/microphone is being used by another application. Close it and retry."
Requested constraints unsatisfiable (rare, only with exact deviceId on unplugged device) getUserMedia rejects OverconstrainedError Falls back to default device automatically, then retries once before surfacing an error
Page is not a secure context (should never happen in production; guards local dev over plain HTTP) Both APIs throw synchronously SecurityError / TypeError Hard block: "Recording requires HTTPS." — dev-only path, unreachable in production since the app is served over TLS
OS-level permission (macOS Screen Recording TCC) not yet granted to the browser itself getDisplayMedia returns a black/empty stream on macOS instead of throwing — (no exception; detected via frame content check) Reelay samples the first captured frame to a canvas; if it is uniformly black for >1s, shows: "macOS is blocking screen recording for [Browser Name]. Grant permission in System Settings → Privacy & Security → Screen Recording, then relaunch your browser." This is the single most common browser-capture support ticket and is called out explicitly in onboarding
User revokes an in-progress screen share from the OS/browser "stop sharing" bar The shared MediaStreamTrack fires ended Recording auto-transitions to paused then finalising (8.10) — never silently drops the session

Every denial increments a client-side telemetry counter (capture_permission_denied, tagged with API and error name) reported to the analytics pipeline (Section 16) so onboarding friction is measurable.

8.2.4 Client error code mapping #

Capture-time failures are surfaced to the recorder UI as typed client errors, not raw DOMExceptions, so the UI layer never branches on browser-specific exception names directly:

export type CaptureErrorCode =
  | 'display_media_cancelled'
  | 'camera_permission_denied'
  | 'mic_permission_denied'
  | 'camera_not_found'
  | 'mic_not_found'
  | 'device_in_use'
  | 'device_overconstrained'
  | 'insecure_context'
  | 'os_permission_blocked'
  | 'unsupported_browser';

export function mapCaptureError(source: 'display' | 'camera' | 'mic', err: DOMException): CaptureErrorCode {
  switch (err.name) {
    case 'NotAllowedError':
      return source === 'display' ? 'display_media_cancelled'
        : source === 'camera' ? 'camera_permission_denied' : 'mic_permission_denied';
    case 'NotFoundError':
      return source === 'camera' ? 'camera_not_found' : 'mic_not_found';
    case 'NotReadableError':
      return 'device_in_use';
    case 'OverconstrainedError':
      return 'device_overconstrained';
    case 'SecurityError':
    case 'TypeError':
      return 'insecure_context';
    default:
      return 'unsupported_browser';
  }
}

These CaptureErrorCode values are client-local (never sent as an API error envelope, since no server round-trip occurred) but are logged to the analytics pipeline (Section 16) using the same string values, giving product analytics a stable taxonomy independent of browser-specific exception naming, which differs across Chromium and Gecko/WebKit for otherwise-equivalent failures.

8.2.5 MediaRecorder setup #

function buildCompositeStream(
  screen: MediaStream | null,
  camera: MediaStream | null,
  mic: MediaStream | null,
  systemAudio: MediaStream | null,
): { videoTracks: MediaStream[]; audioTracks: MediaStreamTrack[] } {
  // Screen and camera are recorded as SEPARATE MediaRecorder instances producing
  // separate track files (8.7) — never composited into one canvas at capture time.
  const videoTracks = [screen, camera].filter((s): s is MediaStream => s !== null);
  const audioTracks = [mic, systemAudio]
    .filter((s): s is MediaStream => s !== null)
    .flatMap((s) => s.getAudioTracks());
  return { videoTracks, audioTracks };
}

function startRecorder(
  stream: MediaStream,
  mimeType: string,
  onChunk: (blob: Blob, index: number) => void,
  onStop: () => void,
  onError: (err: DOMException) => void,
): MediaRecorder {
  const recorder = new MediaRecorder(stream, {
    mimeType,
    videoBitsPerSecond: 8_000_000, // 8 Mbps target for 1080p30 screen content
    audioBitsPerSecond: 128_000,
  });

  let chunkIndex = 0;
  recorder.ondataavailable = (event: BlobEvent) => {
    if (event.data.size > 0) onChunk(event.data, chunkIndex++);
  };
  recorder.onstop = onStop;
  recorder.onerror = (event) => onError((event as unknown as { error: DOMException }).error);

  recorder.start(RECORDER_TIMESLICE_MS); // 3000 — see rationale below
  return recorder;
}

Screen and camera streams (when both present) are each recorded through their own MediaRecorder instance, producing independent chunk sequences and independent uploaded assets (8.7). Audio tracks are attached to whichever MediaRecorder is designated primary (screen in screen_camera mode) — see 8.8 for why mic and system audio are nonetheless persisted as logically separate tracks inside that container via dual-track WebM.

Timeslice choice: 3000 ms. MediaRecorder.start(timeslice) controls how often ondataavailable fires. Reelay fixes this at 3000 ms for every browser recording:

  • Below ~1000 ms, per-chunk container overhead (WebM cluster headers, codec initialization segments on some browser versions) becomes a measurable fraction of chunk size, and the OPFS/IndexedDB write path (9.1) and S3 multipart part-boundary logic (9.2) both incur fixed per-write and per-part overhead that dominates at very small chunk sizes.
  • Above ~5000 ms, the amount of unrecoverable video buffered only in the MediaRecorder's internal encoder (not yet flushed to ondataavailable) grows, increasing data lost in the rare case of a tab crash or forced process kill between flush events.
  • 3000 ms at the target 8 Mbps video bitrate yields chunks in the 2.5–3.5 MB range — comfortably below the 8 MB S3 multipart part size (9.2), so chunks are locally buffered and grouped several-to-a-part rather than needlessly split, and each part upload has enough data to make the fixed per-request overhead of a presigned PUT negligible.

RECORDER_TIMESLICE_MS = 3000 is a shared constant in packages/shared, used identically by the recorder UI and referenced by the resumable-upload logic in 9.1–9.2.

8.3 The codec and fallback matrix #

8.3.1 Ordered preference list #

Reelay probes container/codec combinations in this exact order and records the first supported entry:

export const CODEC_PREFERENCE_ORDER: readonly string[] = [
  'video/mp4;codecs=avc1.42E01E,mp4a.40.2', // H.264 Baseline + AAC-LC, MP4 container
  'video/webm;codecs=vp9,opus',
  'video/webm;codecs=vp8,opus',
  'video/webm',                              // last resort, browser picks internal codec
] as const;

8.3.2 MediaRecorder.isTypeSupported() probe routine #

export interface CodecProbeResult {
  mimeType: string;
  supported: boolean;
}

export function probeSupportedMimeType(
  preferenceOrder: readonly string[] = CODEC_PREFERENCE_ORDER,
): string {
  if (typeof MediaRecorder === 'undefined') {
    throw new UnsupportedBrowserError('MediaRecorder API is unavailable in this browser.');
  }

  for (const mimeType of preferenceOrder) {
    if (MediaRecorder.isTypeSupported(mimeType)) {
      return mimeType;
    }
  }

  throw new UnsupportedBrowserError(
    'No supported recording codec found. Update your browser or use the Reelay desktop app.',
  );
}

export class UnsupportedBrowserError extends Error {
  readonly code = 'unsupported_browser' as const;
}

The resolved mimeType string is stored verbatim on recordings.recorded_mime_type at recording start, and the ingest probe stage (9.3) cross-checks it against the ffprobe output of the assembled upload — a mismatch (e.g. client claimed video/webm;codecs=vp9,opus but the container is actually VP8) flags the recording for manual inspection rather than silently failing transcode.

8.3.3 Browser support table #

Browser Min. version Container Video codec Audio codec Tab-audio capture System-audio capture Max reliable fps Known quirks
Chrome (desktop) 109 video/mp4 (126+) else video/webm H.264 (126+) / VP9 AAC / Opus Yes (Chrome-tab picker only) Yes, Windows/ChromeOS only, via "Share system audio" checkbox; not available on macOS Chrome 30 On macOS, systemAudio: 'include' is silently ignored — no error, just no audio track; Reelay detects a missing audio track post-hoc and shows a one-time tip to use the desktop app for system audio
Edge (desktop, Chromium) 109 Same as Chrome Same as Chrome Same as Chrome Yes Yes, Windows only 30 Identical behavior to Chrome; slightly later MP4-recording rollout, gated to 126+ same as Chrome
Firefox (desktop) 109 video/webm only VP8/VP9 Opus No native "share tab audio" option in the picker No 30 getDisplayMedia audio is unreliable across versions; Reelay treats Firefox audio-with-screen-share as best-effort and prompts the user to verify with the level meter (8.8) before recording
Safari (desktop, macOS) 17 video/mp4 H.264 AAC No No 30, but frame delivery is bursty under high compositor load Requires the macOS Screen Recording TCC prompt at the OS level in addition to the in-page permission (8.2.3); revoking it requires quitting and relaunching Safari, which Reelay's error copy states explicitly
Chrome / Edge (Android) n/a No No getDisplayMedia is not implemented for screen-share-of-other-apps on Android Chrome/Edge; only camera/mic capture works. Reelay's mobile web UI hides screen and screen_camera modes entirely and offers camera/audio only
Safari (iOS/iPadOS) n/a No No getDisplayMedia is unavailable; camera/audio modes work via getUserMedia. Screen recording on iOS is only ever available through the OS's native screen recording (outside Reelay's control)

Minimum supported browser versions are Chrome 109, Edge 109, Firefox 109, Safari 17 (desktop only). Below these, Reelay hard-fails capture entry with:

"Your browser doesn't support screen recording (minimum: Chrome 109, Edge 109, Firefox 109, or Safari 17). Update your browser, or download the Reelay desktop app for the most reliable recording experience."

This check runs via navigator.userAgentData where available (Chromium) with a navigator.userAgent regex fallback, combined with a hard feature-detect ('getDisplayMedia' in navigator.mediaDevices && 'MediaRecorder' in window) that takes precedence over version parsing — if the feature-detect passes on an unrecognized/未来 browser, capture is allowed; if it fails on a recognized modern browser, the version-specific message above is shown, and if it fails on an unrecognized browser, a generic unsupported-browser message is shown.

8.4 What the browser cannot do #

Each limitation below is the explicit, engineering-grounded justification for the desktop app (8.5) — not a claim to be taken on faith by the reader of this specification.

  1. OS-level system audio. No web API exposes the operating system's mixed audio output. getDisplayMedia's systemAudio option only works, and only sometimes, when the shared surface is a Chrome/Edge browser tab or (Windows only) an entire screen — it is implemented per-browser as a special case, not a general OS loopback API, and macOS Chrome silently drops it. Firefox and Safari never implement it. Capturing audio from an arbitrary non-browser application (a video call, a native app playing a demo) is structurally impossible from within a browser sandbox.

  2. Reliable high frame rate at high resolution. getDisplayMedia frame delivery is throttled by the browser's compositor and by OS power-management policy for backgrounded or occluded surfaces; there is no committed frame-delivery SLA in the spec. Above ~30 fps, dropped-frame rates climb sharply and non-deterministically across browser versions and OS combinations — this is a platform limitation, not a Reelay encoder tuning problem, which is why 8.2.1 deliberately caps requested frame rate at 30.

  3. True cursor metadata over other applications. The only cursor signal available to a web page is DOM pointer events (pointermove, pointerdown, etc.), which fire exclusively while the pointer is over that page's own viewport and the tab has focus. There is no web API that reports cursor position, click type, drag state, or cursor shape/bitmap while the pointer is over a different application's window, over the OS desktop, or while the tab is unfocused — which is most of the time during a screen recording of "the user working in some other app." This makes the zoom-to-cursor engine (Section 10) fundamentally unavailable to browser-only recordings of anything but the recording tab itself.

  4. Recording while the browser is minimized or another app is focused. Browsers suspend or heavily throttle background tabs (requestAnimationFrame throttling, timer coalescing, and on some OS/browser combinations, full compositor suspension of hidden surfaces) as a battery/CPU conservation measure that predates and is orthogonal to getDisplayMedia. A MediaRecorder instance can keep producing chunks while backgrounded, but the underlying captured frames may stop updating or drop to a very low rate — there is no API to request an exemption from this OS/browser-level suspension for arbitrary background tabs.

  5. Multi-display selection with per-display scale metadata. getDisplayMedia() hands control of source selection to a native OS/browser picker outside the page's control; the page cannot enumerate available displays, cannot request two displays simultaneously as separate tracks, and cannot read a display's physical resolution, position, or DPI/scale factor — the returned MediaStreamTrack's getSettings() exposes only the capture's own width/height, not the source display's characteristics. This is a native display-enumeration capability the desktop app's desktopCapturer + main-process screen module provide directly (8.5, 8.6).

  6. Hardware-accelerated local encode without dropped frames. MediaRecorder's encoder is entirely internal to the browser implementation; there is no web API to select a specific hardware encoder (VideoToolbox on macOS, Media Foundation/NVENC on Windows), no API to query or guarantee encode-side frame-drop behavior under system load, and no tuning surface for encoder-level rate control beyond the coarse videoBitsPerSecond hint. At sustained high bitrate and resolution, software-path encoding on a contended CPU measurably drops frames with no callback informing the page which frames were dropped.

8.5 Desktop app (Electron 43) #

8.5.1 Architecture #

apps/desktop/
  src/
    main/            # Node.js main process — full OS access, no DOM
      capture/
        desktop-capturer.ts     # desktopCapturer source enumeration
        macos-audio-tap.ts      # native module binding: Core Audio process tap
        windows-audio-loopback.ts # native module binding: WASAPI loopback
        cursor-hook.ts          # native module binding: global mouse/keyboard hook
        encoder.ts               # VideoToolbox / Media Foundation session management
      ipc/            # typed IPC channel handlers (contextBridge-exposed to renderer)
      updater.ts       # electron-updater wiring
      deep-link.ts      # reelay:// protocol handler
      window-manager.ts
    renderer/          # Chromium-rendered UI — recorder controls, source picker, editor shell
      (React 19 + the same packages/ui component library as apps/web, per Section 3)
    preload/            # contextBridge scripts — the ONLY surface renderer code can call into main
    native/              # Rust/C++ native Node addons (N-API), platform-specific
      macos/               # ScreenCaptureKit + Core Audio + CGEventTap bindings
      windows/             # Desktop Duplication API + WASAPI + SetWindowsHookEx bindings

The renderer process runs with contextIsolation: true, nodeIntegration: false, and a sandbox: true webPreferences configuration; it never has direct Node.js or native-module access. All capture control (startCapture, stopCapture, pauseCapture), telemetry ingestion, and file-system writes are invoked through a narrow, typed contextBridge API defined once in preload/ and consumed identically to a regular async client SDK from the renderer's React code — this keeps the desktop renderer's recorder UI React-component-compatible with the web app's dashboard code where sensible, while all privileged work happens in the main process and native modules.

The bridge surface exposed to the renderer is deliberately small and fully typed on both sides:

// preload/index.ts
import { contextBridge, ipcRenderer } from 'electron';

const reelayCapture = {
  listSources: (): Promise<CaptureSourceDescriptor[]> => ipcRenderer.invoke('capture:list-sources'),
  startCapture: (req: StartCaptureRequest): Promise<StartCaptureResult> =>
    ipcRenderer.invoke('capture:start', req),
  pauseCapture: (recordingId: string): Promise<void> => ipcRenderer.invoke('capture:pause', recordingId),
  resumeCapture: (recordingId: string): Promise<void> => ipcRenderer.invoke('capture:resume', recordingId),
  stopCapture: (recordingId: string): Promise<FinalizeResult> => ipcRenderer.invoke('capture:stop', recordingId),
  onEncoderEvent: (cb: (evt: EncoderEvent) => void): (() => void) => {
    const listener = (_: unknown, evt: EncoderEvent) => cb(evt);
    ipcRenderer.on('capture:encoder-event', listener);
    return () => ipcRenderer.removeListener('capture:encoder-event', listener);
  },
} as const;

contextBridge.exposeInMainWorld('reelayCapture', reelayCapture);
IPC channel Direction Payload Notes
capture:list-sources renderer → main (invoke) none → CaptureSourceDescriptor[] Backed by 8.5.2's listCaptureSources
capture:start renderer → main (invoke) StartCaptureRequestStartCaptureResult Main process opens the native encoder session (8.5.5) and begins the cursor hook (8.6.2) atomically — if either fails to initialize, both are torn down and the invoke rejects
capture:pause / capture:resume renderer → main (invoke) recordingIdvoid Drives the 8.10 state machine's recording ⇄ paused transitions
capture:stop renderer → main (invoke) recordingIdFinalizeResult Triggers 8.10's finalising step; result includes final chunk count and computed duration
capture:encoder-event main → renderer (event) EncoderEvent ({ type: 'dropped-frames' | 'disk-low' | 'display-removed' | 'error', ...details }) Push channel for the 8.9 environmental-handling warnings and 8.5.3's dropped-frame telemetry; the renderer never polls for these

Native modules (native/macos, native/windows) are only ever loaded and called from the main process — the preload/renderer boundary above is the sole path by which UI code can reach them, keeping the sandboxed renderer's attack surface limited to the six channels above rather than arbitrary native-module access.

Native addon supply-chain integrity. The compiled native capture addons in native/macos and native/windows — the Core Audio process tap, the WASAPI loopback binding, and the global cursor hook (8.6.2), the three modules carrying the deepest OS privilege of anything shipped in the desktop app — are checksum-pinned in CI. Every release build computes a SHA-256 of each compiled addon binary and diffs it against the previous release's recorded checksum. An unchanged checksum passes the gate silently. A changed checksum — whether from a legitimate source change, a toolchain/compiler upgrade, or a compromised build step — blocks the release pipeline pending required sign-off from a second engineer, who reviews both the source diff and the binary checksum change before the gate can be manually cleared to proceed. This is a release-time supply-chain gate, not a runtime integrity check: it exists to make any change to the highest-privilege native code in the app visible and blockable before it ships, distinct from and in addition to the post-build tamper detection that code signing and notarization (8.5.7) already provide for the shipped artifact.

8.5.2 desktopCapturer usage #

// main process
import { desktopCapturer, screen } from 'electron';

export async function listCaptureSources(): Promise<CaptureSourceDescriptor[]> {
  const sources = await desktopCapturer.getSources({
    types: ['screen', 'window'],
    thumbnailSize: { width: 320, height: 180 },
    fetchWindowIcons: true,
  });
  const displays = screen.getAllDisplays();

  return sources.map((source) => {
    const display = displays.find((d) => source.display_id === String(d.id));
    return {
      id: source.id,
      name: source.name,
      kind: source.id.startsWith('screen:') ? 'screen' : 'window',
      thumbnailDataUrl: source.thumbnail.toDataURL(),
      displayId: display?.id ?? null,
      displayBounds: display?.bounds ?? null,
      scaleFactor: display?.scaleFactor ?? 1,
    };
  });
}

The renderer receives this descriptor list over IPC and renders the source picker grid (8.1). Once selected, the renderer requests the actual MediaStream via a constrained getUserMedia call using the chosen source.id as chromeMediaSourceId — Electron's desktopCapturer integrates with Chromium's media pipeline this way, so the resulting stream still flows through a (renderer-side) MediaRecorder-equivalent, except Reelay's desktop build routes the raw frames to the native hardware encoder path (8.5.4) instead of Chromium's software MediaRecorder, for frame-rate and quality reasons (8.4 item 6).

8.5.3 macOS path #

  • Screen capture: ScreenCaptureKit (macOS 13+, the minimum supported desktop-app macOS version) via a native Swift/Objective-C++ addon, not Electron's default Chromium capture path — ScreenCaptureKit provides per-display capture, window filtering, and hardware-accelerated frame delivery at up to 60 fps with dropped-frame telemetry the app can act on (auto-lower resolution if drops exceed 2% of frames in a rolling 5 s window).
  • System audio: a Core Audio process tap (AudioHardwareCreateProcessTap, macOS 14.4+) captures system output without a virtual audio driver; on macOS 13–14.3 (still within the supported floor), Reelay falls back to a bundled, code-signed virtual audio driver (installed via a one-time privileged helper during app install) that mixes system output into a capturable input device. Both paths are abstracted behind the same native SystemAudioSource interface so upstream capture code is macOS-version-agnostic. The privileged helper's install step is hardened against a staging-to-install substitution attack: the driver bundle is downloaded and extracted to a temporary, user-writable staging location, but immediately before the helper invokes the OS driver-install API, it re-verifies the bundle's code signature by reading it back from — and only from — a root-writable-only location that the helper itself copies the bundle into, under elevated privilege, right before that final check. This closes the classic TOCTOU (time-of-check-to-time-of-use) window in which a bundle could pass an earlier signature check while it still sits in a user-writable directory, then be swapped for a malicious payload before the privileged install call actually consumes it; because the final re-verification only ever reads from a path a non-privileged process cannot write to, that substitution race cannot be won.
  • Permission prompts: macOS requires two separate TCC (Transparency, Consent, and Control) grants — Screen Recording and Microphone — each a one-time OS-level dialog the app cannot pre-answer or suppress. Reelay requests them lazily, at first use of each capability, with an in-app priming screen shown immediately before the OS dialog explaining why the permission is needed (this materially improves grant rates versus a cold OS prompt).
  • Denied prompt handling: CGPreflightScreenCaptureAccess() / CGRequestScreenCaptureAccess() (screen) and the standard AVCaptureDevice.requestAccess(for: .audio) (microphone) report denial without re-prompting — macOS never shows the system dialog a second time once denied. Reelay detects the denied state via these preflight checks on every app launch and every "Start Recording" attempt, and if denied, shows a persistent banner: "Screen Recording permission is off for Reelay. Open System Settings → Privacy & Security → Screen Recording, enable Reelay, then relaunch the app," with a button that opens the exact System Settings pane via x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture.

8.5.4 Windows path #

  • System audio: WASAPI loopback capture on the default render (output) endpoint, via IAudioClient::Initialize with AUDCLNT_STREAMFLAGS_LOOPBACK, in a native N-API addon. This captures the full system mix with no per-app driver installation required.
  • Screen capture: Desktop Duplication API (IDXGIOutputDuplication) for GPU-accelerated frame capture at up to 60 fps, including cursor image data as a bonus (used as a cross-check against the independent cursor hook in 8.6, not a replacement for it, since Desktop Duplication's cursor data does not cover clicks-over-other-windows semantics). Desktop Duplication does not by default capture DRM-protected window content (video players in protected mode, some DRM'd PDF viewers) — those surfaces render as black in the captured frame, which is an OS-level protection Reelay cannot and does not attempt to bypass; the app detects a sustained black region under an otherwise-active window and surfaces a one-time explanatory tooltip rather than silently producing a black recording.
  • Permission model: Windows has no TCC-equivalent modal gate for screen or system-audio capture at the OS level (unlike macOS); the operating boundary is Windows' own UAC for the one-time driver-less installer steps. Camera/microphone use the standard Windows privacy settings (Settings → Privacy & Security → Camera/Microphone), and Reelay performs the same preflight-detect-and-guide pattern as macOS when getUserMedia-equivalent native calls fail with an access-denied error.

8.5.5 Hardware encode #

Platform API Encoder Codec Notes
macOS VideoToolbox Apple hardware H.264/HEVC encoder (Media Engine on Apple Silicon, QuickSync-equivalent on Intel Macs) H.264 (default), HEVC (opt-in, smaller files, used for 4K local encode) VTCompressionSessionCreate configured with kVTCompressionPropertyKey_RealTime = true and a target bitrate ladder scaled to capture resolution
Windows Media Foundation Vendor hardware encoder via MFT (Intel Quick Sync, NVENC, AMD VCE — auto-selected by Media Foundation's transform enumeration, software x264 MFT as final fallback) H.264 (default), HEVC where the hardware MFT supports it IMFSinkWriter with a hardware-preferred transform category; Reelay explicitly avoids forcing a vendor-specific encoder to keep the code portable across GPU vendors

Both paths target 60 fps capture at up to the source display's native resolution, with a bitrate ladder driven off resolution × frame rate (e.g. 1080p60 ≈ 12 Mbps, 4K60 ≈ 45 Mbps H.264 / ≈ 28 Mbps HEVC) chosen to keep local files reasonably sized while remaining visually lossless for screen content (sharp text and UI edges, Reelay's dominant content type). The desktop app does not bundle a full FFmpeg distribution; the locally hardware-encoded output is written directly into an MP4/fragmented-MP4 container by a lightweight native muxer, keeping the FFmpeg 7.x dependency (Section 3) server-side only, where it does the heavier lifting for the transcode ladder, EDL renders, redaction burn-in, and exports (9.3, 9.8).

8.5.6 Auto-update #

electron-updater, backed by a static update manifest (latest-mac.yml, latest.yml) published to a private prefix in the delivery-side object storage (9.6) behind the CDN. Update checks run on app launch and every 4 hours while running. Updates download in the background and install on next app quit (never force-quitting an active recording); a staged rollout percentage field in the manifest lets Reelay ship to e.g. 10% of the install base first, monitored via crash-reporting rate before widening to 100%. macOS updates use Squirrel.Mac semantics (delta updates where possible, full package fallback) and Windows updates use the NSIS-based Squirrel.Windows-compatible updater bundled in electron-updater — both are driven by the same manifest format and the same application code path.

Client-side update signature verification. Beyond the OS-level code-signing checks any signed executable already carries (8.5.7), the updater independently re-verifies the downloaded update before applying it, on both platforms: on Windows, electron-updater is configured with verifyUpdateCodeSignature: true, which extracts and validates the downloaded installer's Authenticode signature against the certificate configured for the app before allowing install, rather than trusting the manifest's declared checksum alone; on macOS, Squirrel.Mac verifies that the downloaded update's code signature matches the same Developer ID Application certificate (8.5.7) as the currently-running app before applying it — a payload signed by a different identity, even one that is validly signed by someone, is rejected outright. Both checks are part of electron-updater's standard install flow and are never bypassed for staged-rollout or delta updates.

Manifest signing, with custody kept outside the CI publishing credential. The latest-mac.yml/latest.yml update manifest is itself signed, using a dedicated manifest-signing key that is generated, stored, and used entirely separately from the CI publishing credential — the credential CI uses to upload release artifacts to the update storage prefix. The manifest-signing key lives in the deployment secrets vault, accessible only to the release-signing step, which runs under a more narrowly scoped identity than general CI jobs and is never granted the publish credential's own permissions. electron-updater's client verifies this manifest signature before trusting any field inside it — version, download URL, staged-rollout percentage, checksum. The point of the separation: a compromised CI publishing token is, on its own, insufficient to ship a malicious update. That token can overwrite files at the update storage prefix, but it cannot produce a validly-signed manifest pointing at them, because the publish pipeline itself never has access to the manifest-signing key. An attacker would need to separately compromise the secrets vault holding that key — a materially higher bar than stealing a CI token — closing the single-point-of-compromise gap where CI credential theft alone would otherwise be enough to push a malicious update to the entire install base.

8.5.7 Code signing and notarization #

Platform Certificate Process
macOS Apple Developer ID Application certificate Build → sign with hardened runtime and the entitlements list (camera, microphone, screen-recording capability declarations, and the process-tap/audio-driver entitlements for 8.5.3) → submit to Apple via notarytool → staple the notarization ticket to the .app/.dmg → verify with spctl --assess in CI before publishing
Windows EV (or OV, accepting a slower SmartScreen-reputation ramp-up) code-signing certificate via a cloud HSM signing service (e.g. Azure Trusted Signing) Build → signtool sign the installer and the main executable → publish; SmartScreen reputation accrues over the first weeks of an EV-signed binary's distribution, tracked as a launch metric, not a blocking release gate

CI fails the release pipeline if either platform's signing/notarization step does not complete successfully — an unsigned or unnotarized build is never published to the update manifest.

8.5.8 App size budget #

Target installed footprint: ≤ 180 MB per platform, installer download ≤ 130 MB. Budget breakdown (approximate): Electron/Chromium runtime ≈ 90–100 MB, renderer UI bundle (React 19 + packages/ui) ≈ 5–8 MB, native capture/audio/cursor addons ≈ 10–15 MB combined across platforms, app icons/resources ≈ 5 MB, headroom ≈ 40–50 MB. This budget explicitly excludes FFmpeg (not bundled, per 8.5.5) — if a future requirement forces bundling a local FFmpeg binary, the budget must be revisited, since a full FFmpeg static build alone is commonly 40–80 MB. CI enforces the installer-size budget as a release gate, same posture as the player budget in Section 15.

The web dashboard's "Record with desktop app" action:

  1. Web app calls POST /v1/desktop-launch-tokens, receiving a one-time, 60-second-TTL launch token ({ token, expiresAt }).
  2. Web app navigates to reelay://record?token=<token>&mode=<captureMode> (a custom protocol the desktop installer registers with the OS on install).
  3. If the desktop app is installed, the OS launches or focuses it and delivers the URL via Electron's app.on('open-url', ...) (macOS) / second-instance argv parsing (Windows, via app.requestSingleInstanceLock()).
  4. The desktop app's main process extracts token, calls POST /v1/desktop-launch-tokens/:token/exchange to trade it for a device-scoped session (same session mechanism as 8.5's IPC-protected renderer, per the auth model in Section 6), and pre-selects the requested capture mode in the recorder UI.
  5. If the desktop app is not installed, the OS reports no handler for reelay://, and the web app — which raced a 1.5 s timeout against the navigation — falls back to showing a "Download the desktop app" screen with the platform auto-detected from navigator.userAgentData/userAgent.

Launch tokens are single-use (server marks them consumed on exchange, a second exchange attempt returns error.code = "launch_token_already_used" per the error envelope in Section 7.6) and are never logged in plaintext in application logs.

8.6 Cursor telemetry capture #

Cursor telemetry is the raw signal Section 10's zoom-to-cursor and auto-framing engine consumes. This subsection is the binding contract between capture (8) and that engine — Section 10 relies on exactly this shape, this sample rate, and these units, with no re-derivation.

8.6.1 Logical record format #

Every sample, regardless of transport encoding, logically carries:

export interface CursorSample {
  tOffsetMs: number;        // ms since recording start, monotonic, uint32 range
  x: number;                 // px, in SOURCE capture coordinate space (not display-scaled)
  y: number;
  displayId: number;         // index into this recording's display table (0-based)
  eventType: CursorEventType;
  button: PointerButton;     // 'none' | 'left' | 'right' | 'middle'
  modifiers: ModifierFlags;  // bitmask: shift=1, ctrl=2, alt=4, meta=8
  scrollDeltaX: number;      // px, 0 unless eventType === 'scroll'
  scrollDeltaY: number;
  activeWindowRect: Rect | null; // resolved at decode time from the window-change channel, not stored per-sample (8.6.3)
  cursorShapeId: number;     // enum: 0=default,1=pointer,2=text,3=grab,4=grabbing,5=resize,6=crosshair,7=not-allowed,8=wait,9=custom
}

export type CursorEventType =
  | 'move' | 'down' | 'up' | 'drag' | 'scroll' | 'key' | 'focus_change' | 'shape_change';

export interface Rect { x: number; y: number; width: number; height: number; }

Typing detection (for Section 10's "typing burst" interest events) does not capture keystroke content — only eventType: 'key' presence/timing at up to one synthetic sample per detected keydown, with no character or key-code payload. This is a deliberate privacy boundary: cursor telemetry never records what was typed, only that typing was occurring and when, which is sufficient for a zoom-timeline decision engine and avoids storing keystroke content entirely.

8.6.2 Sampling rate #

  • Desktop: 120 Hz, driven by the native cursor hook (macOS: CGEventTap on the global event stream; Windows: a low-level SetWindowsHookEx(WH_MOUSE_LL) / raw input combination), sampled on a dedicated native thread independent of the renderer's event loop so UI jank never throttles telemetry.
  • Browser fallback: 60 Hz, driven by pointermove/pointerdown/pointerup/wheel DOM events over the captured page surface only, rate-limited to 60 Hz via requestAnimationFrame-gated sampling. This is explicitly degraded: per 8.4 item 3, the browser can only see the pointer while it is over the recording tab's own document and the tab is focused — cursor movement over any other application, another browser tab, or the OS chrome produces zero samples. The gap is not interpolated or guessed; Section 10 treats sample gaps longer than 500 ms as "no telemetry available for this span" and falls back to its no-telemetry default framing behavior (Section 10 owns that fallback behavior; this section only guarantees the gap is truthfully represented rather than fabricated).

recordings.telemetry_source stores 'native_120hz' or 'browser_60hz_degraded' so downstream consumers (Section 10, analytics) can condition on fidelity without re-deriving it from context.

Native sampling threads write into a lock-free ring buffer drained every 250 ms by the main process, which appends drained samples to the in-memory session buffer described in 8.6.3 — this bounds worst-case sample loss on an ungraceful process termination to roughly 250 ms of telemetry, well inside the ≥500 ms gap threshold Section 10 already treats as "no telemetry available."

8.6.3 Serialization format and compression #

Telemetry is written as a single binary blob per recording with three sections:

┌─────────────────────────────────────────────────────────┐
│ Header (fixed layout, versioned)                         │
│   uint8   formatVersion            (= 1)                 │
│   uint8   sampleRateHz             (60 or 120)            │
│   uint8   displayCount                                    │
│   uint8   reserved                                        │
│   uint32  sampleCount                                     │
│   uint32  windowEventCount                                 │
│   Display[displayCount]  (id, x, y, width, height,        │
│                            scaleFactor — 20 bytes each)   │
├─────────────────────────────────────────────────────────┤
│ Window-change channel (sparse, one entry per focus change)│
│   WindowEvent[windowEventCount]:                           │
│     uint32 tOffsetMs, int16 x, int16 y,                   │
│     uint16 width, uint16 height   (12 bytes each)          │
├─────────────────────────────────────────────────────────┤
│ Sample stream (dense, fixed 20 bytes/sample)               │
│   uint32 tOffsetMs                                          │
│   int16  x                                                   │
│   int16  y                                                   │
│   uint8  displayId                                            │
│   uint8  eventType                                              │
│   uint8  button                                                  │
│   uint8  modifiers                                                 │
│   int16  scrollDeltaX                                               │
│   int16  scrollDeltaY                                                 │
│   uint8  cursorShapeId                                                  │
│   uint8  reserved                                                        │
└─────────────────────────────────────────────────────────┘

activeWindowRect is deliberately not stored per-sample — it changes orders of magnitude less often than cursor position, so it is written once per focus change into the sparse window-change channel, and resolved back onto each CursorSample at decode time by taking the last window-change entry with tOffsetMs ≤ the sample's tOffsetMs. This keeps the dense sample stream at a fixed, cache-friendly 20 bytes/sample while still satisfying the logical contract in 8.6.1.

The raw buffer is compressed with gzip (via the browser's CompressionStream('gzip') or Node's zlib.gzip on desktop) before it ever touches disk or network. The compressed blob is the unit stored as a cursor_telemetry_blobs row's payload (schema owned by Section 5); this section defines its content, not its table columns.

Decode (used identically by Section 10's render/preview pipeline and by any diagnostic tooling) reconstructs the logical CursorSample[] from the physical layout:

export function decodeCursorTelemetry(gunzipped: ArrayBuffer): CursorSample[] {
  const view = new DataView(gunzipped);
  let offset = 0;

  const formatVersion = view.getUint8(offset); offset += 1;
  const sampleRateHz = view.getUint8(offset); offset += 1;
  const displayCount = view.getUint8(offset); offset += 1;
  offset += 1; // reserved
  const sampleCount = view.getUint32(offset, true); offset += 4;
  const windowEventCount = view.getUint32(offset, true); offset += 4;
  if (formatVersion !== 1) throw new Error(`Unsupported telemetry format version ${formatVersion}`);

  offset += displayCount * 20; // display table not needed for sample decode itself

  const windowEvents: Array<{ tOffsetMs: number; rect: Rect }> = [];
  for (let i = 0; i < windowEventCount; i++) {
    windowEvents.push({
      tOffsetMs: view.getUint32(offset, true),
      rect: {
        x: view.getInt16(offset + 4, true),
        y: view.getInt16(offset + 6, true),
        width: view.getUint16(offset + 8, true),
        height: view.getUint16(offset + 10, true),
      },
    });
    offset += 12;
  }

  const samples: CursorSample[] = [];
  let windowCursor = 0;
  for (let i = 0; i < sampleCount; i++) {
    const tOffsetMs = view.getUint32(offset, true);
    while (windowCursor + 1 < windowEvents.length && windowEvents[windowCursor + 1].tOffsetMs <= tOffsetMs) {
      windowCursor++;
    }
    samples.push({
      tOffsetMs,
      x: view.getInt16(offset + 4, true),
      y: view.getInt16(offset + 6, true),
      displayId: view.getUint8(offset + 8),
      eventType: EVENT_TYPE_LOOKUP[view.getUint8(offset + 9)],
      button: BUTTON_LOOKUP[view.getUint8(offset + 10)],
      modifiers: view.getUint8(offset + 11),
      scrollDeltaX: view.getInt16(offset + 12, true),
      scrollDeltaY: view.getInt16(offset + 14, true),
      activeWindowRect: windowEvents[windowCursor]?.rect ?? null,
      cursorShapeId: view.getUint8(offset + 16),
    });
    offset += 20;
  }
  return samples;
}

8.6.4 Size estimates #

Source Raw rate Uncompressed size Gzip-compressed (typical, ~75–80% reduction on motion data)
Desktop, 120 Hz 7,200 samples/min × 20 B ≈ 140 KB/min ≈ 28–35 KB/min
Browser, 60 Hz 3,600 samples/min × 20 B ≈ 70 KB/min ≈ 14–18 KB/min

At the 4-hour soft cap for Pro/Business (Section 21's plan table), desktop telemetry for a full session is ≈ 33.6 MB raw / ≈ 7–8.4 MB compressed — comfortably under the 8 MB single-part threshold used elsewhere in the pipeline (9.2), so telemetry upload uses a single presigned PUT in the common case, escalating to the same multipart mechanism as video chunks only if the compressed blob exceeds 8 MB.

8.6.5 Upload and association #

Telemetry is buffered locally throughout the recording (same local-first posture as video, 9.1) and uploaded once at finalising (8.10) — not streamed continuously — because it is small relative to video and its usefulness to Section 10 requires the complete session, not partial windows. The finalize step:

  1. Serializes and gzip-compresses the buffer (8.6.3).
  2. Requests a presigned PUT URL scoped to POST /v1/recordings/:id/telemetry-upload-url.
  3. Uploads the blob; on success, calls POST /v1/recordings/:id/telemetry-complete with the resulting object key and a SHA-256 checksum of the compressed payload.
  4. The server verifies the checksum via HeadObject (comparing the object's ETag for single-part PUTs, which S3-compatible storage guarantees equals the MD5 for non-multipart uploads) and creates the cursor_telemetry_blobs row, foreign-keyed to the recording's video id (8.10), only after verification succeeds.

If telemetry upload fails after the video itself has finished uploading and processing, the video is not blocked from becoming ready — a recording with a missing or corrupt telemetry blob simply has no zoom-to-cursor auto-edit available (Section 10 degrades to its no-telemetry preset) rather than failing the entire recording. This mirrors the plan-cap invariant in Section 21: a subsystem failure never blocks playback of otherwise-successful content.

8.7 Camera bubble #

Property Values
Shape circle, rounded_square (24px corner radius at 100% scale), square
Size presets small (15% of frame width), medium (20%), large (28%), plus free-drag resize between 10%–35%
Position Four corner presets (top_left, top_right, bottom_left, bottom_right) with 24px inset from frame edges at 100% scale, or a free-drag custom {x, y} position stored as a fraction of frame dimensions (resolution-independent)
Default medium, rounded_square, bottom_right

Background removal posture. At capture time, an on-device segmentation model (a lightweight, WASM/WebGL selfie-segmentation model bundled with the recorder UI, run as a MediaStreamTrackProcessor/canvas pipeline on the camera track) provides a live preview-only background blur/replace so the presenter can see and adjust framing while recording — this is a UX aid, not the final quality bar. The camera track that is actually recorded and uploaded is the unmodified, un-segmented raw camera feed; background removal for the delivered video is re-applied server-side by the render worker (9.3) using a higher-quality, non-realtime-constrained segmentation pass, and is stored as a render-time compositing instruction on the EditDecisionList (Section 11 owns the EDL schema) rather than burned into the uploaded source. This keeps the camera-background choice non-destructive and re-editable after the fact, consistent with the immutable-original principle (Section 11).

Separate-track decision. When capture mode is screen_camera, the screen and camera are recorded as two independent media assets with two independent MediaRecorder (browser) or hardware-encoder (desktop) pipelines, each producing its own chunk sequence, its own upload session (9.2), and its own media_assets row (Section 5 owns the schema) — never composited into a single frame at capture time. This is a deliberate, permanent decision: compositing at capture time would make the camera bubble's size, shape, and position permanently baked into the pixels, making it impossible for the timeline editor (Section 11) to later resize, reposition, hide, or re-time the bubble relative to the screen recording. The render worker performs the actual compositing at render time, driven by the EDL's camera-bubble instructions, so every camera-bubble property in the table above remains editable after recording ends, for the lifetime of the video.

8.8 Audio #

Device selection. navigator.mediaDevices.enumerateDevices() populates a device picker for microphone input; device labels are only populated after at least one getUserMedia permission grant (a browser privacy constraint, not a Reelay limitation) — before first grant, Reelay shows generic "Microphone 1", "Microphone 2" labels and re-enumerates immediately after permission is granted to show real labels for subsequent sessions. Desktop uses the OS's native audio device enumeration (navigator.mediaDevices.enumerateDevices() still works inside the Electron renderer for standard input devices; system audio is not a "device" the user picks, it is always-on when system audio is enabled for the recording, per 8.5.3/8.5.4).

Input level metering. A Web Audio API AnalyserNode (FFT size 256) attached to the microphone track drives a real-time level meter in the recorder UI, sampled via getByteFrequencyData at the animation-frame rate (~60 Hz), converted to a 0–100 UI scale via 20 * log10(rms) normalized against a -60 dBFS floor and 0 dBFS ceiling. This is a UX aid only — it does not affect the recorded audio.

Noise suppression and echo cancellation constraints. Browser: echoCancellation: true, noiseSuppression: true, autoGainControl: true on the microphone getUserMedia call (8.2.2) — these are implementation-defined by the browser vendor with no tuning surface; Reelay documents them as best-effort, not a guaranteed quality bar, in user-facing help content. Desktop: the same three constraints are requested from the OS audio stack where available, supplemented by a bundled RNNoise-based WASM noise-suppression pass applied to the preview audio path only (again UX aid, not applied to the recorded/uploaded track) — final noise suppression, when the user opts in, is a Section 12-owned post-processing step applied server-side to preserve non-destructive editing.

Microphone and system audio as separate tracks. Both are recorded as independent audio tracks, never pre-mixed at capture time, mirroring the camera-bubble separate-track decision (8.7) for the same reason: independent volume control, independent mute/unmute, and independent noise treatment during editing (Section 11) require the tracks to remain distinct all the way through capture and upload. The container (WebM on browser, MP4 on desktop) carries both as separate audio streams; the render worker mixes them at render time per the EDL's audio-mix instructions, and the original multi-track source is retained.

Sample-rate normalization. All captured audio is requested at 48 kHz (8.2.2, 8.2.1) regardless of source device native rate — browsers resample transparently to the requested rate before delivering the MediaStreamTrack; on desktop, the native audio capture path explicitly configures the WASAPI/Core Audio stream format to 48 kHz, 16-bit. This fixed rate is what the ingest probe (9.3) validates against and what the transcode ladder (9.4) assumes as input, avoiding per-recording sample-rate branching anywhere downstream.

8.9 Countdown, pause/resume, duration limits, and environmental handling #

Countdown. Default 3 seconds, user-configurable to 0, 3, 5, or 10 seconds in recorder settings (persisted per-user, not per-recording). During countdown the capture streams are already acquired and live-previewed, but no MediaRecorder/encoder session has started — this avoids capturing the countdown UI itself and avoids wasting encoder-warm-up time inside the recorded footage.

Pause/resume semantics. Pausing calls MediaRecorder.pause() (browser) or signals the native encoder session to stop accepting frames while keeping the session open (desktop); both stop consuming wall-clock recording time. On resume, capture continues into the same chunk sequence and the same media asset — a pause is not a new recording. The gap is recorded as an explicit PauseSegment { startMs, endMs } entry in the recording's metadata (surfaced to Section 11's EDL as a pre-applied cut, so the default rendered timeline skips the paused span without the user ever seeing a jump-cut, while remaining individually revertible per the non-destructive editing invariant owned by Sections 11/12). Cursor telemetry sampling (8.6) also pauses and resumes in lockstep, so tOffsetMs gaps align with PauseSegment ranges rather than appearing as unexplained telemetry dropout.

Max-duration enforcement. The server is the source of truth: the upload session (9.2) tracks cumulative recorded duration against the workspace's plan limit (Section 21's plan table: 5 minutes for Free, unlimited with a 4-hour soft cap for Pro/Business) via the duration reported in each finalize call, and rejects further chunk uploads for a session that has exceeded its cap with error.code = "recording_duration_limit_exceeded" (Section 7.6 error envelope). The client independently tracks elapsed recording time from its own clock and shows a warning before hitting the limit — this is UX only, per the server-side-enforcement rule in Section 21, and the two can disagree by small amounts (clock drift, buffering) without being a bug; the server's figure, derived from actual uploaded/probed media duration, is authoritative for billing and cap enforcement.

5-minute free-tier cap behavior. Client shows a non-blocking warning banner at 4:30 (90% — matching the general 80%/100% warning cadence in Section 21) and automatically stops, finalizes, and uploads at exactly 5:00 — the user is never abruptly cut off mid-word without the tool telling them first, and the resulting recording is fully usable up to the cap (not discarded).

Low-disk handling (desktop). Free disk space on the capture-write volume is polled every 10 seconds during recording. Below 2 GB free, a non-blocking warning toast appears ("Low disk space — recording may stop soon"). Below 500 MB free, the app auto-pauses the recording (same pause path as manual pause, 8.9 above) and shows a blocking dialog requiring the user to free space or end the recording; recording is never allowed to continue writing into a full disk, which would corrupt the in-progress chunk file.

Low-battery handling (desktop, laptops). At 20% battery and not connected to power (navigator.getBattery() or the native power-status API), a non-blocking warning toast recommends connecting to power; recording is not auto-stopped on low battery alone, since interrupting a recording is a worse outcome than a possible mid-recording shutdown the user has been warned about.

Display-disconnect handling. Browser: the shared MediaStreamTrack's ended event fires automatically when the captured display/window disappears (unplugged monitor, closed window/tab) — Reelay listens for this and transitions the session to pausedfinalising (8.10), never leaving a dangling stream. Desktop: a native display-removed event (Electron's screen.on('display-removed', ...)) during an active capture of that specific display triggers the same pause-and-prompt flow, with an explicit dialog offering to reselect a source and resume into the same recording (new chunks appended to the same sequence) or finalize what was captured so far.

8.10 Recording session state machine #

                    ┌──────┐
                    │ idle │
                    └──┬───┘
                       │ user clicks "Record" / selects mode
                       ▼
                  ┌─────────┐
        ┌────────►│ arming  │  (permission requests, source selection, device checks)
        │         └────┬────┘
        │              │ all streams acquired
        │              ▼
        │         ┌───────────┐
        │         │ countdown │  (0–10s per 8.9; skippable)
        │         └─────┬─────┘
        │               │ countdown elapsed / skipped
        │               ▼
        │         ┌───────────┐
        │  resume │ recording │◄──────────────┐
        │  ┌─────►└─────┬─────┘               │
        │  │             │ user pauses /       │ user resumes
        │  │             │ display disconnect   │
        │  │             ▼                      │
        │  │       ┌────────┐                   │
        │  │       │ paused ├───────────────────┘
        │  │       └───┬────┘
        │  │           │ user stops / max-duration hit / display permanently gone
        │  └───────────┤
        │               ▼
        │         ┌─────────────┐
        │         │ finalising  │  (flush last chunk, serialize telemetry, compute duration)
        │         └──────┬──────┘
        │                │ local finalize complete
        │                ▼
        │         ┌────────────┐
        │         │ uploading  │  (multipart upload in progress, 9.2)
        │         └──────┬─────┘
        │                │ CompleteMultipartUpload confirmed by server
        │                ▼
        │         ┌───────────┐
        │         │ uploaded  │
        │         └─────┬─────┘
        │               │ enqueued to video.ingest.probe (9.3)
        │               ▼
        │         ┌────────────┐
        │         │ processing │  (9.3 pipeline: probe → transcode → render → renditions)
        │         └──────┬─────┘
        │                │ all required renditions + poster ready
        │                ▼
        │            ┌───────┐
        │            │ ready │
        │            └───────┘
        │
        │  (from arming, countdown, recording, uploading, or processing — any
        │   unrecoverable error transitions here)
        └────────────────────────────────────────────┐
                                                        ▼
                                                  ┌────────┐
                                                  │ failed │
                                                  └────────┘
From Trigger To Notes
idle User selects capture mode and clicks "Record" arming
arming All required media streams acquired successfully countdown
arming Required permission denied and user does not retry within 60s idle Not failed — no recording ever started, nothing to clean up
countdown Countdown timer elapses, or user clicks "Skip" recording Encoder/MediaRecorder session starts exactly here
recording User clicks "Pause" paused 8.9 pause semantics
recording Captured display/window/tab disconnects paused 8.9 display-disconnect handling
recording Elapsed duration reaches plan cap finalising Server-enforced, client-warned (8.9)
recording User clicks "Stop" finalising Normal end path
recording Unrecoverable encoder/capture error failed e.g. native encoder session crash; local chunks up to the last successful flush are still queued for upload as a partial recording, offered for recovery on next app/tab launch
paused User clicks "Resume" recording
paused User clicks "Stop", or pause exceeds a 30-minute idle ceiling finalising The 30-minute auto-finalize prevents an indefinitely "paused" session from holding local resources forever
finalising Last chunk flushed, telemetry serialized, duration computed uploading
finalising Local write failure (disk full mid-finalize, OPFS quota error) failed Whatever chunks were already durably written are still offered for upload as a partial recording
uploading Server confirms multipart assembly for video and telemetry uploaded Per 9.2's invariant, local chunks are deleted only now
uploading Retry budget exhausted (9.2's 8-attempts-per-part cap hit repeatedly across a resumed session) failed Local chunks are retained, not deleted, on this path — the recording remains resumable by the user re-opening the app
uploaded Job enqueued to video.ingest.probe processing
processing All required renditions and poster/thumbnail generated ready
processing Any pipeline stage exhausts its retry budget (9.9) failed Failure taxonomy and user messaging in 9.9
failed User explicitly retries (re-upload for upload failures, or "Reprocess" for processing failures) uploading or processing Retry re-enters at the failed stage, not from idle

recordings.status (schema owned by Section 5) stores the current state as one of idle | arming | countdown | recording | paused | finalising | uploading | uploaded | processing | ready | failed; idle is a client-only concept and is never actually persisted (a recordings row is not created until arming begins, at which point the video id is generated client-side per Section 4's UUIDv7 application-side generation convention).

9. Media Pipeline — Upload, Transcode, Storage & Delivery #

9.1 Local-first capture #

Every recording is written to durable local storage before any network upload begins, and continues to be written locally throughout the recording regardless of network state. This is what makes the "a recording is never lost because the network failed" invariant (9.2) true rather than aspirational.

Browser: OPFS primary, IndexedDB fallback.

export async function getChunkStorage(recordingId: string): Promise<ChunkStorage> {
  if ('storage' in navigator && 'getDirectory' in navigator.storage) {
    try {
      const root = await navigator.storage.getDirectory();
      const dir = await root.getDirectoryHandle(`recording-${recordingId}`, { create: true });
      return new OpfsChunkStorage(dir);
    } catch {
      // OPFS available but directory creation failed (rare — quota, private browsing edge cases)
    }
  }
  return new IndexedDbChunkStorage(recordingId); // Safari <17.4 practical fallback path, and any OPFS failure
}

class OpfsChunkStorage implements ChunkStorage {
  constructor(private readonly dir: FileSystemDirectoryHandle) {}

  async writeChunk(index: number, blob: Blob): Promise<void> {
    const fileHandle = await this.dir.getFileHandle(`chunk-${String(index).padStart(6, '0')}.bin`, {
      create: true,
    });
    // Synchronous access handle, used inside a dedicated Worker for non-blocking, low-overhead writes
    const accessHandle = await (fileHandle as unknown as FileSystemFileHandleWithSync).createSyncAccessHandle();
    const buffer = await blob.arrayBuffer();
    accessHandle.write(new Uint8Array(buffer), { at: 0 });
    accessHandle.flush();
    accessHandle.close();
  }
}

ChunkStorage.writeChunk runs inside a dedicated Web Worker (not the main thread) so file I/O never contends with the recorder UI's rendering. Each ondataavailable blob (8.2.4) is written as its own file, named with a zero-padded sequential index, so a partial write due to a mid-write crash is isolable to a single chunk file rather than corrupting a growing single-file stream.

Storage-quota request. Before recording starts, Reelay calls navigator.storage.persist() (requests the browser not evict this origin's storage under pressure — granted automatically in most browsers for installed/frequently-visited PWAs, best-effort elsewhere) and navigator.storage.estimate() to check quota - usage against a conservative pre-flight threshold: capture is blocked with a warning if less than 500 MB of estimated available quota remains, since a typical 10-minute 1080p30 screen recording at the 8 Mbps target bitrate (8.2.4) is roughly 600 MB before compression benefits of chunking.

What happens when local storage fills. A writeChunk failure (quota-exceeded DOMException, name QuotaExceededError) triggers: (1) an immediate attempt to free space by deleting any already-server-confirmed chunks from earlier in the same session that are pending local cleanup, (2) if that does not free enough space, an automatic pause (8.9's pause path) with a blocking dialog telling the user local storage is full and offering to stop and finalize with whatever was captured, and (3) under no circumstance does Reelay silently drop a chunk and continue recording — a chunk write failure that cannot be resolved always stops the recording rather than producing a video with an undetectable gap.

Desktop. Chunks are written to the OS-appropriate app data directory (app.getPath('userData')/recordings/<recordingId>/chunk-NNNNNN.bin) using standard fs streaming writes from the main process (never the renderer, consistent with 8.5.1's process-isolation posture). The same low-disk handling described in 8.9 governs this path; there is no OS-level storage quota API equivalent to navigator.storage.estimate() on desktop, so disk-free-space polling (8.9) is the sole guard.

9.2 Resumable chunked upload #

Mechanism. S3-compatible multipart upload, 8 MB parts, up to 4 concurrent part uploads per recording, driven from a dedicated upload Worker (browser) or a background upload manager (desktop main process) that is independent of the capture pipeline — uploading proceeds concurrently with recording for screen/camera sessions once enough locally-written chunks accumulate to fill an 8 MB part, rather than waiting for the recording to end. A recording may have more than one concurrent upload session — one per mediaKind: video_screen, video_camera (only in screen_camera mode, 8.7), and telemetry (only when the compressed telemetry blob exceeds the single-PUT threshold, 8.6.4) — each tracked and retried completely independently of the others.

Presigned part URLs. The app server never proxies media bytes. The client requests presigned part URLs from the API, then PUTs directly to S3-compatible storage:

POST /v1/recordings/:id/uploads
  body: { mediaKind: 'video_screen' | 'video_camera' | 'telemetry' }
                                               → create multipart upload, returns
                                                 { uploadSessionId, uploadId, bucketKey }
POST /v1/recordings/:id/uploads/:uploadSessionId/parts
  body: { partNumber: number }                → returns { url, expiresAt }  (5-minute presigned PUT TTL)
POST /v1/recordings/:id/uploads/:uploadSessionId/complete
  body: { parts: [{ partNumber, etag }] }     → server calls CompleteMultipartUpload, verifies, returns asset status

uploadSessionId is the upload session's own ups_-prefixed public id (Section 4's identifier convention; schema owned by Section 5.4) — it is never the recording's id. A single recording can have several concurrent upload sessions, one per mediaKind as noted above, so the recording id alone cannot address a specific in-progress upload; every subsequent call in this flow is scoped by uploadSessionId, not by recording id. uploadId in the create response is the underlying S3-compatible multipart upload id (s3_upload_id) — a distinct value the client never needs to reference directly again, since every later call already routes by uploadSessionId.

Which physical bucket a session's parts land in is derived from mediaKind, not chosen by the client: video_screen/video_camera sessions upload directly into reelay-media-restricted (9.6), since a freshly captured, not-yet-processed source is definitionally an unredacted original the moment it exists; telemetry sessions upload into reelay-media-delivery (9.6), since cursor telemetry carries none of the unredacted-frame risk that gates the restricted bucket. bucketKey in the response above is always the full key within whichever bucket the session was routed to.

Retry policy (exact). Per part-upload attempt:

export const UPLOAD_RETRY_POLICY = {
  baseDelayMs: 500,
  capDelayMs: 30_000,
  maxAttempts: 8,
  jitter: 'full', // delay = random(0, min(capDelayMs, baseDelayMs * 2 ** attempt))
} as const;

export function computeRetryDelayMs(attempt: number): number {
  const exp = UPLOAD_RETRY_POLICY.baseDelayMs * 2 ** attempt;
  const capped = Math.min(UPLOAD_RETRY_POLICY.capDelayMs, exp);
  return Math.random() * capped; // full jitter
}

A part that exhausts 8 attempts marks the upload session stalled (not failed — see the state table below) and the upload manager retries the entire session on the next network online event or app/tab relaunch, resetting each part's attempt counter, rather than abandoning the recording.

Concurrent part scheduling. A bounded worker-pool scheduler keeps exactly up to 4 part uploads in flight at any time, pulling the next pending part as soon as a slot frees, regardless of whether the recording is still actively producing new local chunks:

export async function scheduleUploads(
  parts: AsyncIterable<PendingPart>,
  uploadPart: (part: PendingPart) => Promise<void>,
  maxConcurrent = 4,
): Promise<void> {
  const inFlight = new Set<Promise<void>>();

  for await (const part of parts) {
    if (inFlight.size >= maxConcurrent) {
      await Promise.race(inFlight);
    }
    const task = uploadPart(part)
      .catch((err) => enqueueForRetry(part, err)) // computeRetryDelayMs-driven backoff, 9.2
      .finally(() => inFlight.delete(task));
    inFlight.add(task);
  }
  await Promise.all(inFlight);
}

Parts are only handed to the scheduler once their backing 8 MB of local chunk data is fully written to OPFS/disk (9.1) — the scheduler never reads a chunk file that a concurrent capture write might still be appending to, since each chunk is written as an immutable, uniquely-indexed file (9.1) and a part is only assembled from chunks whose write has already completed.

Resume after network loss. The upload manager listens for navigator.onLine/offline/online events (browser) or the native OS network-reachability API (desktop) and pauses in-flight part uploads immediately on offline rather than letting them run out their retry budget against a known-dead network — reducing wasted retry attempts — then resumes from the next online event using the same presigned-URL-per-part flow.

Resume after app restart. Upload session state (uploadSessionId, s3_upload_id, bucket key, and each part's status/ETag) is persisted locally (IndexedDB on browser, a small SQLite/JSON state file on desktop), keyed by the upload session's own id and indexed locally by the owning recording id so the resume-prompt scan below can enumerate every open session for a given recording — independent of the in-memory upload manager. On launch, the app scans for any recording with local chunks but at least one upload session not in a terminal state (completed or a user-dismissed aborted) and offers: "You have an unfinished recording from [time]. Resume upload?" — accepting re-attaches to each existing session's S3 multipart upload id (which itself has a server-side TTL of 7 days, matching the lifecycle rule in 9.6) and continues uploading parts that were not yet confirmed complete.

Upload-session state machine. The upload_sessions and upload_parts schema is owned by Section 5.4 — this section defines their runtime behavior only: how a session's status progresses, what triggers each transition, and the invariant governing when local chunks may be deleted. Each upload_sessions row is scoped to one (recording, mediaKind) pair, carries its own ups_-prefixed public id as described above, tracks the underlying s3_upload_id and bucket_key, and rolls up a completed-parts count against a total-parts-expected count (unknown until the client's finalize call reports the true figure). Each upload_parts row is keyed to its parent session and part number, and carries that part's own status, attempt count, and, once confirmed, ETag.

Status Meaning Entered from Exits to
open Multipart upload created server-side, no parts confirmed yet (initial) uploading
uploading At least one part confirmed, more expected open, stalled stalled, assembling
stalled A part exhausted its retry budget; awaiting network recovery or app relaunch uploading uploading (on retry), aborted (after 7 days idle, matching the S3 lifecycle abort rule in 9.6)
assembling Client called .../complete; server is calling CompleteMultipartUpload and verifying uploading completed, stalled (assembly failed, e.g. an ETag mismatch — client re-uploads the disputed part)
completed Server confirmed multipart assembly; local chunks may now be deleted assembling (terminal)
aborted Abandoned by user or expired; storage reclaimed stalled, open (terminal)

Local chunks are deleted only after server-confirmed assembly. The client never deletes a local chunk file on its own initiative from a successful-looking PUT response alone — deletion is gated strictly on the server's .../complete response reporting status: "completed", which itself is gated on the server's own verification of the S3-compatible storage's CompleteMultipartUpload result. This double confirmation (client PUT success is necessary but not sufficient; server-side assembly confirmation is required) is what backs the stated product invariant:

A recording is never lost because the network failed. Local chunks persist through any number of failed upload attempts, network outages, or app restarts, and are removed only once the server has independently verified that every byte was durably assembled in object storage.

9.3 The pipeline, stage by stage #

 ┌────────┐   ┌───────┐   ┌─────────────┐   ┌──────────────────┐   ┌──────────────────┐
 │ ingest │──►│ probe │──►│ asset create│──►│  transcode ladder │──►│ webhook callback  │
 └────────┘   └───────┘   └─────────────┘   │ (StreamingProvider,│   │ (idempotent,      │
                                              │  Section 9.5)      │   │  signature-verified)│
                                              └──────────────────┘   └─────────┬─────────┘
                                                                                  │
                              has EDL edits or active redaction regions?         │
                              ┌───────────────────────────────────────────────────┤
                              │ yes                                                │ no
                              ▼                                                    │
                     ┌─────────────────┐                                          │
                     │ EDL render +    │  (single FFmpeg pass, 9.3.1)              │
                     │ redaction burn-in│                                         │
                     └────────┬─────────┘                                          │
                              │ re-submit rendered master to asset create           │
                              └──────────────►  (loops back through transcode)      │
                                                                                    ▼
                                                                          ┌───────────────────┐
                                                                          │ poster/thumbnail   │
                                                                          │ set generation     │
                                                                          └─────────┬──────────┘
                                                                                    ▼
                                                                               ┌────────┐
                                                                               │ ready  │
                                                                               └────────┘

 Parallel branch (forks at "ingest", independent of the render loop above):
 ingest ──► audio extract ──► transcription ──► captions (VTT) ──► AI chapters/summary/title
                                                                    (Section 12 owns this branch's internals)

All async work runs as BullMQ 6 jobs on Redis (Section 3), with job names in dot.case (Section 4) and job.id = "<entity>:<operation>:<version>" for idempotency (a duplicate enqueue with the same id is a safe no-op — BullMQ rejects the duplicate add). Every queue has a matching dead-letter queue named <queue>.dlq, populated automatically when a job exhausts attempts.

The FFmpeg filter graph invoked by the EDL render + redaction burn-in job (video.render.compose, 9.3.1) — the actual -filter_complex graph that composites camera-bubble instructions (8.7), applies audio-mix instructions (8.8), and burns redaction regions in as an unrecoverable pixel-level modification — is owned by this section; 9.8's export commands illustrate the same render worker's FFmpeg invocation style for a different output. Sections 11.7 and 22.2 own the EDL/redaction contract — which regions exist, when each is active, what security property the burned-in result must guarantee — but neither of those sections redefines the filter graph itself; that stays here.

9.3.1 Queue-by-queue specification #

This table, together with the Export row (also defined here, mechanics detailed in 9.8), is the authoritative specification for every queue and job name the media pipeline uses. No other section may introduce a different job name, queue name, or DLQ naming for any of the operations below.

Queue Job name pattern Payload shape Concurrency Timeout Attempts / backoff DLQ
Ingest probe video.ingest.probe { videoId, bucketKey } 10 per worker replica 60 s 5, exponential (base 2s) video.ingest.probe.dlq
Asset create (standard) video.transcode.request { videoId, sourceBucketKey, priority: 'standard' | 'priority' } 20 per worker replica (I/O-bound); 70% of transcode-request worker capacity reserved to this queue 30 s 5, exponential (base 2s) video.transcode.request.dlq
Asset create (Business priority) video.transcode.request — same job name, routed to the dedicated queue instance video.transcode.request.priority { videoId, sourceBucketKey, priority: 'priority' } 30% of transcode-request worker capacity, reserved and never starved by the standard queue's backlog 30 s 5, exponential (base 2s) video.transcode.request.priority.dlq
Transcode callback video.transcode.callback { videoId, providerAssetId, providerStatus, renditions: RenditionDescriptor[] } 15 per worker replica 30 s 5, exponential (base 2s) video.transcode.callback.dlq
EDL render + burn-in video.render.compose { videoId, editDecisionListId, redactionRegionIds: string[] } 2 per worker replica (CPU-bound; scaled horizontally via replica count) max(300, durationMs / 1000 * 3) seconds 3, exponential (base 5s) — lower attempt count than I/O jobs because a render failure is rarely transient video.render.compose.dlq
Poster/thumbnail video.render.poster { videoId, sourceBucketKey, timestampsMs: number[] } 10 per worker replica 60 s 5, exponential (base 2s) video.render.poster.dlq
Audio extract (fork point to Section 12) video.transcribe.extract-audio { videoId, sourceBucketKey } 10 per worker replica 120 s 5, exponential (base 2s) video.transcribe.extract-audio.dlq
Export video.render.export { videoId, format: 'mp4' | 'webm' | 'gif', rangeMs: {startMs, endMs} | null, resolutionTier } 3 per worker replica (CPU-bound, FFmpeg) Scaled the same way as video.render.compose above 3, exponential (base 5s) video.render.export.dlq

The transcription/captions/AI-metadata queues past audio extract are owned by Section 12; this section only defines the fork point (video.transcribe.extract-audio) it hands off to.

Representative payloads, as enqueued (BullMQ add(name, data, opts)opts.jobId carries the idempotency key described above):

// video.ingest.probe
{
  "videoId": "018f2c9a-6e3b-7000-8b1a-2f6c9a1e4d10",
  "bucketKey": "originals/018f2c99-.../018f2c9a-.../source.webm"
}
// video.render.compose
{
  "videoId": "018f2c9a-6e3b-7000-8b1a-2f6c9a1e4d10",
  "editDecisionListId": "018f2c9b-1a2b-7000-9c3d-4e5f6a7b8c9d",
  "redactionRegionIds": ["018f2c9c-...", "018f2c9c-..."]
}
// video.transcode.callback (constructed by the webhook receiver from the provider's verified event)
{
  "videoId": "018f2c9a-6e3b-7000-8b1a-2f6c9a1e4d10",
  "providerAssetId": "mux_asset_abc123",
  "providerStatus": "ready",
  "renditions": [
    { "resolutionTier": "1080p", "bitrateBps": 4500000, "codec": "h264" },
    { "resolutionTier": "720p", "bitrateBps": 2500000, "codec": "h264" },
    { "resolutionTier": "480p", "bitrateBps": 1200000, "codec": "h264" },
    { "resolutionTier": "360p", "bitrateBps": 700000, "codec": "h264" }
  ]
}

The webhook HTTP receiver itself is a thin, synchronous handler: verify signature (9.5's parseWebhook) → enqueue video.transcode.callback with jobId = "video:transcode-callback:<providerAssetId>:<occurredAt>" → return 200 immediately. All actual state mutation (updating media_assets/renditions rows, deciding whether to enter the render loop) happens inside the queued job handler, never inline in the HTTP request path — this keeps webhook response times low (Section 7.10's webhook contract) and makes reprocessing a stuck callback as simple as re-running the job.

Priority queue for Business. video.transcode.request jobs from a Business-plan workspace are enqueued with priority: 'priority' and routed to a separate BullMQ queue instance (video.transcode.request.priority) consumed by a worker pool with a reserved concurrency allocation — the priority pool is never starved by a backlog on the standard queue, because it is a physically distinct queue with its own dedicated concurrency slice (default split: 70% of transcode-request worker capacity to standard, 30% reserved to priority, tunable via the worker fleet's environment configuration), rather than an in-queue priority field that a large standard backlog could still delay behind. This satisfies the "transcode priority: priority queue" row in Section 21's plan table.

Every stage is idempotent. job.id is deterministic per entity/operation/version (e.g. video:transcode-request:1 for the first attempt at requesting a transcode for a given video, incrementing the version segment only for an intentional re-render, e.g. after an EDL edit — video:transcode-request:2). Re-enqueuing the same job.id while a prior attempt is in flight or already completed is a safe no-op, which makes webhook-triggered enqueues (which can legitimately be delivered more than once by the streaming provider, per Section 7.10's webhook idempotency rules) safe to process without double-charging transcode minutes or producing duplicate renditions.

9.4 The transcode ladder #

Source resolution Included rungs (never upscale past source) Bitrate (H.264, per rung) Profile
≥ 2160p (4K) 2160p, 1440p, 1080p, 720p, 480p, 360p 4K: 12 Mbps · 1440p: 7 Mbps · 1080p: 4.5 Mbps · 720p: 2.5 Mbps · 480p: 1.2 Mbps · 360p: 0.7 Mbps High @ L5.1 (2160p/1440p), High @ L4.2 (1080p/720p), Main @ L3.1 (480p/360p)
1440p 1440p, 1080p, 720p, 480p, 360p Same per-rung values as above, 2160p rung omitted Same as above, top rung High @ L4.1
1080p 1080p, 720p, 480p, 360p Same per-rung values as above High @ L4.2 top rung
720p 720p, 480p, 360p Same per-rung values as above High @ L4.0 top rung
≤ 480p (rare: audio-only-adjacent or heavily cropped region captures) 480p, 360p Same per-rung values as above Main @ L3.1

Rung selection is computed once at asset-create time from the probed source resolution (9.3's video.ingest.probe stage) and never includes a rung whose resolution exceeds the source — upscaling wastes transcode cost and storage without any quality benefit, and is explicitly excluded.

ABR packaging. Adaptive bitrate packaging (HLS manifest generation, segmenting, per-rung keyframe alignment) is performed by the streaming provider (9.5), not by Reelay's own FFmpeg workers — this is the "bought, not built" boundary explained in 9.5. Reelay's FFmpeg workers are responsible only for the EDL render/redaction-burn-in pass (9.3.1) that produces the source handed to the streaming provider when edits or redaction are present; standard-path (no-edit, no-redaction) recordings go straight from the probed original to the streaming provider with no local FFmpeg render step at all.

The player (Section 15, using hls.js where native HLS via <video> is unavailable per the versions in Section 3) consumes a master manifest shaped like the following — Reelay never authors this file directly, it is generated and served entirely by the streaming provider, shown here only to make the rung-to-manifest relationship concrete for implementers wiring up the player:

#EXTM3U
#EXT-X-STREAM-INF:BANDWIDTH=4500000,RESOLUTION=1920x1080,CODECS="avc1.640028,mp4a.40.2"
1080p/index.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=2500000,RESOLUTION=1280x720,CODECS="avc1.4d401f,mp4a.40.2"
720p/index.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=1200000,RESOLUTION=854x480,CODECS="avc1.4d401e,mp4a.40.2"
480p/index.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=700000,RESOLUTION=640x360,CODECS="avc1.4d4015,mp4a.40.2"
360p/index.m3u8

Codec choice. H.264 is the default encode target across every rung because it is the only codec with universal hardware-decode support across every browser and device Reelay's viewers use (Section 8.3's browser matrix), which matters far more for a link-shared, often-first-time viewer than marginal bitrate efficiency gains from HEVC or AV1. The StreamingProvider interface (9.5) already models codec: 'h264' | 'h265' per rendition specifically so a future encode-efficiency migration (e.g. AV1 for the highest rung only, with H.264 rungs retained for compatibility) is an additive provider-side configuration change, not a schema or player change.

9.5 The StreamingProvider interface #

export interface StreamingProvider {
  /** Creates a streaming asset from a source already durably stored in our object storage. */
  createAsset(input: CreateAssetInput): Promise<CreateAssetResult>;

  /** Fetches current status/renditions for a previously created asset. */
  getAsset(providerAssetId: string): Promise<ProviderAsset>;

  /** Permanently deletes an asset and its renditions from the provider. */
  deleteAsset(providerAssetId: string): Promise<void>;

  /** Issues a short-TTL signed playback token for a specific asset (9.7). */
  createSignedPlaybackToken(
    providerAssetId: string,
    opts: { ttlSeconds: number; restrictDomain?: string },
  ): Promise<{ token: string; expiresAt: string }>;

  /** Verifies and parses an inbound webhook payload from the provider. */
  parseWebhook(rawBody: Buffer, signatureHeader: string): ProviderWebhookEvent;
}

export interface CreateAssetInput {
  sourceUrl: string;             // presigned, short-TTL GET URL into reelay-media-delivery (9.6) — the redacted rendition_master, never a restricted-bucket object
  priority: 'standard' | 'priority';
  maxResolutionTier: '360p' | '480p' | '720p' | '1080p' | '1440p' | '2160p'; // from 9.4's rung selection
  generateSubtitlesTrackPlaceholder: false; // captions are Reelay/Section 12-owned, never provider-generated
}

export interface CreateAssetResult {
  providerAssetId: string;
  providerUploadId: string | null; // set for provider-side direct-upload flows, else null
}

export interface ProviderAsset {
  providerAssetId: string;
  status: 'preparing' | 'ready' | 'errored';
  durationMs: number | null;
  renditions: RenditionDescriptor[];
  playbackIds: string[];
}

export interface RenditionDescriptor {
  resolutionTier: '360p' | '480p' | '720p' | '1080p' | '1440p' | '2160p';
  bitrateBps: number;
  codec: 'h264' | 'h265';
}

export interface ProviderWebhookEvent {
  type: 'asset.ready' | 'asset.errored' | 'asset.updated';
  providerAssetId: string;
  occurredAt: string;
  raw: unknown;
}

MuxStreamingProvider implementation. Wraps the Mux Video API: createAsset calls Mux's asset-creation endpoint with our object storage's presigned URL as the input, getAsset proxies Mux's asset-retrieval endpoint mapped into ProviderAsset, createSignedPlaybackToken wraps Mux's signed-URL JWT generation (the 6-hour TTL from 9.7 passed straight through as the token's expiry claim), and parseWebhook verifies the Mux-Signature header against the configured webhook secret before mapping Mux's event schema into ProviderWebhookEvent. This implementation lives entirely behind the interface above, in a single file in apps/worker; no other code in the monorepo imports Mux's SDK directly.

What would change to swap vendors. Only: (1) a new <Vendor>StreamingProvider implementation of the interface above, (2) the webhook signature-verification logic specific to that vendor, and (3) a migration job that re-createAssets existing videos against the new vendor (since renditions live in the vendor's infrastructure, not ours) — no change to the queue structure in 9.3, no change to the transcode ladder's logical rungs in 9.4 (though the new vendor's actual bitrate/encoding implementation may differ, the rung resolution tiers Reelay requests stay the same), no change to the database schema beyond the opaque provider_asset_id column already storing a vendor-agnostic string, and no change to the player (Section 15) beyond swapping which signed-token format it requests, since the player already treats playback URLs as opaque signed tokens.

Why transcoding is bought, not built. Per-title adaptive encoding (choosing bitrate/rung parameters per source rather than one-size-fits-all), a global low-latency CDN specifically tuned for video delivery, codec R&D (staying current with encoder efficiency improvements across H.264/H.265/AV1 without dedicating engineering headcount to codec work), and the operational burden of running a large, elastic FFmpeg transcode fleet reliably at scale (autoscaling for burst load, GPU/hardware-encoder fleet management, regional redundancy) are a multi-year specialization for a dedicated video infrastructure vendor. Reelay's differentiated value is in capture fidelity (Section 8), the auto-editing engine (Section 10), non-destructive editing (Section 11), and engagement/analytics (Sections 16–17) — none of which require owning the transcode fleet. FFmpeg remains in-house specifically for the EDL render and redaction burn-in pass (9.3.1) and exports (9.8), because that logic is proprietary product behavior a generic transcoding vendor's API has no concept of, not because Reelay needs to reduplicate general-purpose transcoding.

9.6 Object storage layout #

Two physically separate buckets, per the security model owned by Section 22.2.2. Restricted, never-served original media and every servable, viewer-safe asset live in two different S3-compatible buckets — not two prefixes inside one shared bucket — so that a bucket-policy misconfiguration on one cannot expose the other:

Bucket Env var Holds CDN origin? Default access
reelay-media-restricted-{env} STORAGE_BUCKET_RESTRICTED Unredacted original screen/camera source only No. Never fronted by any CDN distribution, under any configuration IAM-denied by default; readable only by the render-worker service role and the named break-glass admin role (below)
reelay-media-delivery-{env} STORAGE_BUCKET_DELIVERY Every servable rendition master, poster, thumbnail, GIF preview, export, and screenshot, plus cursor telemetry blobs Yes — this is Reelay's CDN origin (9.7) Readable by the render/poster/export worker roles and the CDN's origin-access identity; never publicly listable and never readable by a direct, unsigned S3 URL — all viewer access goes through the CDN or a short-TTL presigned URL (9.7, Section 14.8.1's access chain)

Both buckets use the identical prefix-naming scheme below, so which bucket a given key lives in is purely a routing decision driven by asset class (the table further down states that mapping explicitly), and no code needs bucket-specific key-construction logic:

originals/{workspaceId}/{videoId}/source.<ext>                              # reelay-media-restricted ONLY
renditions/{workspaceId}/{videoId}/{editDecisionListId}/master.<ext>        # reelay-media-delivery — EDL-rendered, redaction-burned-in master fed to the streaming provider
posters/{workspaceId}/{videoId}/{playbackKeyVersion}/{contentHash}.jpg      # reelay-media-delivery — scoped by share_links.playback_key_version, R6
thumbnails/{workspaceId}/{videoId}/{playbackKeyVersion}/{contentHash}.jpg   # reelay-media-delivery — same scoping as posters
gif-previews/{workspaceId}/{videoId}/{playbackKeyVersion}/{contentHash}.gif # reelay-media-delivery — same scoping as posters
telemetry/{workspaceId}/{videoId}/{recordingId}/cursor.bin.gz              # reelay-media-delivery
exports/{workspaceId}/{videoId}/{exportJobId}/output.<ext>                 # reelay-media-delivery (9.8)
screenshots/{workspaceId}/{screenshotId}/original.png                       # reelay-media-delivery
screenshots/{workspaceId}/{screenshotId}/edited.png                         # reelay-media-delivery (Section 13 owns screenshot editing)

Key naming uses the entity's UUIDv7 (Section 4) directly (not the prefixed public-facing id, e.g. vid_...) since these are internal storage keys never exposed to a client — the API layer translates between public prefixed ids and internal UUIDs at the boundary (Section 5). renditions/ — not renders/ — is the canonical prefix name for the EDL-rendered, redaction-burned-in master, matching the naming of the renditions table it is fed into downstream (Section 5).

Every media_assets.kind value, and which bucket it lives in (the media_assets schema itself is owned by Section 5.4; this section states only the storage location for each kind):

media_assets.kind Bucket Prefix Notes
original reelay-media-restricted originals/ The captured screen/camera source, for a video that has no redaction regions. Physically the same bytes as redaction_unredacted_original below — a video is always in exactly one of these two kinds, never both, selected by whether it currently has any redaction region. source_original is not a valid kind in this model — it does not exist, and the unredacted upload is represented only by these two kinds (Section 5.4.4)
redaction_unredacted_original reelay-media-restricted originals/ The identical captured source, relabeled to this kind the moment a video gains its first redaction region, so every consumer of media_assets.kind can tell at a glance that this specific object must never be served, exported, or referenced by a share link, under any circumstance
brand_logo reelay-media-delivery brand/ Workspace brand-kit assets (Section 18.5); never contains recorded footage
poster reelay-media-delivery posters/ Content-hashed, playback_key_version-scoped (9.7, R6)
thumbnail reelay-media-delivery thumbnails/ Same scoping as poster
export (GIF preview variant) reelay-media-delivery gif-previews/ GIF previews are export-kind rows stored under their own prefix; same playback_key_version scoping as poster (9.7)
export reelay-media-delivery exports/ 9.8
rendition master object (not a media_assets row) reelay-media-delivery renditions/ The EDL-rendered, redaction-burned-in master produced by 9.3's render loop and handed to the streaming provider as CreateAssetInput.sourceUrl (9.5). It is tracked as a renditions row (Section 5.4.4), not a media_assets row. Safe outside the restricted boundary because redaction burn-in has already made it viewer-safe by construction before it is written here
screenshot_original reelay-media-restricted screenshots/ The raw captured frame before beautification or redaction. Restricted per the media_assets_restricted_consistency constraint in Section 5.4.4 — a captured frame is not assumed safe to serve until the owner publishes a screenshot_edited derivative. Section 13 owns screenshot capture
screenshot_edited reelay-media-delivery screenshots/ Section 13

Cursor telemetry (telemetry/) is not a media_assets row at all — its schema is the separate cursor_telemetry_blobs table (Section 5.4, 8.6.5) — but it is listed in the prefix scheme above for completeness, since it shares the same bucket and the same {workspaceId}/{videoId}/... key convention as everything else in reelay-media-delivery.

Storage classes.

Prefix Bucket Class Rationale
originals/ restricted S3 Intelligent-Tiering Access pattern is bursty and unpredictable (re-render on a new edit, redaction change, or a future feature can request the original at any time up to the workspace's retention limit, Section 19) — Intelligent-Tiering auto-optimizes cost without the retrieval-latency surprises of Glacier tiers
renditions/ delivery Standard, 30-day lifecycle transition to Standard-IA if unaccessed Masters are recreated whenever the EDL or redaction set changes, so old masters age out of hot access quickly
posters/, thumbnails/, gif-previews/ delivery Standard only Small objects; the 1-year immutable CDN caching in 9.7 already keeps origin reads rare after first fetch, so tiering buys little
telemetry/ delivery Standard, 90-day lifecycle transition to Standard-IA Small objects; access is concentrated in the first days after recording while Section 10's auto-edit is iterated on
exports/ delivery Standard only, 30-day hard expiry (lifecycle delete) Regenerable on demand (9.8); not worth tiering, worth deleting
screenshots/ delivery Standard only Small, frequently accessed relative to size; no tiering benefit

Lifecycle rules.

  • AbortIncompleteMultipartUpload at 7 days, applied to both buckets — matches the upload_sessions.stalled → aborted timeout in 9.2 (a stalled video_screen/video_camera session targets reelay-media-restricted; a stalled telemetry session targets reelay-media-delivery), preventing orphaned multipart uploads from leaking storage cost in either bucket.
  • exports/* objects in reelay-media-delivery expire (hard delete) 30 days after creation.
  • originals/* in reelay-media-restricted and renditions/* in reelay-media-delivery lifecycle is governed by the workspace's retention policy (Section 19 owns the full retention/deletion rationale and schedule; this section only notes that the storage-class rules above operate independently of, and prior to, whatever retention-driven deletion Section 19 eventually triggers).

Server-side encryption. SSE-KMS with a per-environment (not per-workspace) customer-managed key, applied identically to both buckets, S3 Bucket Keys enabled to reduce KMS request volume/cost, and a bucket policy on each bucket that denies any PutObject lacking the expected encryption header — objects cannot land in either bucket unencrypted even if a caller's request forgets to specify it.

The reelay-media-restricted bucket — IAM boundary. IAM policy and bucket policy jointly restrict all read access to this bucket — the entire bucket, not a prefix within a bucket shared with anything else — to exactly two principals: the render-worker service role (needs the original to produce the redacted rendition master, per 9.3's render loop) and a named break-glass administrative role used only for support/legal escalations. This bucket has no CDN origin configured, under any circumstance — there is no distribution, no origin-access identity, no path by which a CDN edge could ever serve an object from it. That absence is a structural guarantee, not just a policy statement: a misconfigured cache-control header or an accidentally-public setting on the delivery bucket cannot expose restricted content, because restricted content is simply unreachable from anything the CDN talks to. No share link (Section 14), no CDN distribution, and no player configuration ever references a reelay-media-restricted key directly — every viewer-facing delivery path is either a streaming-provider playback id (seeded from a rendition master object already living in the delivery bucket) or an exports/*, posters/*, thumbnails/*, gif-previews/*, or screenshots/* delivery-bucket object. Note that the screenshots/ prefix exists in both buckets: a screenshot_original lives in the restricted bucket and is never viewer-facing, while its screenshot_edited derivative lives in the delivery bucket. Prefix alone therefore never implies servability — the bucket does. Every read of a reelay-media-restricted object, including by the render worker and the break-glass role, is access-logged both at the S3 access-log level and as an application-level audit_events row (Section 22 owns the full access-control rationale and audit-log schema; this section states only that the logging happens on every access, with no exception).

Bucket policies enforcing the restriction and the encryption requirement:

// reelay-media-restricted-production bucket policy
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "DenyAllExceptRenderWorkerAndBreakGlass",
      "Effect": "Deny",
      "Principal": "*",
      "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::reelay-media-restricted-production/*",
      "Condition": {
        "StringNotEquals": {
          "aws:PrincipalArn": [
            "arn:aws:iam::ACCOUNT_ID:role/reelay-render-worker",
            "arn:aws:iam::ACCOUNT_ID:role/reelay-break-glass-admin"
          ]
        }
      }
    },
    {
      "Sid": "DenyUnencryptedObjectUploads",
      "Effect": "Deny",
      "Principal": "*",
      "Action": "s3:PutObject",
      "Resource": "arn:aws:s3:::reelay-media-restricted-production/*",
      "Condition": {
        "StringNotEquals": { "s3:x-amz-server-side-encryption": "aws:kms" }
      }
    }
  ]
}
// reelay-media-delivery-production bucket policy
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "DenyDirectPublicReads",
      "Effect": "Deny",
      "Principal": "*",
      "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::reelay-media-delivery-production/*",
      "Condition": {
        "StringNotEquals": {
          "aws:PrincipalArn": [
            "arn:aws:iam::ACCOUNT_ID:role/reelay-render-worker",
            "arn:aws:iam::ACCOUNT_ID:role/reelay-poster-export-worker",
            "arn:aws:iam::ACCOUNT_ID:role/reelay-cdn-origin-access"
          ]
        }
      }
    },
    {
      "Sid": "DenyUnencryptedObjectUploads",
      "Effect": "Deny",
      "Principal": "*",
      "Action": "s3:PutObject",
      "Resource": "arn:aws:s3:::reelay-media-delivery-production/*",
      "Condition": {
        "StringNotEquals": { "s3:x-amz-server-side-encryption": "aws:kms" }
      }
    }
  ]
}

Every viewer-facing S3-compatible URL, on either bucket, is either a short-TTL presigned GET (originals fetched by the render worker; posters/thumbnails/exports fetched by the CDN origin) or is never issued at all — no long-lived, guessable public URL exists on either bucket.

9.7 Delivery #

Signed playback URLs. The streaming provider issues signed playback tokens with a 6-hour TTL (9.5's createSignedPlaybackToken). The watch page and embedded player (Section 15) request a fresh token from GET /v1/videos/:id/playback-token on load and proactively re-issue a new one at the 5-hour mark of a still-open playback session (long-running background/lecture-style playback), rather than waiting for a 401 from the streaming provider's edge — this keeps uninterrupted long-form viewing from ever hitting an expired-token stall. A token is scoped to a single video's playback ids and, where a share link specifies a domain allowlist (Section 14), carries that restriction through to the provider's own domain-restriction feature where supported, as defense in depth alongside Reelay's own referer check.

The API response for GET /v1/videos/:id/playback-token follows the success envelope defined in Section 7:

{
  "data": {
    "playbackId": "abc123def456",
    "token": "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJhYmMxMjNkZWY0NTYi...",
    "expiresAt": "2026-08-19T18:00:00Z"
  },
  "meta": null
}

The token claim set (opaque to Reelay's own services beyond issuance/verification, since the streaming provider signs it) minimally encodes the playback id, an expiry timestamp matching expiresAt, and, when set, the domain restriction — the player never inspects claims itself, it only appends the token as a query parameter on the HLS manifest URL the provider's player SDK/CDN expects.

Re-issue policy. A 403/expired-token response from the provider's CDN at any point during playback triggers exactly one automatic silent re-issue attempt (new call to the same endpoint) before the player surfaces a visible "Playback session expired — reload to continue" message; this bounds retry behavior to avoid a token-refresh loop masking a genuine authorization failure (e.g. a share link whose visibility changed mid-playback, Section 14).

CDN configuration. HLS manifest and segment delivery for adaptive playback is served through the streaming provider's own CDN (out of scope for Reelay's own CDN configuration, since it is entirely inside the vendor boundary described in 9.5). Reelay's own CDN sits in front of the reelay-media-delivery bucket (9.6) only — it has no origin pointed at reelay-media-restricted, structurally, per 9.6's IAM boundary — for everything the streaming provider does not serve: posters, thumbnails, GIF previews, exports, and screenshots.

Asset type Cache key TTL Notes
Poster / thumbnail Content-addressed object key (filename includes a content hash of the source frame), nested under the share link's playback_key_versionposters/{workspaceId}/{videoId}/{playbackKeyVersion}/{contentHash}.jpg (9.6, R6) 1 year, immutable (Cache-Control: public, max-age=31536000, immutable) This is the one cache policy for this asset class — no competing TTL statement applies to posters or thumbnails anywhere in this specification. Content-hashed filenames mean a routine regenerated poster (e.g. after re-editing a non-redaction change) is simply a new key, not a cache invalidation — no purge needed for that case. The playback_key_version path segment means revoking or downgrading a share link (Section 14.1.1) invalidates every poster/thumbnail URL issued under the old version in one step. The redaction case is the one exception that does require an explicit purge — see below
GIF preview Content-hashed object key, also nested under playback_key_versiongif-previews/{workspaceId}/{videoId}/{playbackKeyVersion}/{contentHash}.gif (9.6) 1 year, immutable Same reasoning and same version-scoping as poster/thumbnail
Export (MP4/WebM/GIF, 9.8) Object key, includes export job id 1 hour, stale-while-revalidate=86400 Exports are regenerable; short TTL bounds staleness risk if an export is ever manually deleted and regenerated under a reused id (defensive posture, not an expected occurrence given job ids are UUIDs)
embed.js loader / player core bundle (Section 15) Versioned build path 5 minutes, stale-while-revalidate=3600 Short enough for fast rollout of player fixes, long enough to absorb traffic spikes without hammering origin. This TTL applies only to the player bundle — never to posters, thumbnails, or GIF previews

Redaction-triggered poster/thumbnail/GIF-preview regeneration and purge. Whenever a redaction region is added to, or its geometry or timing changed on, a video that already has a poster, thumbnail, or GIF preview generated, the full set is unconditionally regenerated from the now-redacted frame content (re-enqueued via video.render.poster, 9.3.1), and — unlike the routine content-hash key rotation described above — the previous poster, thumbnail, and GIF-preview objects are explicitly purged: deleted from reelay-media-delivery and purge-requested from the CDN cache by key. This is the one case where relying on immutable content-hashed keys alone is not sufficient: the new redaction produces different frame content and therefore a new content hash and a new key, but the old, pre-redaction object would otherwise remain live at its old key for the full 1-year immutable TTL and fetchable by anyone who retained that URL — silently leaking exactly the unredacted frame content the redaction was added to hide. This purge step is mandatory, runs synchronously as part of the redaction-region write path, and is never optional or best-effort. (Sections 11.7 and 22.2 own the redaction contract and the security property it guarantees, respectively; this section owns the poster/thumbnail/GIF-preview regeneration mechanics that a redaction change triggers.) The same purge step also runs on visibility downgrade and link revocation (Section 14.1.1) — a poster, thumbnail, or GIF preview must never remain servable under a revoked link's playback_key_version any more than the video itself would.

9.8 Export rendering #

Exports are produced asynchronously by a render worker invoking FFmpeg 7.x directly (the version line is defined once in Section 3; this section refers to it as "FFmpeg 7.x" per Section 3's rule against restating version numbers), using the same FFmpeg filter-graph ownership described in 9.3. Source is always the redacted, EDL-applied rendition master object (renditions/..., 9.6) when one exists, or the probed original when the video has no edits and no redaction regions.

MP4 export (H.264, broadly compatible):

ffmpeg -y -i master.mp4 \
  -vf "scale=${TARGET_WIDTH}:${TARGET_HEIGHT}:force_original_aspect_ratio=decrease,pad=${TARGET_WIDTH}:${TARGET_HEIGHT}:(ow-iw)/2:(oh-ih)/2,${WATERMARK_FILTER}" \
  -c:v libx264 -profile:v high -level 4.2 -preset slow -crf 18 -pix_fmt yuv420p \
  -c:a aac -b:a 192k -ar 48000 \
  -movflags +faststart \
  output.mp4

WebM export (VP9, smaller files, used where the requesting client prefers open codecs):

ffmpeg -y -i master.mp4 \
  -vf "scale=${TARGET_WIDTH}:${TARGET_HEIGHT}:force_original_aspect_ratio=decrease,pad=${TARGET_WIDTH}:${TARGET_HEIGHT}:(ow-iw)/2:(oh-ih)/2,${WATERMARK_FILTER}" \
  -c:v libvpx-vp9 -b:v 0 -crf 30 -row-mt 1 -cpu-used 2 \
  -c:a libopus -b:a 160k -ar 48000 \
  output.webm

GIF export (two-pass palette generation for quality, per the brief's requirement — a direct single-pass GIF encode produces visibly banded, dithered output on screen-recording content with sharp text edges):

# Pass 1: generate an optimized 256-color palette from the actual clip content
ffmpeg -y -i master.mp4 -t ${CLIP_DURATION_S} \
  -vf "fps=12,scale=${GIF_WIDTH}:-1:flags=lanczos,palettegen=stats_mode=diff" \
  palette.png

# Pass 2: encode using that palette, with Floyd-Steinberg dithering for smooth gradients
ffmpeg -y -i master.mp4 -i palette.png -t ${CLIP_DURATION_S} \
  -filter_complex "fps=12,scale=${GIF_WIDTH}:-1:flags=lanczos[x];[x][1:v]paletteuse=dither=floyd_steinberg" \
  output.gif

Watermark filter. ${WATERMARK_FILTER} is overlay=W-w-24:H-h-24 compositing the Reelay wordmark PNG (bottom-right, 24px inset) for Free-plan exports; it is the empty string (no-op) for Pro/Business exports, per Section 21's plan table row "Export MP4/GIF/WebM: 720p, watermark (Free) / up to 4K, no watermark (Pro/Business)". ${TARGET_WIDTH}/${TARGET_HEIGHT} are clamped to 1280×720 for Free-plan requests regardless of what the requester asks for, and up to the source's native resolution (capped at 3840×2160) for Pro/Business.

Resolution, size, and duration limits.

Format Max resolution Max duration Notes
MP4 4K (Pro/Business), 720p (Free) Full source length No hard size cap; -crf 18 targets high visual quality, file size scales with content and duration
WebM 4K (Pro/Business), 720p (Free) Full source length Same posture as MP4
GIF 960px wide, scaled proportionally (GIFs are never offered above this width regardless of plan — GIF is a lightweight-preview format, not a full-fidelity export) 30 seconds max per export job A GIF export request for a longer source range is rejected client-side before job creation with error.code = "gif_export_duration_exceeded" (Section 7.6 envelope), guiding the user to trim the range or use MP4/WebM instead

Pre-export validation. Before invoking the export commands above, the render worker runs a cheap ffprobe sanity check against the source and the requested range, rejecting the job early (rather than letting FFmpeg fail deep into an encode) when the check fails:

ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 master.mp4

If the returned duration is less than rangeMs.endMs, the job fails fast with error.code = "export_range_out_of_bounds" (Section 7.6 envelope) instead of producing a truncated or empty export — this most commonly occurs when an export is requested against a range selected before a subsequent EDL edit shortened the timeline, and pre-validation surfaces that mismatch as an actionable error rather than a silent bad file.

Async delivery. Export jobs run on the same BullMQ posture as the rest of the pipeline — job name, DLQ, concurrency, timeout, and retry policy are exactly as listed for video.render.export in the authoritative queue table in 9.3.1; this section does not restate those numbers so the two never drift apart. On completion, the resulting exports/... object (9.6) is announced two ways: (1) an in-app notification with a direct download link (a short-TTL signed CDN URL, re-issued on each dashboard visit rather than embedded permanently, since the underlying object itself expires after 30 days per 9.6's lifecycle rule), and (2) an email (Section 20 owns the email-delivery mechanics) containing the same download link, sent via the transactional email path logged in email_log (Section 5's entity list).

9.9 Failure taxonomy #

Failure Where detected User-visible message Auto-retried? Lands in DLQ? Operator entry point
Chunk write failure during recording (quota exceeded) Client, writeChunk "Local storage is full — recording paused." (8.9) N/A — client-side pause, not a queue job No N/A (client-side, no server queue involved)
Part upload exhausts 8 attempts Client upload manager "Upload paused — will resume automatically." Yes, on next online event or app relaunch (9.2) No (client-managed retry loop, not a BullMQ job) N/A
Multipart assembly (CompleteMultipartUpload) fails server-side (e.g. an ETag mismatch on one part) API, .../complete handler "Finishing upload — this can take a minute." (transient message, not shown as an error unless it persists past 2 minutes) Yes — client re-uploads only the disputed part and retries complete No (handled synchronously in the request path, not a queue) If the same upload session fails assembly 3+ times, an audit_events entry flags it for the runbook entry point owned by Section 24
ffprobe cannot parse the assembled upload (corrupt container) video.ingest.probe job "We couldn't process this recording. Please try recording again." No — a corrupt source will not become valid on retry Yes, video.ingest.probe.dlq Section 24 runbook: inspect the DLQ payload's bucketKey, confirm corruption via manual ffprobe, offer the user a re-record path
Client-reported codec/mime type does not match probed container video.ingest.probe job No user-visible error — processing continues, discrepancy is logged N/A (not a failure, a data-quality flag) No Logged for support triage, not operator-paged
Streaming-provider asset creation fails (vendor API error, quota, transient 5xx) video.transcode.request job "Processing is taking longer than usual." (shown only if still unresolved after 5 minutes) Yes, 5 attempts exponential backoff Yes, video.transcode.request.dlq after exhaustion Section 24 runbook: check vendor status page, manually re-enqueue from the DLQ once vendor recovers
Streaming-provider webhook reports asset.errored video.transcode.callback job "This recording couldn't be processed. Our team has been notified." No — a provider-side encode error does not self-resolve on blind retry; the runbook governs manual re-submission Yes, video.transcode.callback.dlq Section 24 runbook: inspect the vendor's error detail in the webhook payload, determine if a manual re-createAsset with adjusted input (e.g. after a source repair) is viable
EDL render / redaction burn-in (video.render.compose) fails (FFmpeg crash, out-of-memory, unsupported filter combination) Render worker "Your edit couldn't be rendered. The original recording is unaffected." (immutability invariant, Sections 11/12, means this never risks the source) Yes, 3 attempts Yes, video.render.compose.dlq Section 24 runbook: reproduce with the DLQ payload's editDecisionListId against a worker replica manually, common causes are a redaction region referencing a timestamp past the source's duration (guarded client-side but re-checked here)
Poster/thumbnail generation fails video.render.poster job No blocking error — video can still become ready with a generic placeholder poster, backfilled on next successful retry Yes, 5 attempts, and periodic sweep job retries any video stuck with a placeholder poster Yes, video.render.poster.dlq Low-priority Section 24 runbook entry; never blocks ready
Export job fails (any format) video.render.export job "Export failed. Try again, or contact support if this keeps happening." Yes, 3 attempts Yes, video.render.export.dlq Section 24 runbook
Telemetry upload fails after video is otherwise ready Client + server verification (8.6.5) No blocking error — video is ready; Section 10's auto-edit UI shows "Cursor data unavailable for this recording" Client retries telemetry upload independently up to the same 8-attempt policy as 9.2, then gives up silently (non-critical path) No N/A — non-critical, not paged

Every DLQ entry additionally increments a per-queue Section 24 dashboard metric and, above a configured rate threshold within a rolling window, pages the on-call rotation — the specific alerting thresholds and paging configuration are owned by Section 24's observability/reliability specification; this section defines only which failures are DLQ-eligible and what the affected user sees.

9.10 Cost model #

Estimates below are per-minute-of-source-video, at the median observed screen-recording content profile (1080p30, moderate motion — mostly static UI with periodic cursor movement and scrolling, which compresses substantially better than high-motion video). All figures are planning-level order-of-magnitude estimates for capacity and pricing decisions, not a committed unit-economics guarantee.

Cost component Estimate per minute Driver / lever
Object storage (reelay-media-restricted original + reelay-media-delivery rendition master + telemetry, Intelligent-Tiering blended rate) ≈ $0.0004–0.0008/min-stored/month Source bitrate (8.2.4's 8 Mbps target ≈ 60 MB/min at 1080p30); lever: lower default capture bitrate for screen content, more aggressive Standard-IA transition timing
Streaming-provider transcode + storage (Mux, ladder per 9.4) ≈ $0.015–0.045/min ingested, one-time Number of rungs actually produced (9.4's source-resolution-gated rung selection is itself a cost lever — a 720p source producing only 3 rungs costs less than a 4K source producing 6); lever: cap max ingest resolution on Free plan, prioritize-queue surcharge absorbed into Business plan pricing rather than passed through per-minute
Streaming-provider delivery (ABR playback egress) ≈ $0.003–0.012 per viewer-minute-played, highly volume-tiered Total viewer watch-minutes, not recording minutes — a video watched 1,000 times costs proportionally more than one watched twice; lever: CDN cache hit rate on the streaming provider's edge (out of Reelay's direct control, but influenced by rendition count and popularity clustering)
Reelay CDN (posters/thumbnails/GIF previews/exports/screenshots, reelay-media-delivery only) ≈ $0.0001–0.0003/min-equivalent Cache-hit ratio (9.7's 1-year immutable TTLs on posters/thumbnails/GIF previews push this close to zero after first fetch); lever: content-hashed cache keys already maximize hit rate by design
FFmpeg render worker compute (EDL render + burn-in, exports) ≈ $0.002–0.006/min rendered (compute-hours × instance cost, amortized) Only charged for videos with edits/redaction (9.3) or on-demand exports (9.8) — a never-edited, never-exported video incurs zero render-worker cost beyond the one-time probe; lever: -preset slow in 9.8's export commands trades render time (cost) for output quality — a medium preset would cut render compute time roughly 30–40% at a modest quality cost, available as a future lever if export volume grows faster than anticipated

Primary cost levers, summarized:

  1. Rung count (9.4) — resolution-gated rung selection already avoids paying to encode/store rungs above source resolution; a further lever is capping Free-plan ingest resolution itself (independent of this section's scope — a capture-time or upload-time resolution cap would need to be a product decision, not assumed here).
  2. Render-on-demand posture (9.3) — the decision that unedited, unredacted recordings skip the local FFmpeg render pass entirely and go straight to the streaming provider is itself the single largest render-compute cost lever; every edit or redaction re-render is an explicit, user-triggered cost event, not a background cost incurred on every video.
  3. Export TTL and regenerate-on-demand (9.6, 9.7) — 30-day export expiry means Reelay never pays indefinite storage for exports, at the cost of a re-render (cheap, since exports are typically short single-video artifacts) if a user requests the same export again after expiry.
  4. CDN cache-key strategy (9.7) — content-hashed, immutable-TTL caching on posters/thumbnails/GIF previews is the largest cache-hit-rate lever available to Reelay's own CDN spend, independent of the streaming provider's delivery economics.
  5. Storage class tiering (9.6) — Intelligent-Tiering on originals/ and time-based Standard-IA transitions elsewhere trade a small monitoring/API-call overhead for meaningfully lower steady-state storage cost on the long tail of rarely-accessed older recordings, which — given Business plan's unlimited retention (Section 21) — is expected to be the dominant storage cost pool at scale.

10. AI Auto-Editing Engine — Zoom, Motion & Backgrounds #

10.1 Design goals and the quality bar #

The auto-editing engine turns a raw screen recording plus its cursor telemetry into a directed, zoomed, framed edit — the "someone edited this for me" result — without a human touching a timeline. The engine is a deterministic solver, not a generative model: it consumes structured signal (interest events, audio energy, video metadata) and produces structured output (an EditDecisionList, Section 11.1). No frame of pixels is altered by Section 10 — it only decides where the virtual camera looks and when.

The quality bar is stated as measurable properties, each independently testable:

Property Measurable definition Target
Motion smoothness Second derivative (jerk) of the camera center position, normalized to source resolution Peak jerk ≤ 0.08 (normalized units, Section 10.6) on 95% of zoom transitions
Motion settle time Time from zoom-segment start to camera center within 2% of target position ≤ 550 ms for a critically damped spring at ω₀ = 9.0 rad/s (Section 10.6)
Cursor jitter suppression Residual high-frequency energy in smoothed cursor path vs. raw path, measured as RMS of frame-to-frame delta above 8 Hz ≥ 70% reduction vs. raw telemetry
Zoom relevance Fraction of recording time spent zoomed that overlaps a detected interest event ± its lead-in/ease-out window ≥ 95% (i.e. ≤ 5% of zoomed time is "unexplained" zoom)
Framing safety Fraction of rendered frames where the tracked target point falls outside the inner 60% safe rectangle (Section 10.8) after the hysteresis band has had 300 ms to react 0% — this is a hard invariant, not a target with tolerance
Determinism Byte-identical render given identical source, telemetry, and preset version 100% — hard invariant (Section 10.11)
Solve latency Wall-clock time to produce a complete EDL from telemetry, per hour of recording < 5 s (Section 10.14)

These numbers are the acceptance criteria for the algorithms in the rest of Section 10. Any implementation change that regresses a measured value below its target is a defect, not a style choice.

These targets hold for every preset, not just a default one, because the constants that determine them — ω₀ and ζ for the camera spring (10.6), the One-Euro filter parameters and the 2px anti-jitter deadzone (10.7), and the safe-rect/hysteresis percentages (10.8) — are engine-wide and fixed. A preset (10.5, 10.9) is permitted to vary exactly two fields, zoom.defaultLevel and zoom.maxLevel; it cannot touch the physics. This is why the jerk and settle-time targets above can be stated as single numbers rather than a range per preset.

10.2 Input contract #

The auto-edit solver consumes three inputs, all read-only:

  1. Cursor telemetry stream — the record format, sampling rates (120 Hz desktop, 60 Hz degraded browser fallback), and serialization defined in Section 8.6. The solver treats each telemetry record as { tSourceMs, x, y, displayId, eventType, button, modifierKeys, scrollDeltaX, scrollDeltaY, activeWindowRect, cursorShapeId }, exactly as Section 8.6 specifies it, ordered ascending by tSourceMs and scoped to one recording's source timeline. Browser-sourced telemetry carries a degraded: true flag (Section 8.6); the solver widens its coalescing window (Section 10.4) and lowers confidence scores (Section 10.3) when this flag is set, because 60 Hz sampling and pointer-events-over-page-only capture cannot report clicks over non-page UI, drag precision is lower, and window-focus-change events are unavailable outside the captured tab.
  2. Source video metadata — produced by the probe step of the media pipeline (Section 9): widthPx, heightPx, frameRate, durationMs, sampleAspectRatio, and the per-display geometry map (displayId → { widthPx, heightPx, originX, originY, scaleFactor }) for multi-monitor recordings. The solver never reads pixels at this stage — only geometry.
  3. Audio energy envelope — computed once per recording by the worker (Section 10.14) from the extracted audio track (Section 9): RMS energy in dBFS at a fixed 100 ms hop size, stored as a Float32Array of durationMs / 100 values. This feeds typing-burst confirmation (10.3) and is independent of the transcript (Section 12), which is not yet available when auto-edit first runs.

The solver does not read or write any other entity. It has no access to the transcript, chapters, or redaction regions — those are edited independently in the timeline editor (Section 11) after the first EDL exists.

10.3 Interest event detection #

An interest event is a discrete, timestamped signal that the viewer's attention should be directed somewhere. Every event has a type, a tSourceMs (or a [startMs, endMs] range for sustained events), a targetPoint or targetRect in source pixel coordinates, a weight (relative importance, used for zoom-level scaling in 10.5), and a confidence (0–1, detector certainty).

Event type Detected from Weight Confidence formula
click eventType = 'mousedown' followed by 'mouseup' within 250 ms at the same point (± 4 px) 1.0 1.0 on desktop telemetry; 0.7 on degraded browser telemetry (no distinction from a drag start until mouseup resolves it)
double_click Two click events at the same point (± 6 px) within 400 ms 1.4 1.0 desktop / 0.75 degraded
right_click eventType = 'mousedown', button = 'right' 1.1 1.0 desktop / 0.6 degraded (many browsers suppress the native context-menu event from page-level listeners)
drag_start mousedown followed by ≥ 3 consecutive samples with cumulative movement > 12 px before mouseup 0.9 1.0 desktop / 0.65 degraded
drag_end mouseup that terminates an active drag_start 0.9 inherits the paired drag_start confidence
typing_burst_start First keydown after ≥ 600 ms with no keydown, followed by ≥ 5 keydown events with inter-key gaps ≤ 350 ms 1.2 0.9 base; +0.1 if the audio energy envelope (10.2) shows a coincident increase of ≥ 6 dBFS within ± 150 ms (mechanical keyboard confirmation), capped at 1.0
typing_burst_end First keydown gap > 900 ms after a typing_burst_start, or window/element blur 1.2 inherits typing_burst_start confidence
scroll_start First scrollDelta sample with magnitude > 0 after ≥ 400 ms of no scroll 0.6 1.0 desktop / 0.8 degraded
scroll_stop Last scrollDelta sample before ≥ 400 ms of no further scroll 0.6 inherits scroll_start confidence
window_focus_change activeWindowRect identity changes between consecutive samples (desktop only — unavailable in browser capture per Section 8.4) 1.3 1.0 desktop / not emitted in degraded mode
dwell Cursor position stays within a 20 px radius for ≥ 1500 ms with no click/scroll/key events 0.5 0.85, scaled down by -0.1 per 500 ms beyond the first 1500 ms (a very long dwell is more likely the user stepped away than that the content is interesting) — floor 0.4
region_of_change (frame-differencing fallback) See below 0.7 0.5 base, +0.2 if the changed region area is between 2% and 25% of frame area (too small is noise, too large is a full scene change / scroll), floor 0.3, cap 0.9

Frame-differencing fallback. Browser recordings without real telemetry (no desktop app, and the page-level pointer-event listener was blocked or the recording captured a tab other than the active one) fall back to a vision-only detector. The worker samples decoded frames at 2 fps, computes an absolute luma difference against the previous sampled frame after a 3×3 box blur (noise suppression), thresholds at a per-pixel delta of 18 (0–255 scale), and finds connected components of changed pixels using 4-connectivity flood fill. Each connected component with bounding-box area ≥ 0.5% of frame area becomes a region_of_change event with targetRect set to that bounding box (padded 8% on each side) and tSourceMs set to the sampled frame's timestamp. This fallback runs only when the telemetry stream for the recording is empty or 100% degraded with < 1 real pointer event per 10 seconds of recording — real telemetry, even degraded, is always preferred because it carries intent (a click) rather than mere motion.

interface InterestEvent {
  type:
    | "click" | "double_click" | "right_click"
    | "drag_start" | "drag_end"
    | "typing_burst_start" | "typing_burst_end"
    | "scroll_start" | "scroll_stop"
    | "window_focus_change" | "dwell" | "region_of_change";
  tSourceMs: number;
  endMs?: number;              // present for range-shaped events (typing bursts, drags, dwell)
  targetPoint?: { x: number; y: number };   // source pixel coordinates
  targetRect?: { x: number; y: number; w: number; h: number };
  displayId: string;
  weight: number;
  confidence: number;          // 0..1
  source: "telemetry" | "frame-diff";
}

10.4 Event coalescing #

Raw interest events over-produce: a single "user fills out a form" interaction might emit a dozen click, typing_burst_start/end, and scroll events in three seconds. These are merged into zoom segments before the zoom timeline (10.5) is built.

Clustering algorithm. Events are sorted by tSourceMs (using endMs for the cluster's extent where present). A single forward pass merges an event into the current cluster if its tSourceMs (or, for range events, its start) falls within 900 ms of the current cluster's latest boundary time (endMs if the last event in the cluster is a range event, else tSourceMs). If it does not fall within the window, the current cluster closes and a new one opens.

interface EventCluster {
  events: InterestEvent[];
  startMs: number;
  endMs: number;
  displayId: string;
  targetRect: { x: number; y: number; w: number; h: number }; // union, see 10.5
  aggregateWeight: number;
  aggregateConfidence: number;
  density: number; // events per second within [startMs, endMs]
}

const COALESCE_WINDOW_MS = 900;

function coalesceEvents(events: InterestEvent[]): EventCluster[] {
  const sorted = [...events].sort((a, b) => a.tSourceMs - b.tSourceMs);
  const clusters: EventCluster[] = [];
  let current: InterestEvent[] = [];
  let boundaryMs = -Infinity;

  const flush = () => {
    if (current.length === 0) return;
    clusters.push(buildCluster(current));
    current = [];
  };

  for (const ev of sorted) {
    const evStart = ev.tSourceMs;
    if (current.length > 0 && evStart - boundaryMs > COALESCE_WINDOW_MS) {
      flush();
    }
    // Events on a different display never coalesce, even within the window —
    // a zoom segment cannot straddle two physical displays.
    if (current.length > 0 && current[0].displayId !== ev.displayId) {
      flush();
    }
    current.push(ev);
    boundaryMs = Math.max(boundaryMs, ev.endMs ?? ev.tSourceMs);
  }
  flush();
  return clusters;
}

function buildCluster(events: InterestEvent[]): EventCluster {
  const startMs = Math.min(...events.map((e) => e.tSourceMs));
  const endMs = Math.max(...events.map((e) => e.endMs ?? e.tSourceMs));
  const durationS = Math.max(0.001, (endMs - startMs) / 1000);
  const aggregateWeight = events.reduce((s, e) => s + e.weight * e.confidence, 0);
  const aggregateConfidence =
    events.reduce((s, e) => s + e.confidence, 0) / events.length;
  return {
    events,
    startMs,
    endMs,
    displayId: events[0].displayId,
    targetRect: unionTargetRect(events),   // union of all targetPoint/targetRect, each point treated as a 1px rect
    aggregateWeight,
    aggregateConfidence,
    density: events.length / durationS,
  };
}

Worked example 1 — a form fill. Telemetry yields: click at 1000 ms, typing_burst_start at 1120 ms / typing_burst_end at 2400 ms, click at 2600 ms, typing_burst_start at 2700 ms / typing_burst_end at 4100 ms. Gaps between consecutive boundaries: 1120 − 1000 = 120 ms (merge); 2600 − 2400 = 200 ms (merge); 2700 − 2600 = 100 ms (merge). Result: one cluster, startMs = 1000, endMs = 4100, aggregateWeight = 1.0 + 1.2 + 1.0 + 1.2 = 4.4 (confidences at 1.0 assumed), density = 4 / 3.1 ≈ 1.29 events/s.

Worked example 2 — two unrelated clicks. click at 5000 ms, click at 6200 ms. Gap = 1200 ms > 900 ms → two separate clusters, each becoming its own zoom segment candidate.

Worked example 3 — a scroll then a click just outside the window. scroll_start at 10,000 ms, scroll_stop at 10,600 ms, click at 11,550 ms. Gap from cluster boundary (10,600) to the click (11,550) = 950 ms > 900 ms → two clusters: [10000, 10600] and [11550, 11550].

10.5 The zoom timeline #

Each EventCluster from 10.4 is a candidate zoom segment. The timeline builder converts candidates into the final ordered, non-overlapping list of ZoomSegment entries that make up the zoomTrack in the EDL (Section 11.1).

Constants (all are engine defaults, fixed engine-wide. A preset overrides exactly two of the rows below — zoom.defaultLevel and zoom.maxLevel — and nothing else. Minimum zoom level, minimum hold duration, minimum gap, lead-in, and ease-out are not stored per preset, are not exposed to preset authoring, and cannot be overridden by any preset; they are fixed engine-wide so that the smoothness and settle-time properties measured in 10.1 hold identically no matter which preset produced a given zoom segment):

Constant Value
Default zoom level 1.6x
Maximum zoom level 2.5x
Minimum zoom level 1.0x (i.e. no zoom — a segment never renders below source scale)
Minimum hold duration 1200 ms (time spent at/near target zoom, excluding lead-in/ease-out)
Minimum gap between zoom segments 800 ms (below this, adjacent segments merge — see below)
Lead-in 400 ms (camera begins moving toward target before the triggering event's tSourceMs)
Ease-out 600 ms (camera begins returning toward 1.0x after the segment's last covered event)

Zoom-level formula. The zoom level for a segment scales with the cluster's aggregate confidence and event density, both saturating so that a very dense or very confident cluster does not exceed the maximum:

zoomLevel = clamp(
  DEFAULT_ZOOM
    + (MAX_ZOOM - DEFAULT_ZOOM) * confidenceFactor * densityFactor,
  MIN_ZOOM,
  MAX_ZOOM
)

confidenceFactor = clamp((aggregateConfidence - 0.5) / 0.5, 0, 1)   // 0 at confidence 0.5, 1 at confidence 1.0
densityFactor     = clamp(density / 2.0, 0, 1)                       // 0 at 0 events/s, 1 at ≥2 events/s

At aggregateConfidence = 1.0 and density ≥ 2.0: zoomLevel = 1.6 + 0.9 * 1 * 1 = 2.5 (maximum). At aggregateConfidence = 0.5 or density = 0: zoomLevel = 1.6 (default — a single, unhurried, moderately confident click zooms to the default level, never below it, because any qualifying interest event is worth framing at the default). A cluster is only excluded from producing a segment at all if it fails the minimum-weight admission threshold (aggregateWeight < 0.4) — this filters isolated low-confidence dwell or region_of_change events that should not trigger a zoom.

const DEFAULT_ZOOM = 1.6;
const MAX_ZOOM = 2.5;
const MIN_ZOOM = 1.0;
const MIN_HOLD_MS = 1200;
const MIN_GAP_MS = 800;
const LEAD_IN_MS = 400;
const EASE_OUT_MS = 600;
const MIN_CLUSTER_WEIGHT = 0.4;

function zoomLevelForCluster(c: EventCluster): number {
  const confidenceFactor = clamp((c.aggregateConfidence - 0.5) / 0.5, 0, 1);
  const densityFactor = clamp(c.density / 2.0, 0, 1);
  const level =
    DEFAULT_ZOOM + (MAX_ZOOM - DEFAULT_ZOOM) * confidenceFactor * densityFactor;
  return clamp(level, MIN_ZOOM, MAX_ZOOM);
}

function clamp(v: number, lo: number, hi: number): number {
  return Math.min(hi, Math.max(lo, v));
}

Building the timeline.

  1. Discard clusters with aggregateWeight < MIN_CLUSTER_WEIGHT.
  2. For each remaining cluster, compute a raw segment: rawStartMs = cluster.startMs - LEAD_IN_MS, rawEndMs = max(cluster.endMs + EASE_OUT_MS, cluster.startMs - LEAD_IN_MS + MIN_HOLD_MS + LEAD_IN_MS + EASE_OUT_MS) — i.e. the segment is at least long enough to satisfy the minimum hold once lead-in and ease-out are subtracted.
  3. Sort raw segments by rawStartMs.
  4. Overlap and gap resolution, single left-to-right pass: for each pair of consecutive segments, if next.rawStartMs - previous.rawEndMs < MIN_GAP_MS, the two segments merge into one: the merged segment spans previous.rawStartMs to next.rawEndMs, its targetRect is the union of both clusters' target rects, and its zoom level is max(previous.zoomLevel, next.zoomLevel) (the more urgent zoom wins rather than being averaged away). This applies whether the segments genuinely overlap (next.rawStartMs < previous.rawEndMs) or merely fall inside the 800 ms minimum gap — both cases produce one continuous camera move instead of a jarring zoom-out/in pair less than a second apart.
  5. Repeat step 4 until no adjacent pair violates the gap rule (a merge can create a new violation with the following segment).
  6. Each surviving segment's targetRect is passed through the safe-area / resolution guard before being finalized.

The safe-area / resolution guard. A zoom segment's crop rectangle is computed from targetRect at the chosen zoomLevel: cropWidth = sourceWidthPx / zoomLevel, cropHeight = sourceHeightPx / zoomLevel, centered on targetRect's centroid, then clamped so the crop never extends past [0, sourceWidthPx] × [0, sourceHeightPx] (shift the center, do not shrink the crop — shrinking would change the zoom level and violate determinism for a given cluster). If, after centroid clamping, the crop still cannot contain at least 90% of targetRect's area (this happens only when targetRect itself is larger than the crop — e.g. a drag spanning most of the screen at 2.5x zoom), the guard reduces zoomLevel in steps of 0.1 until either the crop contains ≥ 90% of targetRect or zoomLevel reaches MIN_ZOOM, at which point the segment is emitted at 1.0x (no zoom, camera stays static at full frame) rather than dropped — a wide interaction is still worth holding on screen, just not worth zooming into.

interface ZoomSegment {
  id: string;
  startMs: number;      // source time, includes lead-in
  endMs: number;         // source time, includes ease-out
  zoomLevel: number;
  targetRect: Rect;      // source pixel coordinates, post safe-area guard
  displayId: string;
  sourceClusterIds: string[];
}

function applySafeAreaGuard(
  targetRect: Rect,
  requestedZoom: number,
  sourceWidthPx: number,
  sourceHeightPx: number
): { zoomLevel: number; cropRect: Rect } {
  let zoomLevel = requestedZoom;
  while (zoomLevel >= MIN_ZOOM) {
    const cropW = sourceWidthPx / zoomLevel;
    const cropH = sourceHeightPx / zoomLevel;
    const cx = targetRect.x + targetRect.w / 2;
    const cy = targetRect.y + targetRect.h / 2;
    let cropX = clamp(cx - cropW / 2, 0, sourceWidthPx - cropW);
    let cropY = clamp(cy - cropH / 2, 0, sourceHeightPx - cropH);
    const cropRect = { x: cropX, y: cropY, w: cropW, h: cropH };
    if (coverageRatio(cropRect, targetRect) >= 0.9 || zoomLevel === MIN_ZOOM) {
      return { zoomLevel, cropRect };
    }
    zoomLevel = Math.max(MIN_ZOOM, Math.round((zoomLevel - 0.1) * 10) / 10);
  }
  // Unreachable: loop always returns at zoomLevel === MIN_ZOOM.
  throw new Error("safe-area guard did not converge");
}

function coverageRatio(crop: Rect, target: Rect): number {
  const ix = Math.max(0, Math.min(crop.x + crop.w, target.x + target.w) - Math.max(crop.x, target.x));
  const iy = Math.max(0, Math.min(crop.y + crop.h, target.y + target.h) - Math.max(crop.y, target.y));
  const intersectArea = ix * iy;
  const targetArea = target.w * target.h;
  return targetArea === 0 ? 1 : intersectArea / targetArea;
}

Overlapping interest clusters. When two clusters' raw segments overlap in time but target different, non-adjacent screen regions (e.g. a click on the far left while a typing burst continues on the far right on an ultrawide display — telemetry can report events milliseconds apart at different points), the merge in step 4 still applies: the union targetRect is used, which widens the crop and lowers the effective zoom via the safe-area guard rather than producing two simultaneous, contradictory camera targets. The engine never renders two zoom targets at once — one camera, one path, at all times. This is enforced structurally: ZoomSegment entries in the zoomTrack are stored sorted and non-overlapping (validated in the EDL JSON Schema, Section 11.1).

10.6 Camera motion — the critically damped spring #

The camera's crop-rectangle center moves along a critically damped spring trajectory toward each ZoomSegment's target, not a linear or cubic-bezier tween. A spring is used for three reasons: (1) it has a single, physically meaningful smoothness parameter (ω₀) instead of a hand-tuned easing curve, so the same math produces walk, run, or snap motion by changing one number; (2) it is naturally continuous in velocity across consecutive segments — a linear or bezier tween restarts at zero velocity at every keyframe, producing a visible "stop-start" stutter when zoom segments are close together, while a spring carries momentum through; (3) critical damping (ζ = 1.0) is the unique damping ratio that reaches the target in the minimum time without overshoot — underdamped (ζ < 1) bounces past the target and back, which reads as sloppy; overdamped (ζ > 1) is unnecessarily slow to settle.

The system. The camera center x (applied independently to the horizontal and vertical crop position, and to the zoom scalar, as three independent scalar springs sharing the same ω₀, ζ) obeys the second-order ODE:

x'' + 2ζω₀x' + ω₀²x = ω₀²x_target

With ω₀ = 9.0 rad/s and ζ = 1.0 (critically damped), the closed-form step response reaches 98% of the target displacement in approximately t ≈ 5.8 / ω₀ ≈ 0.64 s, and the property in 10.1 (settle within 2% by 550 ms) is met because the lead-in (400 ms, Section 10.5) begins the motion before the triggering event's timestamp, giving the spring a head start relative to when the viewer's eye reaches the target.

Frame-rate-independent integration. The renderer's dt between frames varies (variable-refresh capture, dropped frames, or a re-render at a different output frame rate — Section 10.11). A fixed per-frame update (x += velocity * frameConstant) would change the physical speed of the camera when dt changes, breaking determinism across renders at different frame rates and violating the settle-time property. The engine instead uses semi-implicit (symplectic) Euler integration, which is stable for stiff springs at large dt (unlike explicit Euler, which diverges for ω₀·dt approaching 2) and is evaluated per-axis, per-frame, from the continuous target (the spring does not know or care what frame rate it is stepped at):

interface SpringState {
  position: number;
  velocity: number;
}

interface SpringParams {
  omega0: number;       // natural frequency, rad/s
  zeta: number;          // damping ratio
  maxVelocity: number;   // clamp, units/s
  maxAcceleration: number; // clamp, units/s^2
}

const CAMERA_SPRING: SpringParams = {
  omega0: 9.0,
  zeta: 1.0,
  // Max pan velocity: 1.4x the source frame's diagonal per second — fast enough to cross the
  // full frame in ~0.7s (matching the 0.64s settle time above) without ever reading as a "whip pan".
  maxVelocity: 1.4,       // in units of "source diagonals per second", applied per-axis after
                           // normalizing axis units to the same diagonal-relative scale
  maxAcceleration: 6.0,   // in units of "source diagonals per second squared"
};

/**
 * Advances one scalar spring (used independently for cropCenterX, cropCenterY, and zoomLevel,
 * each normalized to comparable units before calling) by dt seconds toward `target`.
 * Semi-implicit Euler: velocity is updated from the acceleration at the CURRENT position first,
 * then position is updated using the NEW velocity. This ordering (vs. explicit Euler, which uses
 * the old velocity for both updates) is what makes the integrator stable for a stiff, critically
 * damped spring stepped at real-world variable frame deltas.
 */
function stepSpring(state: SpringState, target: number, dt: number, params: SpringParams): SpringState {
  if (dt <= 0) return state;
  // Clamp dt defensively: a stalled encoder or a paused/resumed capture must never inject a huge
  // dt that would cause the spring to jump discontinuously. Cap at 100ms (10fps-equivalent floor);
  // callers needing to bridge a longer gap must step multiple times.
  const clampedDt = Math.min(dt, 0.1);

  const displacement = state.position - target;
  const springForce = -params.omega0 * params.omega0 * displacement;
  const dampingForce = -2 * params.zeta * params.omega0 * state.velocity;
  let acceleration = springForce + dampingForce;

  acceleration = clampMagnitude(acceleration, params.maxAcceleration);

  let newVelocity = state.velocity + acceleration * clampedDt;
  newVelocity = clampMagnitude(newVelocity, params.maxVelocity);

  const newPosition = state.position + newVelocity * clampedDt;

  return { position: newPosition, velocity: newVelocity };
}

function clampMagnitude(v: number, max: number): number {
  if (v > max) return max;
  if (v < -max) return -max;
  return v;
}

/**
 * Steps all three camera axes (x, y, zoom) for one output frame. Called once per rendered frame
 * by the render worker (Section 9), using the ACTUAL elapsed source-time delta for that frame,
 * so the visual speed of the camera is identical regardless of the render's output frame rate.
 */
function stepCamera(
  camera: { x: SpringState; y: SpringState; zoom: SpringState },
  target: { x: number; y: number; zoom: number },
  dtSeconds: number
): { x: SpringState; y: SpringState; zoom: SpringState } {
  return {
    x: stepSpring(camera.x, target.x, dtSeconds, CAMERA_SPRING),
    y: stepSpring(camera.y, target.y, dtSeconds, CAMERA_SPRING),
    zoom: stepSpring(camera.zoom, target.zoom, dtSeconds, {
      ...CAMERA_SPRING,
      // Zoom uses a slower effective rate: a zoom LEVEL change of 0.9 (1.6x -> 2.5x) should not
      // visually "arrive" faster than a full-frame pan, so its velocity/accel clamps are scaled
      // down proportionally to the smaller numeric range zoom moves in versus a position pan.
      maxVelocity: CAMERA_SPRING.maxVelocity * 0.6,
      maxAcceleration: CAMERA_SPRING.maxAcceleration * 0.6,
    }),
  };
}

The solver (10.14) does not itself run this per-frame — it emits the target keyframes (ZoomSegment boundaries) into the EDL. stepCamera runs in the render worker (Section 9) at render time, evaluated once per output frame using the source-time delta between frames, which is what makes the render byte-identical for a given preset version and output frame rate (Section 10.11): the same sequence of dt values, fed through the same deterministic step function, always produces the same trajectory.

10.7 Cursor smoothing — the One-Euro filter #

Raw cursor telemetry is visually noisy at pixel granularity even from a precise mouse — the auto- edit engine never renders the raw cursor path when a synthetic cursor overlay or a cursor-linked pan is shown. The One-Euro filter (Casiez, Roussel, Vogel, 2012) is used because it is a low-latency filter whose cutoff frequency adapts to signal speed: slow, precise movement gets heavy smoothing (minimizing jitter), fast movement gets light smoothing (minimizing lag), without the fixed lag/ jitter trade-off of a plain low-pass filter.

Parameters:

Parameter Value Effect
minCutoff 1.0 Hz Cutoff frequency at zero speed — lower values mean more smoothing of slow movement
beta 0.007 Speed coefficient — higher values reduce lag during fast movement at the cost of more jitter
derivateCutoff 1.0 Hz Fixed cutoff for the internal velocity-estimate filter
Anti-jitter deadzone 2 px Movements smaller than this, measured frame-to-frame post-filter, are clamped to the previous position entirely — see below

Behavior at the extremes. During a fast flick (e.g. a rapid swipe across the screen), the instantaneous cursor speed drives the effective cutoff frequency up (cutoff = minCutoff + beta * |dx/dt|), so the filter tracks the raw signal closely — lag stays low and the flick still reads as a flick rather than a lazy drift. During slow, precise movement (e.g. hovering over a small UI target before clicking), speed is near zero, the cutoff collapses toward minCutoff, and the filter aggressively damps the sub-pixel tremor inherent in raw mouse/trackpad samples — the cursor appears to hold rock-steady rather than visibly vibrating.

The anti-jitter deadzone is applied after the One-Euro filter, as a final clamp: if the filtered position's Euclidean distance from the previous rendered position is < 2 px, the previous rendered position is reused unchanged. This eliminates the last-mile sub-pixel "breathing" that a frequency-domain filter alone cannot fully remove, at the cost of up to 2 px of positional lag — imperceptible at any zoom level ≤ 2.5x on a source recorded at typical desktop resolutions (1920×1080 or higher).

interface LowPassState {
  hasLastValue: boolean;
  lastValue: number;
}

function lowPassFilter(state: LowPassState, value: number, alpha: number): number {
  const filtered = state.hasLastValue
    ? alpha * value + (1 - alpha) * state.lastValue
    : value;
  state.hasLastValue = true;
  state.lastValue = filtered;
  return filtered;
}

function smoothingAlpha(cutoffHz: number, dt: number): number {
  const tau = 1 / (2 * Math.PI * cutoffHz);
  return 1 / (1 + tau / dt);
}

interface OneEuroState {
  x: LowPassState;
  dx: LowPassState;
  lastRawValue: number | null;
}

interface OneEuroParams {
  minCutoff: number;   // Hz
  beta: number;
  derivateCutoff: number; // Hz
}

const CURSOR_ONE_EURO: OneEuroParams = {
  minCutoff: 1.0,
  beta: 0.007,
  derivateCutoff: 1.0,
};

function stepOneEuro(state: OneEuroState, value: number, dt: number, params: OneEuroParams): number {
  if (dt <= 0) return state.x.hasLastValue ? state.x.lastValue : value;

  const rawDerivative =
    state.lastRawValue === null ? 0 : (value - state.lastRawValue) / dt;
  state.lastRawValue = value;

  const dAlpha = smoothingAlpha(params.derivateCutoff, dt);
  const edx = lowPassFilter(state.dx, rawDerivative, dAlpha);

  const cutoff = params.minCutoff + params.beta * Math.abs(edx);
  const alpha = smoothingAlpha(cutoff, dt);
  return lowPassFilter(state.x, value, alpha);
}

const JITTER_DEADZONE_PX = 2;

interface CursorSmoother {
  filterX: OneEuroState;
  filterY: OneEuroState;
  lastRenderedX: number | null;
  lastRenderedY: number | null;
}

function smoothCursorSample(
  smoother: CursorSmoother,
  rawX: number,
  rawY: number,
  dt: number
): { x: number; y: number } {
  const fx = stepOneEuro(smoother.filterX, rawX, dt, CURSOR_ONE_EURO);
  const fy = stepOneEuro(smoother.filterY, rawY, dt, CURSOR_ONE_EURO);

  if (smoother.lastRenderedX === null || smoother.lastRenderedY === null) {
    smoother.lastRenderedX = fx;
    smoother.lastRenderedY = fy;
    return { x: fx, y: fy };
  }

  const dist = Math.hypot(fx - smoother.lastRenderedX, fy - smoother.lastRenderedY);
  if (dist < JITTER_DEADZONE_PX) {
    return { x: smoother.lastRenderedX, y: smoother.lastRenderedY };
  }

  smoother.lastRenderedX = fx;
  smoother.lastRenderedY = fy;
  return { x: fx, y: fy };
}

Cursor smoothing runs independently of, and downstream from, camera motion (10.6): the camera spring targets the zoom segment's targetRect centroid (a stable region, not a twitchy point signal), while the One-Euro filter smooths the visible synthetic cursor overlay's per-frame position within whatever the camera is currently showing.

10.8 Auto-framing #

The inner 60% safe rectangle. For any active ZoomSegment, the current crop rectangle defines a safe rectangle at 60% of the crop's width and height, centered on the crop's center: safeRect = { x: crop.x + crop.w * 0.2, y: crop.y + crop.h * 0.2, w: crop.w * 0.6, h: crop.h * 0.6 }. The tracked target point (the cursor position, or the centroid of an active drag/typing target) is expected to remain inside safeRect while the crop is static. When telemetry shows the target point about to exit safeRect, the camera spring's target is updated to re-center — this is what "the camera moves" means operationally: the spring target (10.6) is recomputed, not the crop instantaneously.

Hysteresis band. A target sitting exactly on the 60% boundary with small back-and-forth motion would otherwise cause the camera to re-target every frame, producing visible oscillation. A hysteresis band prevents this: the camera re-targets only when the tracked point crosses outward past the outer 68% boundary (safe rect edge + 8% of crop dimension), and once re-targeted, does not re-evaluate for re-centering again until the point has first returned inside the 60% inner boundary. This 8-percentage-point gap between the trigger boundary (68%) and the reset boundary (60%) is the hysteresis band; a point oscillating between 58% and 64% never triggers a re-target, while a point that genuinely moves past 68% and stays there does.

const SAFE_RECT_RATIO = 0.6;
const HYSTERESIS_TRIGGER_RATIO = 0.68;

interface FramingState {
  armed: boolean; // true = eligible to trigger a re-target; false = waiting to return inside safe rect
}

function evaluateFraming(
  point: { x: number; y: number },
  crop: Rect,
  state: FramingState
): { shouldRetarget: boolean } {
  const nx = (point.x - crop.x) / crop.w; // normalized 0..1 within crop
  const ny = (point.y - crop.y) / crop.h;

  const insideSafe =
    nx >= 0.5 - SAFE_RECT_RATIO / 2 && nx <= 0.5 + SAFE_RECT_RATIO / 2 &&
    ny >= 0.5 - SAFE_RECT_RATIO / 2 && ny <= 0.5 + SAFE_RECT_RATIO / 2;
  const outsideTrigger =
    nx < 0.5 - HYSTERESIS_TRIGGER_RATIO / 2 || nx > 0.5 + HYSTERESIS_TRIGGER_RATIO / 2 ||
    ny < 0.5 - HYSTERESIS_TRIGGER_RATIO / 2 || ny > 0.5 + HYSTERESIS_TRIGGER_RATIO / 2;

  if (insideSafe) {
    state.armed = true;
    return { shouldRetarget: false };
  }
  if (outsideTrigger && state.armed) {
    state.armed = false;
    return { shouldRetarget: true };
  }
  return { shouldRetarget: false };
}

Edge and corner clamping. When a re-target would place the new crop such that it extends past the source frame boundary, the same clamping used in the safe-area guard (10.5) applies: the crop center shifts to keep the crop fully within [0, sourceWidthPx] × [0, sourceHeightPx], never shrinking the crop. Near a corner, this means the safe rectangle is no longer centered on the tracked point — the point may sit near the safe rect's edge nearest the frame corner. This is accepted: a target genuinely at the extreme corner of the source frame cannot be both fully centered and fully on-screen at a zoom level > 1.0x without cropping content that does not exist, so the engine prioritizes keeping the crop valid (no black bars, no out-of-bounds sampling) over perfect centering.

Multi-monitor / display-change handling mid-recording. Desktop captures may span a displayId change mid-recording (the user drags a window to a second monitor, or the recording follows active-window focus across displays — Section 8.5). Because a ZoomSegment is scoped to a single displayId (10.5, step 4's merge rule refuses to merge clusters from different displays), a display change always falls on a segment boundary. The camera spring is reset, not interpolated, across a display change: stepCamera receives a fresh SpringState (velocity = 0, position = the new display's default centered crop at 1.0x) rather than continuing to integrate toward a target on a physically different display's coordinate space, which would produce a meaningless trajectory. The reset crop holds for the lead-in duration (400 ms) before the next segment's spring engages, giving the viewer a still frame to reorient rather than a fast, jarring pan across unrelated coordinate spaces.

10.9 Backgrounds and framing #

Background presets wrap the source recording in a styled frame before the zoom/pan crop (10.5–10.8) is composited on top. Every preset is versioned as part of the auto_edit_presets entity's immutable version record (10.11) so that a rendered video's visual style never silently changes.

10.9.1 Preset catalogue #

Preset ID Type Description
gradient-aurora gradient Linear 135°, stops #4F46E5 0%, #7C3AED 50%, #DB2777 100%
gradient-sunset gradient Linear 135°, stops #F97316 0%, #DB2777 60%, #7C3AED 100%
gradient-ocean gradient Linear 160°, stops #0EA5E9 0%, #2563EB 50%, #4F46E5 100%
gradient-mono-slate gradient Linear 180°, stops #1E293B 0%, #0F172A 100%
gradient-mint gradient Linear 135°, stops #10B981 0%, #06B6D4 100%
solid-white solid #FFFFFF
solid-black solid #0A0A0A
solid-slate solid #1E293B
image-custom image User-uploaded still image, cover-fit, center-anchored
blurred-screenshot blurred-screenshot The recording's own first frame, Gaussian blur σ = 40px, 30% darken overlay
desktop-style-macos desktop-style Simulated macOS desktop chrome: menu bar strip (28px, #F5F5F7 at 92% opacity) pinned to top, no dock rendered
desktop-style-windows desktop-style Simulated Windows desktop chrome: taskbar strip (48px, #1F1F1F at 92% opacity) pinned to bottom
none none No background — output is the cropped/zoomed recording only, no padding. Free-tier default (10.13).

Free tier is restricted to solid-white, solid-black, and none (10.13); all other presets require Pro or Business.

10.9.2 Per-preset geometry #

Every preset except none applies the same geometry parameters, only the fill/backdrop differs:

Property Value
Padding 8% of output shorter-edge dimension, applied on all four sides (i.e. the recording content occupies the inner (100 - 16)% region)
Corner radius 18px at 1x output scale (scales proportionally with output resolution — e.g. 36px at 4K export)
Shadow offset x: 0px, y: 12px
Shadow blur 40px
Shadow spread -4px
Shadow color #000000
Shadow opacity 0.35
Inset border 1px solid #FFFFFF at 8% opacity (a subtle edge highlight so the content doesn't look pasted onto the background)
Window-chrome option Off by default; when enabled, adds a simulated title bar (32px, matching desktop-style preset's OS convention if selected, else a neutral gray #E5E5E5 bar) with three non-functional traffic-light/window-control dots, above the content, inside the padded area

desktop-style presets additionally render their chrome strip (menu bar or taskbar) at the true edge of the output frame, outside the padded/shadowed content card, to simulate the content sitting on an actual desktop.

10.9.3 Aspect-ratio conversion #

Supported output aspect ratios: 16:9 (default, matches typical source), 9:16 (vertical, social), 1:1 (square), 4:5 (portrait, social). Conversion is content-aware: it is not a uniform letterbox or a uniform center-crop, it re-solves the zoom timeline (10.5) for the new target aspect ratio.

Policy:

  1. If the target aspect ratio is wider than the source content's active region for a given ZoomSegment (e.g. converting a tall code-editor-focused zoom to 16:9 when the source is naturally narrow at that moment), the engine widens the crop symmetrically around the existing targetRect centroid up to the source frame's actual boundary (subject to the same safe-area guard, 10.5), then pads any remaining aspect-ratio gap with the active background preset rather than stretching or letterboxing with plain black bars — the background is never plain black unless the preset itself is solid-black.
  2. If the target aspect ratio is taller/narrower than the source content at that moment (e.g. converting to 9:16), the engine re-runs zoom-level selection (10.5's formula) biased toward a tighter crop: the effective zoomLevel search increases in the safe-area guard's step-down loop direction reversed — i.e. it increases zoom (in 0.1 steps, same MAX_ZOOM ceiling) until the crop's aspect ratio can be satisfied while still containing ≥ 90% of targetRect, because a narrower frame needs a tighter, not wider, crop to keep the target legible. If even MAX_ZOOM cannot satisfy 90% coverage at the narrower aspect, the crop is centered on targetRect's centroid at MAX_ZOOM and the guard accepts the reduced coverage — total exclusion of the target from frame is never allowed; partial cropping of a wide selection at the edges is.
  3. Letterbox pillars/bars (when policy 1 applies and the background alone cannot fully absorb the gap — e.g. none preset with an extreme aspect mismatch) render as the preset's own fill (background color/gradient/blur), never a hard black bar, except for the none preset, where a flat #000000 bar is the explicit, documented fallback since there is no background fill to draw from.
  4. Each supported aspect ratio's re-solve is cached independently: a video with all four aspect ratios exported has four independently computed (and independently cached, 10.11) zoomTrack variants sharing the same source EventCluster data from 10.4 — coalescing (10.4) and interest detection (10.3) run once per recording; only the zoom-level/crop solve (10.5) and the aspect conversion policy above re-run per target aspect ratio.

10.10 Camera bubble composition #

The camera bubble (the presenter's webcam feed, captured as a separate track per Section 8.7) is composited by the render worker as an overlay on top of the background and zoomed screen content.

Property Values / rule
Shape circle (default) or rounded-square (corner radius 24px at 1x output scale)
Size small (12% of output shorter-edge dimension), medium (18%, default), large (24%) — diameter for circle, edge length for rounded-square
Position One of four corners: bottom-right (default), bottom-left, top-right, top-left, inset 4% of output shorter-edge dimension from both edges
Border 3px solid #FFFFFF at 90% opacity
Shadow Same shadow spec as 10.9.2, scaled to 60% blur/spread since the bubble is a smaller element

Motion and collision avoidance. The bubble's corner position is fixed per-video by user/preset choice — it does not roam the frame. When an active ZoomSegment's crop rectangle, mapped into output-frame coordinates, would place the zoomed content's own subject matter directly beneath the bubble's corner region for more than 1500 ms continuously, the render worker computes an alternate corner: it picks the corner diagonally opposite the currently active one first (maximizing distance from the zoom target's centroid), falling back to whichever of the remaining two corners is geometrically farthest from the zoom target's centroid in output-frame coordinates if the diagonal opposite is itself occupied by another overlay element (there are none in the current product scope, but the rule is defined generally). The bubble cross-fades between corners over 350 ms (opacity 0→1 fade-in at the new position, 1→0 fade-out at the old position, overlapping, not a slide/move animation — a moving bubble competing with the primary camera pan reads as visually busy). Once relocated, the bubble does not move again until the overlap condition re-triggers for another continuous 1500 ms, prevented from oscillating by the same style of hysteresis as 10.8: after a relocation, the bubble is "sticky" at the new corner for a minimum of 3000 ms regardless of overlap state before it is eligible to move again.

10.11 Determinism #

The invariant. Given the same source recording, the same cursor telemetry blob, and the same auto_edit_preset_version, the render worker produces a byte-identical output file (matching render settings — output resolution, aspect ratio, container/codec choice held constant; changing those is a different, explicitly different render request, not a determinism violation).

Why this is achievable. Every stage in 10.3–10.10 is a pure function of its inputs: interest detection (10.3) reads only telemetry and the audio envelope; coalescing (10.4) is a deterministic sort-and-merge; the zoom-level formula and safe-area guard (10.5) are closed-form arithmetic; the camera spring (10.6) is a deterministic integrator stepped on the source frame-time sequence (not wall-clock time, not an async scheduler); the One-Euro filter (10.7) is likewise a pure per-sample function of prior state; auto-framing (10.8) is a deterministic state machine; background and bubble compositing (10.9, 10.10) apply fixed, versioned geometry constants. No stage consults a random-number generator, wall-clock time, or any external service at render time. FFmpeg encode determinism (matching encoder version and settings, Section 9) is a render-worker concern, not an auto-edit concern — Section 10 guarantees the EDL and the camera/framing trajectory are byte-identical; the render worker's encoder configuration (fixed preset, fixed thread count where the codec's multi-threading is otherwise non-deterministic) extends that guarantee to the final video bytes.

Preset versioning. auto_edit_presets rows are immutable once published: a preset's id (e.g. gradient-aurora) is stable, but its full parameter set — 10.9.2 geometry, 10.10 bubble defaults, and its zoom.defaultLevel/zoom.maxLevel values (10.5, and only those two zoom fields, per 10.1 and 10.5) — is snapshotted under an integer version column on first publish and every subsequent tuning change. The camera-spring, cursor-filter, and auto-framing constants (10.6–10.8) are not part of any preset's parameter set: they are engine-wide constants shared identically by every preset and every preset version, which is what keeps the 10.1 quality-bar targets valid without being restated per preset. If the engine's own tuning of those constants ever changes, that is an engine-wide release, not a preset version bump, and it affects every preset uniformly rather than being attributable to any one auto_edit_preset_version. A video records the exact auto_edit_preset_version used to produce its current EDL (stored on the edit_decision_lists row, Section 11.1) at the time auto-edit last ran on it.

What happens when a new preset version ships: nothing happens to existing videos. An existing video's EDL keeps referencing the preset version it was created with; its render is untouched and remains byte-identical to itself indefinitely. The new version becomes the default for new recordings and for any video where the user explicitly clicks "re-run auto-edit" (which creates a new EDL revision, Section 11.9, referencing the new version — this is an explicit user action, never automatic). This is what makes determinism meaningful as a promise: "this video will never change its look under you" holds even as the product's auto-edit tuning evolves.

10.12 Handoff to the timeline editor #

Auto-edit's output is not pixels. It is an EditDecisionList — the schema is defined and owned by Section 11.1 — populated with a zoomTrack (the ZoomSegment list from 10.5, carrying the camera spring's resolved keyframes), the selected background settings (10.9), the cameraBubble settings (10.10), and metadata (autoEditPresetVersion, sourceTelemetryBlobId, generatedAt). This EDL is written as a new row referencing the video; the original recording (recordings / media_assets, Section 5) is never modified, re-encoded, or deleted by auto-edit. A user opening the timeline editor (Section 11) sees this EDL pre-populated and can adjust, delete, or add to any zoom segment, background choice, or bubble setting using the same editing operations (Section 11.4) as any manually-created edit — auto-edit output is not a separate, less-editable class of data. Re-running auto-edit on a video that already has manual edits prompts the user (this is a UI/product-flow decision owned by the editor, Section 11) rather than silently discarding manual work.

10.13 Per-plan behavior #

Capability Free Pro / Business
Zoom-to-cursor engine (10.3–10.8) Not available — recordings render at fixed 1.0x, no camera motion Full engine
Background presets solid-white, solid-black, none only (10.9.1) Full catalogue
Camera bubble composition Available (bubble is a capture-time feature, Section 8.7, not gated) with small/medium size only Full size/shape/position options
Aspect-ratio re-solve (10.9.3) 16:9 only (source aspect, no conversion) 16:9, 9:16, 1:1, 4:5
Re-run auto-edit with a newer preset version Not available Available, unlimited

This matches the plan table's "AI auto-editing (zoom/motion/bg): basic presets only" row for Free and "full" for Pro/Business. Enforcement is server-side in the auto-edit job handler (10.14): a Free- tier job request for the full engine is rejected with error code plan_limit_exceeded (Section 7.6 envelope) before the job is enqueued, never silently downgraded after the fact.

10.14 Performance #

Target: the solver (interest detection through zoom-timeline construction, 10.3–10.5; the camera spring and cursor filter, 10.6–10.7, are evaluated at render time by the render worker, not by the solver) must process one hour of telemetry (at 120 Hz, ≈ 432,000 samples) and produce a complete EDL in under 5 seconds of wall-clock time.

Where it runs. The solver runs as a BullMQ job (video.autoedit.solve, Section 9) on apps/worker, never inline in an API request and never in the browser/desktop client. It is CPU- bound, single-pass over sorted telemetry, with no external network calls (the audio envelope, 10.2, is precomputed by an earlier pipeline stage and read from object storage, not recomputed inline).

Memory bounds. The job holds the full telemetry stream for one recording in memory at once (never streamed in chunks — a one-hour 120 Hz stream at the Section 8.6 record format is on the order of tens of megabytes, well within a worker process's per-job budget) plus the derived InterestEvent and EventCluster arrays, which are strictly smaller than the input. The job's worker process enforces a 512 MB per-job memory ceiling (BullMQ job-level resource tracking, Section 9); a job that exceeds this is killed and retried per the standard job retry policy (Section 9) up to the standard max-attempts, then routed to the dead-letter queue.

Degradation path. If the solve does not complete within 5 seconds (measured, not estimated — a hard timeout set at 15 seconds, 3x target, to absorb worker contention without masking a real regression), the job fails with a retryable error. After the standard retry budget (Section 9) is exhausted, the job's fallback is to emit a minimal EDL: zoomTrack empty (no zoom, static 1.0x throughout), background set to the plan-appropriate default (10.13), cameraBubble set to capture- time defaults. The video is marked ready with this minimal EDL rather than left stuck in processing indefinitely — a video with no auto-edit applied is a acceptable degraded outcome; a video the user can never open is not. The workspace's audit/notification system (Section 24) logs this fallback for operational visibility; the user is not shown an error, since a plain recording is still a fully usable product outcome.

10.15 Worked end-to-end example #

Input: 90 seconds (90,000 ms) of desktop telemetry (120 Hz, real, non-degraded), source 1920×1080 at 30fps, single display disp_1. The recording shows: a click at 2000 ms, a typing burst from 2150–4800 ms in a text field near the click, a 3-second pause, a drag from 9000–9800 ms resizing a panel, then a double-click at 15,200 ms opening a dialog near the top-right of the screen, then nothing else of note until the recording ends at 90,000 ms.

Step 1 — interest events (10.3):

[
  { "type": "click", "tSourceMs": 2000, "targetPoint": {"x": 640, "y": 420}, "displayId": "disp_1", "weight": 1.0, "confidence": 1.0, "source": "telemetry" },
  { "type": "typing_burst_start", "tSourceMs": 2150, "endMs": 4800, "targetPoint": {"x": 640, "y": 420}, "displayId": "disp_1", "weight": 1.2, "confidence": 0.9, "source": "telemetry" },
  { "type": "typing_burst_end", "tSourceMs": 4800, "targetPoint": {"x": 640, "y": 420}, "displayId": "disp_1", "weight": 1.2, "confidence": 0.9, "source": "telemetry" },
  { "type": "drag_start", "tSourceMs": 9000, "targetPoint": {"x": 960, "y": 540}, "displayId": "disp_1", "weight": 0.9, "confidence": 1.0, "source": "telemetry" },
  { "type": "drag_end", "tSourceMs": 9800, "targetPoint": {"x": 1100, "y": 540}, "displayId": "disp_1", "weight": 0.9, "confidence": 1.0, "source": "telemetry" },
  { "type": "double_click", "tSourceMs": 15200, "targetPoint": {"x": 1620, "y": 180}, "displayId": "disp_1", "weight": 1.4, "confidence": 1.0, "source": "telemetry" }
]

Step 2 — coalescing (10.4): click (2000) and typing_burst_start (2150) are 150 ms apart → merge. typing_burst_end (4800) closes that cluster's boundary. drag_start (9000) is 4200 ms after the previous boundary (4800) → new cluster. drag_end (9800) merges into the drag cluster (0 ms gap). double_click (15,200) is 5400 ms after the drag cluster's boundary (9800) → new cluster. Result: three clusters.

Cluster Span Events Aggregate weight Aggregate confidence Density
A 2000–4800 ms click, typing_burst_start, typing_burst_end 1.0(1.0) + 1.2(0.9) + 1.2(0.9) = 3.16 (1.0+0.9+0.9)/3 = 0.933 3 / 2.8 ≈ 1.07
B 9000–9800 ms drag_start, drag_end 0.9(1.0) + 0.9(1.0) = 1.8 1.0 2 / 0.8 = 2.5
C 15200–15200 ms double_click 1.4(1.0) = 1.4 1.0 1 / 0.001 → treated as density cap, see below

For a zero-duration cluster (a single instant event, C), density is defined as events.length / max(0.001, durationS), which produces a very large number; densityFactor (10.5) clamps this at 1.0 via clamp(density / 2.0, 0, 1), so an instantaneous high-confidence event still reaches maximum density factor — this is intentional: a single confident double-click deserves a strong zoom just as much as a sustained dense interaction.

Step 3 — zoom levels (10.5):

  • Cluster A: confidenceFactor = clamp((0.933-0.5)/0.5, 0, 1) = 0.866, densityFactor = clamp(1.07/2.0, 0, 1) = 0.535. zoomLevel = 1.6 + 0.9 * 0.866 * 0.535 ≈ 1.6 + 0.417 = 2.0172.0x (rounded to nearest 0.1x for the worked example; the engine itself keeps full float precision).
  • Cluster B: confidenceFactor = 1.0, densityFactor = clamp(2.5/2.0, 0, 1) = 1.0. `zoomLevel = 1.6
    • 0.9 * 1.0 * 1.0 = 2.5` → 2.5x (maximum).
  • Cluster C: confidenceFactor = 1.0, densityFactor = 1.0 (capped). zoomLevel = 2.52.5x.

Step 4 — segment boundaries (lead-in 400 ms, ease-out 600 ms, minimum hold 1200 ms):

  • Segment A: raw start 2000 - 400 = 1600; raw end `max(4800+600, 1600+1200+400+600) = max(5400,
    1. = 5400`. Final: 1600–5400 ms, zoom 2.0x.
  • Segment B: raw start 9000-400=8600; raw end max(9800+600, 8600+2200)=max(10400,10800)=10800. Final: 8600–10800 ms, zoom 2.5x.
  • Segment C: raw start 15200-400=14800; raw end max(15200+600, 14800+2200)=max(15800, 17000)=17000. Final: 14800–17000 ms, zoom 2.5x.

Gap check: B starts (8600) − A ends (5400) = 3200 ms > 800 ms minimum gap → no merge. C starts (14800) − B ends (10800) = 4000 ms > 800 ms → no merge. All three segments stand independently.

Step 5 — safe-area guard: all three target rects (single points padded to a nominal 40×40 px interaction footprint) fit comfortably within their computed crops at the given zoom levels on a 1920×1080 source with target points away from the extreme edges — no zoom-level reduction triggered for A or B. Segment C's target at (1620, 180) is near the top-right: crop at 2.5x is 768×432, centered at (1620,180) would need x ∈ [1236, 2004], which exceeds sourceWidthPx=1920 by 84px on the right — the crop center shifts left to x=1920-768=1152 (crop x ∈ [1152,1920]), and similarly y clamps to [0, 432] since 180 - 216 = -36 < 0. Coverage of the 40×40 target rect remains 100% after the shift (the shift only moves the crop, doesn't shrink it), so no zoom reduction is needed.

Complete EDL zoom track (JSON), matching the schema owned by Section 11.1:

{
  "zoomTrack": [
    {
      "id": "zoom_01J8X7K2QYB3ZC9WQZKZ6XQJ5A",
      "startMs": 1600,
      "endMs": 5400,
      "zoomLevel": 2.02,
      "targetRect": { "x": 460, "y": 300, "w": 360, "h": 240 },
      "displayId": "disp_1",
      "sourceClusterIds": ["cluster_a"]
    },
    {
      "id": "zoom_01J8X7K2QY6R7VXHN3F4T9P2WM",
      "startMs": 8600,
      "endMs": 10800,
      "zoomLevel": 2.5,
      "targetRect": { "x": 900, "y": 480, "w": 260, "h": 120 },
      "displayId": "disp_1",
      "sourceClusterIds": ["cluster_b"]
    },
    {
      "id": "zoom_01J8X7K2QYD1H8N4M5R6S7T8UV",
      "startMs": 14800,
      "endMs": 17000,
      "zoomLevel": 2.5,
      "targetRect": { "x": 1152, "y": 0, "w": 768, "h": 432 },
      "displayId": "disp_1",
      "sourceClusterIds": ["cluster_c"]
    }
  ],
  "background": { "presetId": "gradient-aurora", "presetVersion": 3 },
  "cameraBubble": { "enabled": true, "shape": "circle", "size": "medium", "position": "bottom-right" },
  "autoEditPresetVersion": 3,
  "sourceTelemetryBlobId": "tel_01J8X7K1ZQK6R2N3M4P5Q6R7ST",
  "generatedAt": "2026-08-19T14:32:07.000Z"
}

From 17,000 ms to the recording's end at 90,000 ms, no further interest events occur, so the zoomTrack has no further entries — the camera spring (10.6) eases back to and holds at 1.0x (full frame) for the remainder, per its ease-out (600 ms, already included in segment C's endMs) and its natural settle behavior with no further target changes.


11. Timeline Editor & Edit Decision List #

11.1 The EDL schema #

The EditDecisionList (EDL) is the single authoritative, editable representation of everything that has been done to a video beyond the raw recording. It is owned entirely by this section: every other section that produces or consumes edit data (Section 10's auto-edit output, Section 12's caption track reference, Section 14's redaction burn-in) references these types rather than defining its own.

Why every operation references source time, never rendered time. The EDL's clips array is the only place rendered-timeline time exists, and it is derived, not stored as an editable property of any other track. Every other track (zoomTrack, redactionRegions, caption cues via reference, speed ramps) stores ranges in sourceStartMs/sourceEndMs — positions on the original, immutable recording. This is because rendered time is a moving target: inserting, trimming, or ripple-deleting one clip shifts the rendered position of everything after it (11.4), and speed ramps (11.4) non-linearly warp the mapping between source and rendered time. If zoom segments or redaction regions were stored in rendered time, every edit anywhere earlier in the timeline would require rewriting every later track's timestamps — an expensive, error-prone cascade. Anchoring every track to source time means an edit to the clips array never requires touching any other track; the rendered position of a zoom segment or redaction region is computed on demand by walking the clips array (11.3's timeline model) and is never persisted.

Top-level document:

interface EditDecisionList {
  id: string;                  // edl_<base58>, primary key
  videoId: string;              // FK to videos
  version: number;               // monotonic, incremented on every saved revision (11.9)
  documentVersion: 1;             // EDL SCHEMA version (this document defines schema v1) — distinct
                                   // from `version` above, which tracks revisions of one video's edits
  clips: Clip[];
  zoomTrack: ZoomSegment[];        // defined in Section 10.5; referenced here as the owned schema
  cameraBubbleTrack: CameraBubbleKeyframe[];
  background: BackgroundSettings;
  redactionRegions: RedactionRegion[];
  captionTrackRef: { transcriptId: string; captionEditsApplied: boolean } | null; // Section 12 owns transcript/caption content
  chapterMarkers: ChapterMarker[];
  audioAdjustments: AudioAdjustment[];
  speedRamps: SpeedRamp[];
  autoEditPresetVersion: number | null;  // null if no auto-edit has ever run on this video
  createdAt: string;   // ISO 8601 UTC
  updatedAt: string;
}

interface Rect {
  x: number;
  y: number;
  w: number;
  h: number;
}

/**
 * A Clip is a reference into the SOURCE recording's timeline. The ordered array of clips IS the
 * rendered timeline: rendered time 0 is clips[0]'s first included source frame, and each
 * subsequent clip's rendered start is the running sum of all prior clips' rendered durations
 * (source duration adjusted by that clip's speedFactor, if any — see 11.4).
 */
interface Clip {
  id: string;                    // clip_<base58>
  sourceStartMs: number;          // inclusive, source time
  sourceEndMs: number;             // exclusive, source time
  speedFactor: number;              // 1.0 = normal; see 11.4 SPEED CHANGE
  order: number;                     // integer, defines position; gaps allowed, dense re-sequencing not required
  originClipId: string | null;        // if this clip was produced by splitting another clip, points
                                        // to the original pre-split clip's id for edit-history purposes;
                                        // null for a clip that has never been split
}

/** ZoomSegment: full type defined in Section 10.5. Reproduced here only as the field list this
 *  section's editor UI reads/writes — Section 10 owns the generation semantics. */
interface ZoomSegment {
  id: string;
  startMs: number;     // source time
  endMs: number;         // source time
  zoomLevel: number;
  targetRect: Rect;       // source pixel coordinates
  displayId: string;
  sourceClusterIds: string[]; // empty array for a manually-created (non-auto-edit) zoom segment
}

interface CameraBubbleKeyframe {
  id: string;
  atSourceMs: number;
  position: "bottom-right" | "bottom-left" | "top-right" | "top-left";
  visible: boolean; // allows the user to manually hide the bubble for a stretch of the recording
}

interface BackgroundSettings {
  presetId: string;       // Section 10.9.1 catalogue
  presetVersion: number;
  aspectRatio: "16:9" | "9:16" | "1:1" | "4:5";
  windowChromeEnabled: boolean;
}

interface RedactionRegion {
  id: string;                 // rdx_<base58>
  sourceStartMs: number;
  sourceEndMs: number;
  kind: "static" | "keyframed";
  staticRect: Rect | null;         // set when kind = "static"
  keyframes: Array<{ atSourceMs: number; rect: Rect }> | null; // set when kind = "keyframed", ≥ 2 entries, sorted by atSourceMs
  blurStrength: number;              // Gaussian sigma in px, 8–64, default 24 (Section 11.7)
  pixelate: boolean;                   // if true, renders as pixelation instead of Gaussian blur
  muteAudio: boolean;                    // default false; when true, the render worker silences this
                                           // region's audio range and strips overlapping transcript
                                           // words before caption generation (Section 11.7)
  status: "pending" | "active";            // 'pending' at creation; 'active' only once a completed,
                                             // current render has burned this exact region in.
                                             // Section 22.2.4 gates sharing on every region being
                                             // 'active' (Section 11.7)
  createdBy: string;                     // userId
  createdAt: string;
}

interface ChapterMarker {
  id: string;
  sourceStartMs: number;
  title: string;             // max 80 chars, Section 12 governs AI-suggested vs accepted title text
}

interface AudioAdjustment {
  id: string;
  sourceStartMs: number;
  sourceEndMs: number;
  gainDb: number;              // -40 to +12
  muted: boolean;
}

interface SpeedRamp {
  id: string;
  sourceStartMs: number;
  sourceEndMs: number;
  speedFactor: number;    // 0.25 to 4.0; note this OVERRIDES any Clip.speedFactor for the overlapping range
}

JSON Schema (Draft 2020-12) for the top-level document, used for server-side validation on every save (Section 7):

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://api.reelay.app/schemas/edit-decision-list.json",
  "title": "EditDecisionList",
  "type": "object",
  "required": ["id", "videoId", "version", "documentVersion", "clips", "zoomTrack",
    "cameraBubbleTrack", "background", "redactionRegions", "chapterMarkers",
    "audioAdjustments", "speedRamps", "createdAt", "updatedAt"],
  "properties": {
    "id": { "type": "string", "pattern": "^edl_[1-9A-HJ-NP-Za-km-z]+$" },
    "videoId": { "type": "string", "pattern": "^vid_[1-9A-HJ-NP-Za-km-z]+$" },
    "version": { "type": "integer", "minimum": 1 },
    "documentVersion": { "const": 1 },
    "clips": {
      "type": "array",
      "minItems": 1,
      "items": {
        "type": "object",
        "required": ["id", "sourceStartMs", "sourceEndMs", "speedFactor", "order", "originClipId"],
        "properties": {
          "id": { "type": "string" },
          "sourceStartMs": { "type": "integer", "minimum": 0 },
          "sourceEndMs": { "type": "integer", "minimum": 0 },
          "speedFactor": { "type": "number", "exclusiveMinimum": 0, "maximum": 4.0 },
          "order": { "type": "integer" },
          "originClipId": { "type": ["string", "null"] }
        }
      }
    },
    "zoomTrack": {
      "type": "array",
      "items": {
        "type": "object",
        "required": ["id", "startMs", "endMs", "zoomLevel", "targetRect", "displayId", "sourceClusterIds"],
        "properties": {
          "id": { "type": "string" },
          "startMs": { "type": "integer", "minimum": 0 },
          "endMs": { "type": "integer", "minimum": 0 },
          "zoomLevel": { "type": "number", "minimum": 1.0, "maximum": 2.5 },
          "targetRect": { "$ref": "#/$defs/rect" },
          "displayId": { "type": "string" },
          "sourceClusterIds": { "type": "array", "items": { "type": "string" } }
        }
      }
    },
    "redactionRegions": {
      "type": "array",
      "items": {
        "type": "object",
        "required": ["id", "sourceStartMs", "sourceEndMs", "kind", "blurStrength", "pixelate", "muteAudio", "status", "createdBy", "createdAt"],
        "properties": {
          "kind": { "enum": ["static", "keyframed"] },
          "blurStrength": { "type": "number", "minimum": 8, "maximum": 64 },
          "muteAudio": { "type": "boolean" },
          "status": { "enum": ["pending", "active"] }
        }
      }
    },
    "background": {
      "type": "object",
      "required": ["presetId", "presetVersion", "aspectRatio", "windowChromeEnabled"],
      "properties": {
        "aspectRatio": { "enum": ["16:9", "9:16", "1:1", "4:5"] }
      }
    }
  },
  "$defs": {
    "rect": {
      "type": "object",
      "required": ["x", "y", "w", "h"],
      "properties": {
        "x": { "type": "number" }, "y": { "type": "number" },
        "w": { "type": "number", "exclusiveMinimum": 0 },
        "h": { "type": "number", "exclusiveMinimum": 0 }
      }
    }
  }
}

The full schema additionally validates zoomTrack entries are sorted by startMs and non- overlapping (a cross-field constraint expressed as a Zod .superRefine at the application layer, since JSON Schema alone cannot express "sorted and non-overlapping" cleanly — the persisted document is Zod-validated server-side before the JSON Schema-documented shape above is accepted as valid, giving both a portable schema for external tooling and precise application-level validation).

11.2 The non-destructive invariant #

Product invariant, stated exactly: the original recording is immutable. No editing operation, auto-edit run, redaction, or render ever modifies, overwrites, or deletes the source media_assets row or its underlying object-storage bytes (Section 5, Section 9) while the video exists. Every change a user makes is represented as data in the EditDecisionList (11.1) — never as a destructive transformation of source pixels or audio samples.

Two corollaries follow directly and are both enforced, not just documented:

  1. Every removal is individually revertible. A trimmed clip boundary, a ripple-deleted segment, a filler-word cut (11.5), or a redaction region is represented as EDL data (a shortened Clip range, a removed Clip entry, an entry in redactionRegions). Because the underlying source frames are never deleted, reverting any single one of these is restoring the EDL entry, not recovering lost data — there is no data to recover, only a document to un-edit. Undo/redo (11.4) operates at this level.
  2. "Restore all" returns exactly the original timeline. The editor provides an explicit "restore original" action that replaces the current EDL's clips array with a single Clip spanning sourceStartMs: 0 to sourceEndMs: <source durationMs> at speedFactor: 1.0, and clears zoomTrack, redactionRegions (subject to the exception below), speedRamps, and audioAdjustments. This is exact, not approximate, because the source recording — the only thing that could make it inexact — was never touched.

Exception, stated explicitly: redaction regions marked as burned in for security reasons (Section 11.7, Section 22's threat model) are retained through "restore all" if they were applied to comply with a workspace policy (e.g. an admin-enforced redaction requirement, Section 22) rather than a creator's own editorial choice — this prevents "restore all" from becoming a mechanism to bypass a compliance-mandated redaction. A creator-applied redaction with no policy flag is cleared like any other edit. This distinction is tracked via RedactionRegion.createdBy combined with a policyEnforced: boolean field the editor sets when a region originates from an admin policy rather than manual drawing (field included in the full redaction-region persistence model; omitted from the 11.1 excerpt above for brevity since Section 22 owns the policy trigger, this section owns only that the flag exists and gates "restore all" behavior).

11.3 Editor UI architecture #

Component hierarchy (packages/ui, React on the version line in Section 3):

EditorPage
├── EditorTopBar (save state, undo/redo buttons, export/share actions)
├── EditorLayout
│   ├── PreviewPane
│   │   ├── VideoCanvas (renders the composited preview, 11.8)
│   │   └── PreviewControls (play/pause, scrubber, current time)
│   ├── InspectorPanel (context-sensitive: selected clip / zoom segment / redaction region properties)
│   │   ├── ClipInspector
│   │   ├── ZoomSegmentInspector
│   │   ├── RedactionInspector
│   │   └── BackgroundInspector
│   └── TimelinePane
│       ├── TimelineToolbar (zoom-to-fit, split, trim mode toggle, snapping toggle)
│       ├── TimelineRuler (time labels, playhead)
│       ├── TimelineTracks (virtualized list, 11.3.3)
│       │   ├── ClipTrack
│       │   ├── ZoomTrack
│       │   ├── AudioWaveformTrack
│       │   ├── RedactionTrack
│       │   ├── CaptionTrack (read-only reference into Section 12's data; edits deep-link to the transcript view)
│       │   └── ChapterTrack
│       └── TimelineCanvas (the actual rendering surface for all tracks above, 11.3.1)

State is managed with a single reducer-based store (useEditorStore, built on React's useReducer (Section 3's version line) plus a context provider — no external state library needed given the bounded, well-typed shape of an EditDecisionList) holding: the current in-memory EDL, the undo/redo stacks (11.4), selection state, playhead position, zoom/scroll viewport (11.3.4), and dirty-state flags (11.9). All track components subscribe via selectors to avoid re-rendering the entire tree on every playhead tick.

11.3.1 Timeline canvas rendering approach #

The timeline surface (ruler, waveform, clip blocks, zoom-segment blocks, redaction markers) renders to a single <canvas> element per track group, not DOM elements per clip/segment. Rationale:

  • A long recording (up to the 4-hour soft cap, Section 8.9) at typical editing zoom levels can have hundreds of clips after heavy filler-word removal (11.5) plus dozens of zoom segments and redaction regions. DOM nodes at that count, redrawn on every scroll/zoom/playhead-move frame, produce layout thrashing; canvas drawing is a flat per-frame cost independent of DOM tree depth. the timeline redraws at 60fps during scrubbing.
  • Waveform rendering (11.3.2) is fundamentally a per-pixel-column min/max plot — a canvas primitive, not a DOM layout problem.
  • Precise sub-pixel positioning of trim handles and snap guides is simpler to reason about as canvas draw calls with explicit coordinates than as CSS transforms fighting layout rounding.

Interaction (click, drag, hover) is handled by a hit-testing layer maintained alongside the draw calls: each render pass also updates a spatial index (a flat array of { rect, targetId, trackType } entries, since track counts are small — no need for a quadtree) that pointer event handlers query to resolve which clip/segment/region a click landed on. This keeps interaction logic in TypeScript (testable, Section 25) rather than relying on the DOM's native hit-testing, which canvas does not provide.

11.3.2 Waveform generation and caching #

Waveform data is generated once per recording by the worker (Section 9), not by the browser, from the extracted audio track: for a target of up to 4096 peak samples per zoom level bucket, the worker computes min/max amplitude pairs at three fixed resolutions (1 sample per 50ms, 1 per 500ms, 1 per 5000ms) and stores them as a compact binary blob (Int16Array pairs, min/max, little-endian) in object storage, referenced from the media_assets row. The editor fetches the resolution tier closest to the current timeline zoom level (11.3.4) and interpolates for in-between zoom levels, avoiding a full waveform recomputation in the browser. The blob is cached in the browser's HTTP cache (immutable, content-addressed filename) and additionally held in an in-memory Map<tier, Float32Array> for the session.

11.3.3 Virtualized track rendering #

Track content lists (clips, zoom segments, redaction regions) are windowed: only entries whose rendered-time range intersects the current viewport (current scroll offset ± one viewport width, for smooth scroll-ahead) are included in the per-frame draw call list. The spatial index (11.3.1) is rebuilt only for the visible window, not the full track, each frame. This keeps draw and hit-test cost bounded by viewport size, not total timeline content, which matters most on long recordings with many filler-word cuts (11.5).

11.3.4 Zoom/scroll model #

The timeline viewport is defined by two values: pixelsPerSecond (zoom level, range 2–400) and scrollOffsetMs (horizontal pan). pixelsPerSecond changes are anchored at the playhead or cursor position (whichever triggered the zoom — scroll-wheel-with-modifier zooms at cursor, the toolbar zoom-to-fit button computes pixelsPerSecond to fit the full rendered duration in the visible width and re-centers). Horizontal scroll uses native wheel/trackpad deltas mapped 1:1 to scrollOffsetMs change (deltaScrollMs = deltaPixels / pixelsPerSecond), clamped to [0, totalRenderedDurationMs - viewportDurationMs] (or [0,0] if the full timeline fits in the viewport). Vertical scroll (when more tracks exist than fit vertically) is native DOM scroll on the track-list container, independent of the canvas horizontal model.

11.4 Editing operations #

Every operation below is expressed as a command object consumed by the undo/redo system (command pattern, detailed at the end of this subsection). Each command stores enough state to reverse itself without re-deriving it from the resulting EDL.

Operation Semantics Edge cases
Trim Adjusts a Clip.sourceStartMs (trim-in) or sourceEndMs (trim-out). Rendered timeline duration for that clip changes; all later clips' rendered positions shift by the same delta (derived, not stored — 11.1). Trim cannot move sourceStartMs past sourceEndMs - 1 (minimum clip duration 1 ms enforced) or outside the underlying source recording's [0, durationMs] bounds. Trimming a clip to zero length via trim-in/out is disallowed by the UI (use delete instead); the API rejects a save with a zero-or-negative-length clip with invalid_clip_range.
Split Divides one Clip at a given rendered-time position (converted to the corresponding sourceMs via the timeline walk, 11.1) into two Clip entries with the same speedFactor, both carrying originClipId set to the pre-split clip's id (or its own originClipId if it was already a split product, preserving the lineage to the true original). Splitting exactly at an existing clip boundary is a no-op (returns without creating a command, so it does not pollute undo history). Splitting inside a zoom segment or redaction region that spans the split point does not alter that segment/region — they remain anchored to source time and are unaffected by how many Clip entries cover their range.
Ripple delete Removes a Clip (or a partial range, which splits then removes) and closes the rendered-time gap — every later clip's rendered position shifts earlier by the removed clip's rendered duration. This is the default delete (Delete/Backspace) and is what filler-word removal (11.5) uses. Deleting the only remaining clip is disallowed (cannot_delete_last_clip) — an EDL always has at least one clip; deleting the last piece of content should route the user to deleting the whole video instead. Ripple-deleting a range that fully contains a zoom segment removes that zoom segment from zoomTrack as part of the same command (bundled, one undo step); a zoom segment partially overlapping the deleted range is truncated to its remaining overlap with surviving clips, or removed if remaining overlap is < 200ms.
Non-ripple delete Removes a Clip and leaves the resulting gap as a genuine gap in the rendered timeline — during playback/render, a non-ripple-deleted range shows the background (10.9) with no source content composited, for its original rendered duration. Used rarely, primarily to reserve a beat of dead air deliberately (e.g. before inserting an external clip in a future release scope). The gap is represented as the absence of a Clip covering that position, not a special "gap clip" type — the timeline walk (11.1) treats an interval between the end of one clip's contribution and the declared start of the next as background-only. Two ripple vs. non-ripple deletes cannot be mixed silently: the UI requires an explicit modifier (e.g. Shift+Delete) for non-ripple, defaulting to ripple, since ripple is correct for the dominant use case (filler removal).
Move Reorders a Clip's order value, repositioning it in the rendered timeline. Implemented as updating the moved clip's order to a value between its new neighbors (fractional ordering, e.g. neighbor orders 10 and 20 → moved clip gets order 15) to avoid renumbering the whole array; a periodic compaction pass renumbers to dense integers when fractional gaps get too small (< 1e-6 apart) to represent reliably in a double. Moving a clip does not move the zoom segments/redaction regions anchored to its source range — they stay anchored to source time and simply appear at the clip's new rendered position automatically, which is the entire point of source-time anchoring (11.1).
Speed change Sets Clip.speedFactor (0.25–4.0) for a whole clip, or adds a SpeedRamp (11.1) for a sub-range that overrides the clip's factor for that range only. Rendered duration for the affected range becomes sourceRangeDurationMs / speedFactor. Audio pitch is corrected (time-stretch, not naive resample) by the render worker (Section 9) using FFmpeg's atempo filter chain (chained in 2x steps for factors outside [0.5, 2.0], since atempo itself only supports that range per filter instance). Speed factor outside [0.25, 4.0] is rejected client-side and server-side (invalid_speed_factor). A zoom segment overlapping a sped-up/slowed-down range keeps its startMs/endMs in SOURCE time (unaffected) — its rendered duration changes automatically via the same speed factor when the timeline walk computes rendered position, keeping zoom and speed visually synchronized without any cross-track update.

Undo/redo — command pattern. Every operation above is captured as a Command with do() and undo():

interface Command {
  id: string;
  label: string;              // human-readable, shown in a future "edit history" UI
  timestamp: number;            // logical sequence number, NOT wall-clock (Workflow scripts and
                                  // determinism concerns elsewhere in this spec avoid wall-clock reads;
                                  // the editor client may display wall-clock separately but the
                                  // command stack orders by an incrementing counter)
  apply: (edl: EditDecisionList) => EditDecisionList;   // pure function, returns new EDL
  invert: (before: EditDecisionList, after: EditDecisionList) => Command; // produces the undo command
}

interface EditorHistory {
  undoStack: Command[];
  redoStack: Command[];
  coalescingWindowMs: number; // 800ms — see below
  lastCommandAt: number | null;
}
  • Stack depth: 100 commands. Beyond 100, the oldest command is dropped from undoStack (the EDL itself is unaffected — this only limits how far back undo can travel, per session; it does not limit EDL revision history, which is separately versioned, 11.9).
  • What is coalesced: consecutive commands of the same type targeting the same entity (e.g. repeated small trim-drag adjustments on the same clip edge while the user drags a handle, or repeated keystroke-level text edits to a chapter title) within an 800 ms rolling window are merged into a single undo step, so one undo reverts the whole drag/typing gesture rather than requiring dozens of undos for one perceived action. A trim followed by a split is never coalesced regardless of timing, since they are different operation types. Coalescing resets (the next command starts a fresh potential coalescing group) whenever more than 800 ms elapses since the last command of that type/entity pair.
  • Redo stack is cleared whenever a new command is applied after an undo (standard behavior — no branching history in the editor UI, though the underlying revision history, 11.9, does retain every saved version regardless).

11.5 Filler-word and silence removal #

Detection source. Filler-word detection operates on the word-level transcript (word text, start time in source milliseconds, end time in source milliseconds, confidence, and speaker label) defined in Section 12.2. Silence detection operates independently on the audio track's energy envelope (the same class of signal as Section 10.2's audio envelope, computed at finer resolution for this purpose: 20 ms hop size, versus 10.2's 100 ms hop, since silence-boundary precision matters more here than it does for typing-burst confirmation).

Filler-word list (default, configurable per workspace). um, uh, uhh, umm, er, erm, like (only when tagged by the transcript provider as a disfluency/filler use rather than a comparison/quotative use — Section 12.1's provider interface exposes a disfluency: boolean flag per word when the underlying vendor supports it; when the vendor does not support the distinction, like is excluded from the default filler list entirely to avoid false-positive removal of meaningful words), you know (as a two-word phrase, only when both words are individually tagged low-confidence-content by the same disfluency heuristic), so (only as a sentence-initial filler before a pause > 400 ms, not as a conjunction). Workspace admins (role admin or owner, Section 6) edit this list in workspace settings: add/remove exact-match words or phrases (case-insensitive, punctuation-stripped comparison), with the position-sensitive rules above (like, so) fixed — those heuristics are not user-configurable, only the flat word list is.

Silence detection thresholds:

Parameter Value
dBFS floor -40 dBFS (audio below this level is considered silence)
Minimum silence duration 700 ms (shorter gaps are natural speech pauses and are never flagged)
Padding retained either side 150 ms of the original silence is kept on each side of a removed silence gap, so the cut does not sound abrupt

A candidate silence removal is therefore: find contiguous stretches of the energy envelope below -40 dBFS lasting ≥ 700 ms; the removable range is [stretchStartMs + 150, stretchEndMs - 150] (only the interior beyond the retained padding is actually cut); a stretch between 700 ms and 300 ms (150+150) long after padding is subtracted produces no removable interior and is skipped entirely even though it met the duration floor.

Aggressiveness presets:

Preset Filler words removed Silence removed Effective description
off none none Detection still runs (for the review UI, below) but nothing is pre-selected for removal
light filler words only, confidence ≥ 0.85 none Cleans up verbal tics, leaves pacing untouched
balanced (default) filler words, confidence ≥ 0.70 silences ≥ 700ms per the table above The general-purpose preset
aggressive filler words, confidence ≥ 0.55 silences ≥ 500ms (padding still 150ms/side) Tightest pacing; more false-positive risk, surfaced clearly in the review UI (below)

confidence in the filler-word rows above refers to the transcript word's own confidence field (Section 12.2) — a low-confidence transcription of an ambiguous "uh"-like sound is treated conservatively (excluded at light/balanced) rather than aggressively cut.

Per-removal accept/reject UI. Running detection populates a review list (not an immediate edit): each candidate (a filler word or a silence gap) appears as a card with its transcript context (±2 words of surrounding text for filler words) or waveform snippet (for silence), a play button scoped to just that ±500ms window, and Accept/Reject controls. Accepting a candidate creates a ripple-delete Command (11.4) for that range; rejecting simply removes the candidate from the review list without touching the EDL. Candidates are ordered by source time.

Bulk apply. "Accept all" applies every currently-listed candidate as a single batched command (one undo step reverts the entire bulk operation, not one step per candidate — this is an explicit exception to the per-type coalescing rule in 11.4, since a deliberate bulk action is conceptually one edit). "Accept all filler words" / "Accept all silences" apply only that category. Adjusting the aggressiveness preset after an initial detection re-runs detection and re-populates the review list from scratch; any already-accepted removals from a prior preset run are NOT retroactively reverted by changing the preset — the user must explicitly undo or use "restore all" (11.2) to reverse previously accepted removals.

The reversibility guarantee. Every filler-word or silence removal is a ripple-delete Command like any other edit (11.4) — it is undoable via the standard undo stack while the session is open, and reversible at any later time by removing that command's resulting gap from the EDL (the editor exposes accepted-and-applied removals in a lightweight "recent AI edits" list, distinct from the review-list UI, letting a user find and revert a specific removal after closing and reopening the editor, which the plain undo stack — session-scoped, 11.4 — cannot do).

11.6 The fabrication boundary #

Product invariant, stated exactly: trimming pauses, silences, and filler words is EDITING — removing time that already existed, using only the words the speaker actually said, in the order they said them. Changing what the speaker said — replacing a word, synthesizing new audio, reordering speech, or generating any audio that was not captured from the original recording — is FABRICATION. The product performs the first and never the second, anywhere, under any plan tier or configuration.

Rationale. A screen recording is used as a record of a real product walkthrough, a real support interaction, or a real explanation — its evidentiary and communicative value depends on the words being what was actually said. An editing tool that can silently reorder or resynthesize speech undermines that trust category-wide, not just for the one video it was used on; a viewer cannot tell, from watching, whether any given video used it. Restricting the product to time-domain removal only (cutting, never inserting or altering content) keeps that trust boundary structurally enforced rather than a matter of feature-by-feature restraint.

What this rules out, explicitly:

  • No text-to-speech generation anywhere in the product — not for filler-word replacement, not for translation/dubbing (out of scope entirely, Section 2), not for narration.
  • No word replacement — a transcript correction (Section 12.10) edits the displayed/exported caption text only; it never touches the underlying audio, and a corrected caption word is visually distinguished from the recognized audio in the transcript editor so a viewer of the edit history cannot mistake a spelling correction for evidence the audio was altered.
  • No reordering of speech — Clip entries (11.1) may be reordered via Move (11.4), but this is a visible timeline operation on recorded video+audio together (moving a whole clip moves its audio with it); there is no operation that extracts and reorders words independent of their clip.
  • No synthesized audio — every audio sample in a rendered output originates from the source recording's audio track (Section 9) or, where explicitly a separate authored asset (background music if introduced in a future release, intro/outro clips, Section 18.6), is clearly a distinct, separately-sourced track, never blended into or replacing the speaker's own recorded voice.

What makes this testable. Every audio sample in a rendered output can be traced to a sourceStartMs/sourceEndMs range in exactly one Clip (11.1) referencing the original media_assets audio track (Section 9), at a speedFactor that time-stretches (11.4) but never pitch-shifts-to-impersonate or replaces content. An automated test (Section 25) asserts this by rendering a test EDL with known clip ranges and verifying, via audio fingerprinting (cross- correlation against the known source segments), that every rendered audio sample matches a corresponding source sample within the expected time-stretch mapping — any rendered audio content that does not trace back to a source range fails the test. This is a regression gate on the render worker (Section 9), not a runtime check on every render, since the pipeline's structural design (no TTS engine, no audio-generation dependency exists anywhere in the render worker's dependency graph, Section 3) already makes fabrication architecturally impossible, not merely policy-forbidden.

11.7 Redaction in the editor #

Drawing static rects. The editor's RedactionTrack (11.3) lets a user draw a rectangle directly on the VideoCanvas preview (11.3, 11.8) at the current playhead position: click-drag defines the rect in preview-canvas pixel coordinates, which is converted to source pixel coordinates using the current zoom segment's active crop transform (so a rect drawn while previewing a 2x zoom is stored correctly mapped back to full source resolution, Section 10.5). The resulting RedactionRegion (11.1) defaults to kind: "static", status: "pending", muteAudio: false, spanning from the current playhead to a default 3-second duration (adjustable by dragging the region's edges on the RedactionTrack, same trim interaction as a clip).

Keyframed tracks. Converting a static region to kind: "keyframed" (or creating one directly) adds keyframe control points: the user repositions the rect at different points in time (minimum 2 keyframes, added via a "add keyframe" action at the current playhead while the region is selected), and the rect at any in-between source time is linearly interpolated between the two surrounding keyframes (position and size both interpolated independently — a keyframed region can grow/shrink as well as move, tracking e.g. a shrinking dialog box or a moving cursor tooltip revealing sensitive data). Keyframes are stored sorted by atSourceMs (11.1); the editor UI enforces this ordering when a keyframe is dragged past a neighbor by re-sorting rather than allowing crossed keyframes, which would make interpolation direction ambiguous.

Blur strength and pixelation. blurStrength (Gaussian sigma, 8–64px, default 24) is adjustable per region via a slider in the RedactionInspector. A pixelate boolean toggle switches the render-time effect from Gaussian blur to a mosaic/pixelation effect (block size derived from blurStrength as blockSizePx = round(blurStrength / 2), so the same slider value produces a comparably strong obscuring effect under either mode) — pixelation is offered as an alternative because some compliance guidance (Section 22) treats blur as reversible with sufficient source resolution and prior frames, while heavy pixelation destroys more information; the product supports both and documents this trade-off to the user via inline help text next to the toggle, without prescribing one as universally correct.

The hard rule: redaction is burned in server-side, never a client-side overlay. Every RedactionRegion in the EDL is applied by the render worker (Section 9) directly onto the pixel data of every rendition produced from that EDL — every adaptive-bitrate rendition, every poster/ thumbnail frame that falls within a redacted range, and every exported file (MP4/GIF/WebM, Section 10.13's export capability). There is no code path, anywhere in the player (Section 15) or the editor's own preview (11.8), that composites a redaction rectangle as a visual layer on top of otherwise-unredacted video bytes for anything other than the editor's own authoring preview (11.8), which is explicitly and only shown to users with edit access to the video (the same access level that could view the unredacted region directly in the source anyway). A viewer of a shared link or embed never receives unredacted bytes for a redacted region — the pixels themselves are altered before encoding. This is a security property, not a visual effect: it means a redacted region cannot be recovered by inspecting network traffic, browser devtools, or any client-side state, because the information was never transmitted.

Where the unredacted original lives. The source that redaction burns in from is never stored anywhere a viewer's request path can reach. It lives in reelay-media-restricted — a bucket with no CDN origin, IAM-denied by default — while every rendition, poster, thumbnail, and export a redacted video ever serves lives in reelay-media-delivery. These are two physically separate buckets, not two prefixes inside one bucket, precisely so a misconfigured bucket policy on the delivery side can never expose restricted content (Section 22.2 owns this model in full, including which media_assets.kind values live in which bucket).

Section 22.2 owns the full threat model, including the restricted-bucket retention described above, the audit logging of every access to the restricted bucket, and the redaction-triggered unpublish behavior (11.9). Section 10 is camera-motion physics only — it decides where the virtual camera looks and alters no pixels — and is never the citation for a redaction or retention concern; the canonical citation for those is always Section 22.2. This section (11.7) owns only that the editor produces correct RedactionRegion data and that every render path listed above consumes it without exception — a render job that produces output without applying all redactionRegions present in its source EDL is treated as a critical pipeline defect (Section 24), not a cosmetic bug, and is alerted accordingly.

Redaction is visual only. Burning in a blur or pixelation rectangle removes information from the picture only. On its own it does not remove anything from the transcript (Section 12), the caption track (Section 12), or the audio track — a password that is blurred on screen but read aloud by the presenter is still fully present in the transcript, the burned-in captions, and the rendered audio unless the region is also configured to mute audio (below). Treating a visual blur as a complete redaction is a real leak, and the product does not let that gap pass silently — see the gap warning below.

muteAudio and transcript stripping. RedactionRegion (11.1) carries an optional muteAudio boolean, default false. When muteAudio is true for a region, the render worker (Section 9) silences that region's [sourceStartMs, sourceEndMs] range on the rendered audio track of every rendition and export, in addition to burning in the visual blur/pixelation — governed by the same hard, server-side, no-client-path rule as the visual redaction above. Because captions are generated from the transcript (Section 12) after redaction regions are known, any transcript word whose time range overlaps a muteAudio region is stripped before caption generation runs, rather than being generated and then hidden after the fact — a stripped word never reaches the caption track, the downloadable transcript, or any AI-generated chapter/summary text (Section 12) that quotes the transcript.

The gap warning. A redaction region that covers a time range with no corresponding audio mute is common and often correct — for example, blurring a visible-but-never-spoken API key. But the editor does not assume that is always the case: when a RedactionRegion's time range overlaps a period where the transcript (Section 12) contains recognized speech, the RedactionInspector (11.3) shows an inline warning — "This region has spoken audio during its time range. Blurring the picture does not remove it from the transcript or captions unless you also mute the audio." — with a one-click action to enable muteAudio for that region. The warning is advisory, not blocking (some blurred regions genuinely cover no speech, e.g. a background notification popup during silence), but it is never suppressed automatically, and a workspace admin cannot disable it workspace-wide: a false negative here is a real data leak, not a UX annoyance.

Region status and share gating. Every RedactionRegion is created with status: 'pending' (11.1). It transitions to status: 'active' only once a completed render (11.9) whose rendition set is now current has actually burned in that exact region — confirmation that the pixels (and, if muteAudio is set, the audio) were altered, not merely that the EDL was saved. Section 22.2.4 gates whether a video can be shared, or have its visibility widened, on every one of its redaction regions being active; a video with any pending region blocks that widening until the render catches up, closing the window where a share link could be created against an EDL whose redaction has not yet reached the delivered bytes.

11.8 Real-time preview #

The editor previews the EDL without a server render by compositing in the browser at playback time: the VideoCanvas (11.3) plays the original source video element(s) (screen track and, if present, camera-bubble track, Section 8.7, as separate native <video> elements, both seeked together) and draws the composited result to a <canvas> every animation frame using the current playhead's resolved state — active clip mapping (11.1's timeline walk), active zoom crop/camera spring position (10.6, stepped using the same stepCamera function against real frame deltas from requestAnimationFrame), background fill (10.9, drawn as CSS-equivalent canvas gradient/fill primitives), camera bubble position (10.10), and any active redaction rects (drawn as a blurred/ pixelated canvas region using filter: blur() or a manual box-blur canvas operation — client-side only for preview purposes, per 11.7's hard rule that this is never what ships to a viewer).

The fidelity gap versus the final render. The preview is deliberately not pixel-identical to the server render, and the gap is bounded and specific:

Aspect Preview Final render
Blur/redaction quality CSS/canvas filter: blur(), browser-GPU-dependent, approximate Gaussian FFmpeg gblur filter, exact Gaussian per blurStrength (11.7)
Background gradients Canvas createLinearGradient, sRGB, browser color management applies FFmpeg gradient generation, explicit color space, no browser color-management variance
Speed-ramped audio pitch correction Not previewed — preview mutes or plays at natural pitch during scrubbing over a sped-up region, since real-time atempo-equivalent processing in-browser is not attempted Full atempo chain (11.4) applied
Camera spring at extreme scrub speeds May visibly skip/stutter if requestAnimationFrame frame deltas exceed the spring's 100ms defensive clamp (10.6) during fast manual scrubbing Always evaluated at the render's fixed, complete frame sequence — never skips
Color/encoding Source codec's native browser decode Full transcode ladder (Section 9), output codec/profile per rendition

How the gap is communicated. The EditorTopBar shows a persistent "Preview — may differ slightly from final export" label whenever any redaction region, background preset other than none, or speed ramp is present in the current EDL (the conditions under which the gap is non-trivial); it is hidden when the EDL contains none of those, since a preview of an EDL with only trims/splits/zoom is effectively exact. Additionally, the first time a user adds a redaction region in a given editor session, a one-time inline tooltip states plainly that the exact blur strength is confirmed in the exported/shared version, not the live preview — set expectations before the user relies on the preview to judge how obscured content looks.

11.9 The render request lifecycle #

Dirty-state tracking. The useEditorStore (11.3) tracks a dirty: boolean flag, set true on any committed Command (11.4) and false immediately after a successful autosave (11.10) or manual save. A separate renderDirty: boolean flag tracks whether the current rendition (the last completed server render) still matches the current EDL content — this is set true by the same commands that set dirty, but is cleared only when a new render actually completes, not merely when the EDL is saved, since saving the EDL and rendering it are decoupled operations.

When a re-render is triggered. A re-render is enqueued (video.render.compose job, Section 9) automatically 3 seconds after the last EDL-mutating command, debounced (each new command resets the 3-second timer) — this avoids queuing a render for every keystroke of a drag gesture while still rendering promptly once the user pauses. A re-render is also triggered immediately, without the debounce, when the user explicitly clicks "Publish"/"Done editing" or initiates a share/export action (Section 14) against an EDL that is renderDirty.

Render versioning. Each completed render creates a new renditions row set (Section 5, Section 9) tagged with the EditDecisionList.version (11.1) it was rendered from. Renditions are never deleted while referenced by the video's currentRenditionSetId or by any share link's pinned version (share links always point at the current rendition set unless explicitly configured otherwise — a future capability out of scope here); superseded rendition sets are eligible for cleanup per the retention policy (Section 19) once no longer current and no share link references them.

What a viewer sees while a re-render is in flight. The previous rendition keeps playing. A video's public/shared playback surface (Section 15) always serves the most recent completed rendition set; an in-progress render never interrupts or replaces what a viewer is currently watching or would start watching, and there is no partial/streaming exposure of an incomplete render. Only when the new render completes does the player's next playback session (or a live session's next natural chapter/seek boundary, to avoid an abrupt mid-playback swap) pick up the new rendition set.

The one exception: a redaction-triggered unpublish. The keeps-playing rule above assumes the previous rendition is still safe to serve. It is not, in exactly one case: Section 22.2.5 defines a redaction-triggered unpublish, where a rendition's renditions.servable flag (Section 5) flips to false the instant a redaction region is added or widened against a rendition set that is currently serving pixels which are now known to require redaction — the system cannot wait for the next render to catch up while a now-unredacted-but-should-not-be rendition keeps streaming to viewers. This is the only case anywhere in this section where a viewer-facing rendition stops being served before its replacement is ready; every other reason a render might be in flight (an ordinary edit, an aspect-ratio re-solve, a re-run of auto-edit) follows the keeps-playing rule above without exception. For a playback session already in progress at the moment servable flips to false, the player (Section 15) does not continue serving the now-disallowed pixels and does not merely freeze or buffer indefinitely: it stops playback immediately and shows an explanatory message ("This video was updated and is being reprocessed.") rather than continuing to serve pixels that are now known to be unredacted. Playback resumes automatically, without the viewer needing to reload, once a new rendition set with servable: true and the redaction actually burned in becomes current.

Render failure recovery. A failed render (exhausausted retries, Section 9's standard BullMQ retry policy) leaves the previous rendition set as current — the video remains fully playable on its last good render. The editor surfaces a non-blocking "render failed, retry" banner in the EditorTopBar with a manual retry action (which re-enqueues video.render.compose at normal, non- debounced priority) and logs the failure for operational visibility (Section 24). The user's EDL changes are never lost or rolled back due to a render failure — render failure is purely a rendering-pipeline concern, decoupled from the EDL document's own save state (11.10).

11.10 Autosave, draft state, and conflict handling #

Autosave. The current in-memory EDL is persisted to the server 2 seconds after the last Command (11.4), debounced identically to the render trigger (11.9) but on its own independent timer (a save does not wait for or block on a render, and vice versa). Each autosave PUTs the full EDL document (not a diff) to /v1/videos/{videoId}/edl, incrementing EditDecisionList.version by 1 server-side on each accepted save; the response's version is stored back into the client store to keep the two in sync.

Draft state. There is no separate "draft" document distinct from the saved EDL — the saved EDL is the current state of the video's edits, always. "Unsaved changes" is only ever the ≤2-second window between a command and its autosave completing (surfaced in the EditorTopBar as a small "saving…" / "saved" indicator), plus the deliberately-reverted state during an active undo/redo sequence before the next autosave fires.

Conflict handling when two people open the same video. The editor is single-writer, advisory-locked: opening the editor for a video acquires a soft lock (editor_locks table, implicit entity referenced from videos, keyed by videoId, holding userId, acquiredAt, lastHeartbeatAt, 15-second heartbeat, lock considered stale and reclaimable after 45 seconds of no heartbeat — e.g. a crashed tab). A second user opening the same video while the lock is held sees a read-only preview of the editor (full timeline visible, no editing controls active) with a banner: "{firstUser} is currently editing this video" and a "request control" action that notifies the current lock holder (via the in-app notification system, Section 24) and offers a "release lock" button to the current holder; the lock is not force-taken automatically — the current editor must explicitly release it, or its heartbeat must lapse (45s), for the second user to acquire it. This avoids EDL merge conflicts entirely by construction rather than attempting operational-transform or CRDT-style concurrent editing, which the product does not need at its expected concurrency profile (a workspace-owned video is typically edited by one person or asynchronously by a small team, not edited live by multiple simultaneous editors).

Lock release. Explicit ("done editing" / navigating away triggers a beforeunload-driven best- effort release call), or implicit via heartbeat lapse. A held lock never blocks viewing the video (Section 15's playback is unrelated to the editor lock) or blocks non-editor operations like sharing an existing rendition (Section 14) — only the timeline editor's write surface is gated.

11.11 Keyboard shortcuts and accessibility #

Keyboard shortcut reference:

Shortcut Action
Space Play/pause preview
J / K / L Shuttle back / pause / shuttle forward (standard NLE convention; repeated J/L presses increase shuttle speed: 1x, 2x, 4x, 8x)
Left / Right Step one frame back/forward at the current playhead
Shift+Left / Shift+Right Jump back/forward 1 second
Home / End Jump to timeline start / end
S Split at playhead
Delete / Backspace Ripple delete selected clip/range
Shift+Delete Non-ripple delete selected clip/range
Cmd/Ctrl+Z Undo
Cmd/Ctrl+Shift+Z Redo
Cmd/Ctrl+S Force immediate save (bypasses the 2s autosave debounce, 11.10)
+ / - Zoom timeline in / out
Shift+F Zoom to fit full timeline
Z (drag on ZoomTrack) Draw a new manual zoom segment at the dragged range
R (drag on preview) Draw a new redaction region at the current playhead (11.7)
Cmd/Ctrl+B Toggle camera bubble visibility keyframe at playhead
Tab / Shift+Tab Move focus to next/previous focusable timeline element (clip, handle, or track header) — see accessibility below
Enter / Space (on a focused timeline element) Activate/select the focused element, equivalent to a mouse click
Arrow keys (on a focused, selected clip/region) Nudge the selected element's boundary by 1 frame (fine adjustment without a mouse)
? Open the keyboard shortcut reference overlay

Accessibility requirements. The editor conforms to WCAG 2.2 AA, the standard defined and owned by Section 23; this subsection states the editor-specific conformance points rather than restating the standard:

  • Every timeline interaction available via mouse drag (trim, move, split-point selection, redaction rect drawing, keyframe placement) has a keyboard-operable equivalent — the table above's Tab- focus-then-arrow-key-nudge pattern, plus explicit menu actions (accessible via a right-click- equivalent context menu that is itself keyboard-triggerable via Shift+F10/context-menu key) for operations that are inherently spatial (e.g. "draw a redaction rect" has a keyboard path: focus the preview, press R, type explicit x/y/w/h values or use arrow keys to position a default-sized rect that appears, then Enter to confirm).
  • The canvas-rendered timeline (11.3.1) is paired with an off-screen, DOM-based accessibility tree (a visually-hidden but screen-reader-navigable list of clips/segments/regions with their time ranges and labels, kept in sync with the canvas's spatial index) since a <canvas> element is otherwise opaque to assistive technology — this shadow DOM structure is what Tab navigation above actually moves focus through, with each focused entry drawing a visible focus ring on the canvas at its corresponding screen position.
  • All inspector panel form controls (11.3) are standard accessible form elements (native or ARIA- compliant custom components from packages/ui) with visible focus indicators, associated labels, and error messaging tied via aria-describedby.
  • Color is never the sole indicator of state on the timeline (e.g. a filler-word candidate pending review versus accepted versus rejected is distinguished by icon and pattern, not color alone, for users with color vision deficiency).
  • Live regions (aria-live="polite") announce autosave status changes ("saved", "save failed") and render-lifecycle banner changes (11.9) without stealing focus.

12. Transcription, Captions, Chapters & AI Metadata #

12.1 The Transcription Pipeline #

Transcription runs as the parallel audio branch of the media pipeline described in Section 9. It starts as soon as the audio track is extracted from the probed original and does not block video transcode, rendition generation, or delivery — a video is watchable before its transcript exists.

12.1.1 Pipeline stages #

Stage What happens
1. Audio extraction The worker app runs FFmpeg (version per Section 3) against the probed original to extract a mono, 16 kHz PCM WAV (or the vendor's preferred submission format) and writes it to a temporary object-storage prefix (tmp/audio/{videoId}.wav), auto-expired after 24h via bucket lifecycle rule.
2. Vendor submission The transcription.request job calls the configured TranscriptionProvider with a signed, time-limited URL to the extracted audio.
3. Vendor processing Asynchronous — the vendor transcribes and diarizes off-band, then calls back.
4. Callback handling The vendor posts to a dedicated webhook endpoint. The handler verifies the signature (Section 7.10), looks up the job by vendor job id, and enqueues transcription.ingest.
5. Ingest & normalize transcription.ingest maps the vendor's proprietary response shape into the canonical TranscriptResult contract (12.2), persists it (Section 5: transcripts, transcript_segments), and enqueues captions.generate.
6. Downstream fan-out Successful ingest enqueues captions.generate (12.5) and, if the workspace plan allows it (12.11), chapters.generate and ai-metadata.generate (12.7, 12.8).

12.1.2 Default vendor and the provider interface #

The default transcription vendor is Deepgram Nova (its "Nova" model line, submitted via Deepgram's asynchronous batch API with diarization enabled on the same call — diarization is not a separate vendor request, see 12.4). Deepgram is never hard-coded into worker logic; every call goes through a provider-agnostic interface so the default can be swapped by configuration:

// packages/shared/src/transcription/provider.ts

export interface TranscriptionSubmitInput {
  audioUrl: string;          // signed, time-limited GET URL to the extracted audio
  videoId: string;
  languageHint: string | null; // BCP-47 tag if known, else null (triggers auto-detect, 12.3)
  diarize: boolean;          // always true for the default vendor call, see 12.4
  callbackUrl: string;       // Reelay webhook endpoint, vendor posts the result here
  idempotencyKey: string;    // `${videoId}:transcription:${presetVersion}`
}

export interface TranscriptionSubmitResult {
  vendorJobId: string;
  vendor: 'deepgram' | 'groq-whisper' | 'assemblyai';
}

export interface TranscriptionProvider {
  /** Submit audio for asynchronous transcription. Must be idempotent on idempotencyKey. */
  submit(input: TranscriptionSubmitInput): Promise<TranscriptionSubmitResult>;

  /** Verify an inbound webhook body against the vendor's signature scheme. */
  verifyWebhookSignature(rawBody: Buffer, headers: Record<string, string>): boolean;

  /** Normalize a vendor-native webhook payload into the canonical result shape. */
  normalizeResult(rawPayload: unknown): TranscriptResult; // TranscriptResult defined in 12.2
}

Two alternative providers implement the same interface and are selectable per workspace via a feature flag (Section 24 owns feature flag mechanics), primarily for cost or latency experiments and as a failover target (12.13):

  • Whisper via Groq — Whisper-class open-weight model served on Groq's inference hardware. Fastest turnaround of the three, no native diarization (diarization is computed as a secondary pass using the same third-party diarization step AssemblyAI exposes natively, or is skipped, per 12.4). Used as the low-cost / high-speed alternative and as the automatic failover vendor (12.13).
  • AssemblyAI — full-featured alternative with native diarization and broad language coverage. Used as a secondary failover and as an option for workspaces that need its specific language coverage.

All three implementations live in packages/shared/src/transcription/providers/{deepgram,groq-whisper,assemblyai}.ts and are registered in a small provider registry keyed by vendor name; the active default is a single configuration constant, not a code branch scattered through the worker.

12.1.3 Async callback handling and signature verification #

The callback endpoint is POST /v1/webhooks/transcription/{vendor}. It is unauthenticated at the transport level (no session/API key) but every request is signature-verified per the scheme in Section 7.10 before any processing occurs — an unverifiable request is rejected with 401 and never reaches the queue. Verified callbacks are idempotent: the handler upserts on vendor_job_id, so a duplicate delivery (vendors retry callbacks that don't receive a fast 200) is a no-op after the first successful ingest.

The handler's only synchronous work is: verify signature → validate the envelope shape → enqueue transcription.ingest with the raw payload → return 200 within the vendor's timeout window (typically under 10s). All parsing, normalization, and persistence happens inside the queued job, never inline in the HTTP handler, so a slow ingest never causes the vendor to consider the callback failed and retry it.

12.1.4 Retry and dead-letter behavior #

All transcription jobs follow the standard BullMQ job contract from Section 9: max 5 attempts, exponential backoff, dead-letter queue on exhaustion, idempotent handlers keyed on job.id = <entity>:<operation>:<version>.

Job name Triggers Idempotency key On exhaustion
transcription.request audio extraction completes {videoId}:transcription:{presetVersion} video's transcription_statusfailed; enqueues transcription.failover once (12.13) before giving up
transcription.ingest vendor webhook verified {videoId}:transcription-ingest:{vendorJobId} moved to transcription.ingest.dlq; alerted per Section 24
captions.generate transcript ingested {videoId}:captions:{transcriptId} video keeps playing without captions; captions_statusfailed, retried on next EDL save
captions.rederive EDL saved (Section 11) {videoId}:captions-rederive:{edlVersion} stale captions remain until the next successful EDL save
chapters.generate transcript ingested, plan allows (12.11) {videoId}:chapters:{promptVersion}:{attemptId} suggestion row statusfailed, user sees a retry action (12.9)
ai-metadata.generate transcript ingested, plan allows (12.11) {videoId}:ai-metadata:{kind}:{promptVersion}:{attemptId} suggestion row statusfailed, user sees a retry action

A vendor outage that exhausts transcription.request retries triggers the failover path in 12.13, which resubmits the job through a different registered TranscriptionProvider rather than dead-lettering immediately, so a single vendor incident does not silently strand every in-flight video.

12.2 The Transcript Data Model — Word Level #

Word-level timing is the load-bearing contract of the entire transcript subsystem: filler-word and silence removal (Section 11.5) selects source-time ranges to cut directly from these word boundaries, caption cues (12.5) are built by grouping consecutive words, and the transcript view's click-to-seek (12.6) seeks to a single word's startMs. Every consumer of transcript data — the editor, the caption generator, the AI chapter/summary prompts — reads this exact shape. It is not renegotiated per feature.

12.2.1 Canonical TranscriptWord shape #

// packages/shared/src/transcription/types.ts

export interface TranscriptWord {
  text: string;           // the word as spoken, punctuation-stripped (e.g. "reelay")
  punctuatedText: string; // display form with case and trailing punctuation (e.g. "Reelay.")
  startMs: number;        // integer, inclusive, source-timeline milliseconds
  endMs: number;          // integer, exclusive, source-timeline milliseconds; endMs > startMs always
  confidence: number;     // 0.0–1.0, vendor-reported word-level confidence
  speaker: string | null; // speaker label id, e.g. "speaker_1" (12.4); null if diarization unavailable
}

export interface TranscriptSegment {
  id: string;              // uuid
  index: number;           // 0-based order within the transcript
  startMs: number;         // = words[0].startMs
  endMs: number;           // = words[words.length - 1].endMs
  speaker: string | null;  // dominant speaker for the segment; equals every word's speaker in the common case
  text: string;             // punctuatedText of all words, space-joined — a display convenience, not authoritative
  words: TranscriptWord[]; // ordered, non-overlapping, contiguous within the segment
}

export interface TranscriptResult {
  vendor: 'deepgram' | 'groq-whisper' | 'assemblyai';
  vendorModel: string;         // vendor's model identifier string, stored for audit, never parsed
  languageCode: string;        // BCP-47, e.g. "en-US" (12.3)
  languageConfidence: number;  // 0.0–1.0
  durationMs: number;          // audio duration transcribed
  segments: TranscriptSegment[];
  speakers: TranscriptSpeaker[]; // see 12.4
}

A segment is a sentence-scale grouping: the vendor's utterance/sentence boundaries where available, otherwise a server-side fallback that closes a segment at terminal punctuation (., ?, !) or after a silence gap of ≥ 700 ms between consecutive words, whichever comes first. Segment boundaries are the "sentence boundaries" referenced by AI chapter snapping (12.7) and by transcript search grouping (12.6).

12.2.2 Storage #

Section 5 is canonical for the transcripts and transcript_segments tables. In outline: transcripts holds one row per video (vendor, language, duration, overall status); transcript_segments holds one row per TranscriptSegment, in segment_index order, with its words array stored as a jsonb column conforming exactly to the TranscriptWord[] shape above. Word-level rows are never split into their own relational table — the array-per-segment shape keeps the common read path (render one segment, one row) to a single row fetch, and segments are small enough (typically 5–30 words) that array operations on them are cheap both in Postgres and in application code.

12.2.3 Ordering and overlap invariants #

  • Words within a segment are strictly ordered by startMs, non-overlapping (words[i].endMs <= words[i+1].startMs), and contiguous with the segment's own startMs/endMs.
  • Segments across a transcript are strictly ordered by index and non-overlapping.
  • A gap between words[i].endMs and words[i+1].startMs (silence, breath, cross-talk) is legal and expected — it is not filled with a synthetic zero-duration word.
  • confidence is never null; a vendor that does not report word confidence populates it with the segment-level confidence, and this substitution is recorded in transcripts.confidence_source ('word' or 'segment') so the QA flagging in 12.12 can account for the difference.

12.3 Language Support #

12.3.1 Auto-detection #

If languageHint is not supplied (the desktop and browser recorders never set it — Reelay always relies on detection), the vendor call requests language auto-detection. The normalized result's languageConfidence reflects the vendor's detection confidence. If languageConfidence < 0.6, the UI shows a "Detected language: {name} (uncertain) — change" affordance in the transcript view (12.6) that lets the user manually pick the correct language and re-run transcription (transcription.request with an explicit languageHint, billed as a new job per 12.11).

12.3.2 Supported languages and accuracy tiers #

Supported languages are the intersection of what the active default vendor supports. Reelay exposes the following list, grouped into accuracy tiers used to set user expectations in-product (the transcript view shows a tier-appropriate note, e.g. "lower accuracy expected" for tier 3):

Tier Expected word accuracy Languages
1 — High ~95–97% on clear studio/desk-mic audio English, Spanish, French, German, Portuguese
2 — Medium ~88–94% Italian, Dutch, Japanese, Mandarin Chinese, Korean, Hindi, Polish, Russian, Swedish
3 — Emerging ~75–87%, more sensitive to accent and background noise Arabic, Turkish, Vietnamese, Indonesian, Thai, Ukrainian, Danish, Norwegian, Finnish, Czech, Romanian, Greek, Hebrew

Accuracy figures are vendor-reported benchmark ranges for clear, single-speaker, near-field audio (typical of a screen recording with a desk or laptop microphone); they degrade with background noise, cross-talk, strong accents, and domain-specific jargon, which is exactly what the confidence-flagging in 12.12 exists to surface per-recording rather than relying on a static table.

12.3.3 Mixed-language audio #

When a recording switches languages mid-stream (e.g. a bilingual walkthrough), behavior depends on vendor capability:

  1. If the active vendor supports code-switched multi-language detection within a single submission, that capability is used, and TranscriptSegment.languageCode (an optional per-segment override, defaulting to the transcript-level languageCode when absent) is populated per segment.
  2. If the active vendor does not support code-switching, the transcript is produced entirely in the single dominant language detected from the first 30 seconds of speech. Segments spoken in a different language will transcribe with visibly low confidence; those words are flagged for review by the normal confidence threshold (12.12), and the transcript view surfaces a banner: "This recording may contain multiple languages. Detected: {language}. Some words may be inaccurate." No automatic re-submission is triggered — the user may manually re-run transcription with an explicit languageHint for a full single-language pass.

12.4 Speaker Diarization #

Diarization runs automatically, in the same vendor call as transcription (12.1), for every recording that has an audio track — there is no separate opt-in step and no separate billing line (its cost is bundled into the per-minute transcription cost in 12.11). It is skipped only when the recording has no audio track at all (silent screen recording), in which case transcripts.diarization_status = 'not_applicable'.

12.4.1 Speaker labeling #

export interface TranscriptSpeaker {
  id: string;              // stable label used in TranscriptWord.speaker, e.g. "speaker_1"
  displayName: string;     // user-facing name, defaults to "Speaker 1", "Speaker 2", ...
  firstAppearanceMs: number;
}

Speakers are numbered in order of first spoken word in the source timeline (speaker_1 is whoever speaks first), not by vendor-assigned cluster id, so numbering is stable and predictable across re-transcriptions of the same audio. The default displayName is "Speaker {n}" where n is the 1-based position in the speakers array.

12.4.2 Renaming a speaker #

PATCH /v1/videos/{videoId}/transcript/speakers/{speakerId} with body { "displayName": "Priya" } updates only the TranscriptSpeaker.displayName value stored on the transcript row. Word-level TranscriptWord.speaker references ("speaker_1") are never rewritten — renaming is an O(1) metadata update, not a bulk rewrite of every word in the transcript, and it applies retroactively everywhere the label is displayed (transcript view, captions if speaker names are shown, exported SRT/VTT if the caption style option "show speaker names" is on, per 12.5).

displayName validation: 1–60 characters, trimmed, no control characters. Duplicate display names across speakers in the same transcript are allowed (two people can legitimately share a first name) and are not an error.

12.5 Captions #

12.5.1 WebVTT generation from the word-level transcript #

Captions are generated deterministically from TranscriptSegment.words — never from TranscriptSegment.text directly, so that every cue carries real per-word timing rather than a single evenly-divided span. Before the word stream reaches the cue builder, it is filtered against active muted-audio redaction regions per 12.5.7 — a word that was stripped there never has a chance to become part of a cue. The generator (captions.generate / captions.rederive) then walks the flattened, filtered word stream and produces an ordered list of CaptionCue objects:

export interface CaptionCue {
  index: number;         // 1-based, VTT cue order
  startMs: number;
  endMs: number;
  lines: string[];       // 1 or 2 strings, already line-broken
  speaker: string | null; // TranscriptSpeaker.id, if the caption style shows speaker names (12.5.5)
}

12.5.2 Line-breaking and cue-duration parameters #

Parameter Value
Max characters per line 42
Max lines per cue 2
Min cue duration 1000 ms
Max cue duration 7000 ms
Reading-speed cap 20 characters/second
Min gap between consecutive cues 84 ms (2 frames at 24fps-equivalent, avoids overlapping cue ambiguity in players)

12.5.3 Cue-building algorithm #

  1. Start a new cue at the next unconsumed word.
  2. Greedily add words to the current line while line.length + 1 + word.punctuatedText.length <= 42 (the +1 accounts for the joining space). Prefer to break a line at a clause boundary (comma, semicolon, dash) or sentence boundary if one falls within the last 10 characters of the limit; otherwise break at the last word that fits.
  3. A cue holds at most 2 lines. When a second line would be needed and the cue already has 2 lines, close the current cue and start a new one at the next word.
  4. Cue timing: cue.startMs = firstWord.startMs, cue.endMs = lastWord.endMs.
  5. Apply the reading-speed cap: compute charCount = sum(lines.map(l => l.length)). If charCount / ((cue.endMs - cue.startMs) / 1000) > 20, extend cue.endMs up to cue.startMs + max(minCueDuration, charCount / 20 * 1000), capped at maxCueDuration (7000 ms). If even the max duration cannot satisfy the reading-speed cap for the accumulated text, the cue is split one word earlier and the algorithm backtracks to step 2 with a smaller word set.
  6. Enforce minCueDuration: if cue.endMs - cue.startMs < 1000, extend cue.endMs to cue.startMs + 1000, but never past nextCue.startMs - 84ms (the minimum inter-cue gap); if the next word starts too soon to allow the 1000 ms floor, the two cues are merged into one instead.
  7. Sentence and segment boundaries are preferred cue-break points: the algorithm will end a cue early (before hitting the character limit) if doing so aligns the cue boundary with a TranscriptSegment boundary and the resulting cue is still ≥ minCueDuration.

This produces a .vtt file with standard WEBVTT header and HH:MM:SS.mmm --> HH:MM:SS.mmm cue timing lines computed directly from startMs/endMs (millisecond-precision throughout, per Section 7's duration convention).

12.5.4 Re-derivation when the EDL changes #

Captions must stay in sync with cuts made in the timeline editor (Section 11). Whenever a video's EditDecisionList is saved, captions.rederive runs:

  1. Apply the muted-audio redaction filter (12.5.7) to the original word-level transcript first, removing any word whose [startMs, endMs) range overlaps a RedactionRegion with muteAudio = true — this runs on every re-derivation, not only the first generation, so a redaction region added or changed after captions already exist is reflected the next time the EDL is saved (12.5.7 also covers the case where redaction changes without an EDL change, via captions.rederive's own trigger on redaction updates).
  2. For every remaining word in the filtered word-level transcript, test its [startMs, endMs) range against the EDL's kept source-time ranges (Section 11 owns the EDL schema; the render worker's source-to-output time-mapping function is the single implementation of this mapping and is reused here rather than reimplemented).
  3. A word entirely outside every kept range is dropped from the caption input.
  4. A word that straddles a cut boundary is clipped to the portion inside the kept range.
  5. Every remaining word's timestamp is translated from source-timeline milliseconds to output-timeline milliseconds using the same cumulative-offset mapping the render worker applies to produce the final video, guaranteeing frame-accurate sync between the rendered cuts and the regenerated captions.
  6. The cue-building algorithm (12.5.3) runs again over the remapped word stream, producing a new caption set tagged with the EDL version it was derived from (captions.edl_version).

This job is idempotent per (videoId, edlVersion) — saving the same EDL twice does not regenerate captions twice. The original, un-cut transcript (12.2) is never modified by this process; only the derived caption artifact changes.

12.5.5 Default state, editing, and styling #

Captions are on by default in the embeddable player (Section 15 owns the player itself; this is the stated default: a freshly published video plays with captions visible unless the viewer or the video owner turns them off).

Caption editing UI (in the video editor, alongside the transcript view): each cue is independently editable —

  • Text correction: editing cue text edits the underlying transcript words it was built from (a human correction, tracked per 12.10/11.6, not an AI action).
  • Timing nudge: startMs/endMs can be nudged in 100 ms increments, clamped so a cue never starts before the previous cue's endMs + 84ms gap or ends after the next cue's startMs - 84ms gap.
  • Speaker label toggle: per-workspace style setting, "Show speaker names in captions" (off by default); when on, cues are prefixed [DisplayName]:.
  • Manual re-split/merge: a user can force-split a cue at a word boundary or merge two adjacent cues; this produces a manual override flag (caption_cues.manually_edited = true) that captions.rederive respects by re-applying the same split/merge points (matched by nearest word) after an EDL change, rather than silently discarding manual work on every re-derivation.

Caption styling options (applied at playback, not burned into the underlying data): font family (from a fixed web-safe set, or the workspace brand kit font on Pro/Business, Section 18), font size (small / medium / large), text color, background opacity (0–100%), and position (bottom or top of frame, to avoid covering on-screen UI elements being demonstrated).

12.5.6 Burned-in versus sidecar captions for exports #

Export path Caption behavior
Embed player / watch page (Section 15) Sidecar VTT, rendered by the player, respects the styling options in 12.5.5. This is the default and the common case.
MP4/WebM/GIF export (Section 27) Sidecar by default (a .vtt/.srt file offered alongside the video download). Burned-in is an explicit export option (burnInCaptions: true) that invokes the same FFmpeg subtitle-burn filter chain used for redaction burn-in (Sections 11.7 and 22.2), producing captions permanently rendered into the video pixels — required for platforms that don't support sidecar tracks (e.g. sharing a raw MP4 into a chat app).
GIF export Always sidecar-incompatible (GIF has no subtitle track) — burned-in is the only option when captions are requested for a GIF export, and is applied automatically if the export request includes includeCaptions: true.

12.5.7 Redaction interaction: muted-audio regions strip overlapping words #

Redaction (Section 11.7 owns the editor behavior and render contract; Section 22.2 owns the security property) is visual by default — a RedactionRegion blurs a spatial rect or keyframed track in the rendered video. Section 11.7 also defines an audio companion flag, RedactionRegion.muteAudio: boolean: when set, the region's source-time span ([startMs, endMs) — for a keyframed/moving region, the audio decision still uses a simple time range; the spatial keyframes are irrelevant to muting) is silenced in every delivered rendition and export, exactly as the visual blur is burned in.

Transcription runs on the full, unredacted original audio as part of the parallel pipeline (12.1) — it starts before a user has necessarily added any redaction in the editor at all. This means the raw transcripts/transcript_segments word data (12.2) can legitimately contain words spoken during a time range that later becomes a muted redaction region: a password read aloud, a customer's name, anything the user blurs specifically because it should not be exposed. If those words remained visible anywhere as text, redacting the video's audio and picture would be cosmetic only — a blurred password that still appears in the caption text or the searchable transcript is a real data leak, not a hardened one, and Reelay treats it as such.

The rule, applied everywhere transcript words reach a human or a downstream consumer:

  • Caption generation (12.5.1) and re-derivation (12.5.4): before the cue-building algorithm (12.5.3) runs, every word whose [startMs, endMs) overlaps any RedactionRegion with muteAudio = true for that video is removed from the word stream. The cue builder never sees these words, so no caption cue can contain them. This filter runs on both the initial captions.generate path and every captions.rederive pass — a redaction region added, changed, or removed re-triggers captions.rederive even when the EDL itself is unchanged, so captions always reflect the current redaction state, not a stale one from before the redaction existed.
  • The transcript view and transcript export (12.6): the server-side transcript-read path that backs the editor's transcript panel and the GET /v1/videos/{videoId}/transcript download endpoint applies the identical filter before the data ever leaves the server. The client-side search and click-to-seek behavior in 12.6 operate only over this already-filtered word/segment array, so a muted-audio redacted word cannot be searched, displayed, copied, or downloaded through the transcript surface. The underlying transcript_segments.words storage (Section 5) is not mutated by this filter — the original word data is retained for audit and for correctly re-deriving captions/transcript views if a redaction region is later edited or removed — but every reader-facing path filters it live against the current redaction_regions state.
  • AI chapters, summaries, titles, and descriptions (12.7, 12.8): the transcript text assembled for every LLM prompt in this section is built from the same filtered word/segment stream, so muted-audio content cannot surface through a generated chapter title, summary sentence, or suggested title/ description either.

Filtering is keyed specifically off muteAudio = true — a RedactionRegion with muteAudio unset or false (a purely visual blur, audio untouched) does not remove any words; the corresponding audio is legitimately still audible in the delivered video, so the transcript, captions, and AI text outputs correctly continue to include it.

12.6 The Transcript View #

The transcript view is a panel in the editor and (read-only) on the watch page, rendering the ordered TranscriptSegment[] with per-word timing available for interaction.

  • Search within transcript: client-side, case-insensitive substring and light fuzzy matching (typo tolerance of 1 edit for words ≥ 5 characters) performed against the already-loaded word/segment array — no server round-trip for a single video's transcript search. Matches are highlighted inline and listed as a jump list (3 of 17 matches) with next/previous navigation.
  • Click-to-seek: clicking any word seeks the player to that word's startMs and highlights the word as active while the player's current time falls within [startMs, endMs); the active word auto-scrolls into view during playback.
  • Copy: copies the visible (or search-filtered) transcript as plain punctuated text, with an option to include [MM:SS] timestamp markers at each segment boundary.
  • Download: GET /v1/videos/{videoId}/transcript?format=vtt|srt|txt. These three format variants are the one documented exception to the standard JSON success envelope (Section 7.4): the response Content-Type is text/vtt, application/x-subrip, or text/plain respectively, and the body is the raw file, because the client-facing use case is "save/open this file," not "consume this JSON." Every other transcript-related endpoint uses the standard envelope.

12.6.1 Redaction interaction #

Transcript words overlapping a muted-audio redaction region (RedactionRegion.muteAudio = true, Section 11.7) are stripped server-side, per 12.5.7, before this view — and its search, click-to-seek, copy, and download paths — ever receives the data. The transcript view never displays, searches, copies, or exports a muted-audio redacted word: the filtering happens in the API response the view is built from, not as a client-side hide, so there is no code path (including the raw ?format=txt download) that can surface it. Visually redacting a video's picture and audio without also removing its words from the transcript surface would leave the redacted content fully readable as text; Reelay treats that as an unacceptable data leak, not an accepted trade-off, which is exactly why the filter runs at the server-side read path shared by every consumer of transcript data rather than being left to each UI surface to apply independently.

12.7 AI Chapters #

AI chapter generation is gated to Pro and Business plans (12.11) and runs automatically once a transcript successfully ingests, subject to the minimum-length gate in 12.7.2.

12.7.1 Input #

The prompt receives: the full segment-level transcript with timestamps (12.2, filtered per the muted-audio redaction rule in 12.5.7), the video's total durationMs, and — when available — scene-change signals: timestamps where the AI auto-editing engine (Section 10) detected a window-focus change or a large cursor-context jump, used as weak hints for topic boundaries, not as authoritative cut points.

12.7.2 Chapter count heuristics by video length #

Videos under 3 minutes are not chaptered at all (chapters.generate short-circuits, no LLM call is made, no cost is incurred) — a 3-minute video does not benefit from being segmented.

Video duration Target chapter count
< 3 min not chaptered
3–8 min 2–3
8–20 min 4–6
20–45 min 6–10
45–90 min 10–16
> 90 min 1 chapter per ~6 minutes of runtime, capped at 40 total

targetChapterCount is computed server-side from this table before the prompt is built and passed to the model as a target, not a hard constraint — the model may return one fewer or one more if content clearly warrants it, but the output validator (12.7.4) rejects results that deviate by more than 2 from the target for videos under 90 minutes.

12.7.3 Prompt template #

SYSTEM:
You are a video chapter generator. You segment a spoken-word transcript into topical chapters.
You must not alter, invent, or omit any spoken content — you only identify boundaries and write
short chapter titles that describe what is discussed in that span. Respond with strict JSON
matching the provided schema. Do not include commentary outside the JSON object.

USER:
VIDEO_DURATION_MS: {{durationMs}}
TARGET_CHAPTER_COUNT: {{targetChapterCount}}
MIN_CHAPTER_DURATION_MS: {{minChapterDurationMs}}
SCENE_CHANGE_SIGNALS_MS: {{sceneChangeSignalsJson}}

TRANSCRIPT (format "[startMs] speaker: sentence"):
{{transcriptWithTimestamps}}

Identify chapter boundaries. Each chapter must start at or within 500ms after a sentence
boundary already present in the transcript above. Prefer a boundary that falls within 3000ms
of an entry in SCENE_CHANGE_SIGNALS_MS when a topic shift is already evident there. Each
chapter must be at least MIN_CHAPTER_DURATION_MS long. The first chapter must start at 0.

Return JSON only, matching this schema exactly:
{ "chapters": [ { "title": string (max 60 chars, no trailing punctuation),
                  "startMs": integer,
                  "summary": string (max 200 chars) } ] }

12.7.4 Output schema and validation #

import { z } from 'zod'; // Zod, version per Section 3

export const ChapterSuggestionSchema = z.object({
  chapters: z.array(z.object({
    title: z.string().min(1).max(60),
    startMs: z.number().int().nonnegative(),
    summary: z.string().max(200),
  })).min(1),
});
export type ChapterSuggestion = z.infer<typeof ChapterSuggestionSchema>;

The raw model response is parsed as JSON, then validated with ChapterSuggestionSchema.safeParse. Additional server-side checks beyond the Zod shape: chapters sorted ascending by startMs; first chapter's startMs === 0; no two chapters within minChapterDurationMs of each other; count within the tolerance band of 12.7.2. Any failure (malformed JSON, schema mismatch, or a business-rule violation) triggers one repair attempt: the model is re-prompted with the original prompt plus "Your previous response was invalid: {{validationError}}. Return corrected JSON only." After 2 failed repair attempts total, the job falls back to a single chapter — { title: "Full Recording", startMs: 0, summary: "" } — and logs an ai_generation_failed event (Section 24) for operational visibility. The fallback is still created as a normal suggestion subject to the human-acceptance flow in 12.9, never auto-published.

12.7.5 Minimum chapter duration and sentence-boundary snapping #

Minimum chapter duration is 20,000 ms (20 seconds). If the validator (12.7.4) finds a chapter shorter than this after boundary snapping, it is merged into the following chapter server-side before the suggestion is persisted (the merge concatenates summaries with "; " and keeps the earlier title).

Sentence-boundary snapping runs server-side, independent of the prompt's own instruction to the model (the prompt reduces how often snapping is needed; snapping guarantees it is always exact): for each proposed startMs, find the nearest TranscriptSegment boundary (12.2.1) within a window of ±1500 ms and snap to it. If no segment boundary falls within the window, snap to the nearest individual word boundary instead — a chapter boundary never lands mid-word.

12.8 AI Summaries and AI Suggested Titles/Descriptions #

Gated identically to AI chapters (12.11), generated in the same fan-out as chapters once a transcript ingests.

12.8.1 Summary prompt template #

SYSTEM:
You are a video summarizer. You write a neutral, factual summary of what is said and shown in
a screen recording, based only on the transcript provided. You never invent details not present
in the transcript. You never alter quoted content.

USER:
TONE: {{tone}}
TARGET_LENGTH_WORDS: 150-300
TRANSCRIPT:
{{fullTranscriptText}}

Write a summary in {{tone}} tone, 150 to 300 words, third person, describing what the recording
covers. Do not use first person. Do not fabricate names, numbers, or claims not present in the
transcript. Return JSON only: { "summary": string }

tone is one of neutral (default), professional, casual, enthusiastic, selectable per-generation by the user from the AI metadata panel; regenerating with a different tone creates a new suggestion (12.9) rather than mutating the prior one. {{fullTranscriptText}} is built from the same muted-audio-redaction- filtered word stream defined in 12.5.7.

export const SummarySuggestionSchema = z.object({
  summary: z.string().min(1),
});

Server-side post-validation: word count between 120 and 340 (a tolerance band around the 150–300 target, since the model's own count and a whitespace-split count can differ slightly); outside that band triggers the same one-shot repair-then-fallback pattern as 12.7.4, with the fallback being the first 200 words of the raw transcript text, clearly flagged as promptVersion: 'fallback' so it is visually distinct in the suggestion history if ever inspected.

12.8.2 Title/description prompt template #

SYSTEM:
You generate suggested titles and descriptions for a screen recording based on its transcript
and chapter list. You never fabricate content not evidenced by the transcript.

USER:
TRANSCRIPT_EXCERPT (first 2000 words):
{{transcriptExcerpt}}
CHAPTER_TITLES:
{{chapterTitlesJson}}

Generate exactly 3 title options (each ≤ 60 characters, no clickbait, descriptive) and 1
description (≤ 300 characters, plain text, no markdown, third person). Return JSON only:
{ "titles": [string, string, string], "description": string }
export const TitleDescriptionSuggestionSchema = z.object({
  titles: z.tuple([z.string().max(60), z.string().max(60), z.string().max(60)]),
  description: z.string().max(300),
});

Title and description are generated together (one model call, one suggestion of kind 'title' bundled with a sibling suggestion of kind 'description' sharing a generationBatchId) because titles are more useful when written with awareness of the description being proposed alongside them, and vice versa; they are still accepted/edited/rejected independently in the UI (12.9) — a user may accept a suggested title while rejecting its paired description.

12.9 The Human-Acceptance Invariant #

Every AI-generated artefact in this section — chapters, summary, title, description — is a suggestion, stored separately from whatever value is actually shown to viewers. Nothing an LLM produces becomes the video's public metadata without a human explicitly accepting it. This is a product invariant, not a configurable setting, and it is the authority every other section defers to on this point.

12.9.1 Data model: suggestion versus accepted value #

Section 5.4 is canonical for the exact DDL of ai_metadata_suggestions, reconciled to match the contract below exactly; this section is the authority for that contract — every part of the system (worker, API, editor UI) reads and writes exactly this shape.

kind — exactly four values, one per AI-generated artefact type:

kind Produced by Written to on acceptance
chapters chapters.generate (12.7) chapters rows (Section 5), source = 'ai_accepted'
summary ai-metadata.generate (12.8.1) videos.summary
title ai-metadata.generate (12.8.2) videos.title
description ai-metadata.generate (12.8.2) videos.description

status — exactly six values:

status Meaning Terminal?
pending Generated, awaiting human review. The only status from which accept, edit_and_accept, reject, or regenerate (12.9.2) may be invoked. No
accepted Accepted verbatim; content copied into the owning entity. Yes — a later regenerate creates a new, independent pending row rather than transitioning this one
edited_and_accepted User edited content before accepting; editedContent copied into the owning entity. Yes, same as accepted
rejected Explicitly dismissed. Owning entity untouched. Yes, except regenerate may still be invoked from this state (12.9.2), producing a new row
superseded A newer suggestion of the same kind replaced this one before it was reviewed — the result of calling regenerate while this row was pending, rejected, or failed. Yes
failed Generation hard-failed after the repair-then-fallback sequence (12.7.4/12.8.1) produced no usable content at all. Note: the repair-and-fallback path itself still produces a pending suggestion (the fallback content, e.g. "Full Recording" or the truncated-transcript summary) — failed is reserved for the harder case where the vendor/model call errors out with nothing to fall back to. Yes, except regenerate may still be invoked from this state

Column set:

export type AiMetadataKind = 'chapters' | 'summary' | 'title' | 'description';
export type AiMetadataSuggestionStatus =
  | 'pending'              // generated, awaiting review
  | 'accepted'             // accepted verbatim
  | 'edited_and_accepted'  // user edited content before accepting
  | 'rejected'             // explicitly dismissed, never applied
  | 'superseded'           // a newer suggestion of the same kind replaced this one before review
  | 'failed';              // hard generation failure, no fallback content produced

export interface AiMetadataSuggestion {
  id: string;                    // uuid
  videoId: string;
  kind: AiMetadataKind;
  status: AiMetadataSuggestionStatus;
  content: unknown;              // ChapterSuggestion | { summary: string } | { titles: [string,string,string] } | { description: string }, per kind
  editedContent: unknown | null; // same shape as content, populated only for edited_and_accepted
  modelProvider: string;
  modelName: string;
  promptVersion: string;         // e.g. "chapters.v1"
  generationBatchId: string | null; // links paired title+description generations (12.8.2)
  generatedAt: string;           // ISO 8601 UTC
  reviewedAt: string | null;
  reviewedByUserId: string | null;
}

State machine — from generation to a terminal (or re-generatable) state:

From Event To
— (no prior row) generation succeeds (raw output, or a repaired/fallback result per 12.7.4/12.8.1) pending
— (no prior row) generation hard-fails, no fallback content produced failed
pending accept (12.9.2) accepted
pending edit_and_accept (12.9.2) edited_and_accepted
pending reject (12.9.2) rejected
pending regenerate (12.9.2) this row → superseded; a new row is created pending
rejected regenerate this row → superseded; a new row is created pending
failed regenerate this row → superseded; a new row is created pending
accepted / edited_and_accepted regenerate this row is untouched and remains accepted/edited_and_accepted; a new, independent row is created pending alongside it — accepting that new row is what changes the published value

accepted, edited_and_accepted, rejected, superseded, and failed are the only statuses a row can be left in; there is no path back to pending for an existing row — every retry or regeneration produces a fresh row.

The accepted value for each kind lives on its owning entity, never inside the suggestion row itself: accepted chapters become rows in the chapters table (Section 5) with source = 'ai_accepted'; accepted title and description are written to videos.title and videos.description; accepted summary is written to videos.summary (a field distinct from description, surfaced in library previews and digest emails, Section 17/18). A suggestion is inert data until this copy happens.

The database-level guard. The human-acceptance invariant is enforced not only by the API's action handlers (12.9.2) but by a trigger at the database layer, so the invariant holds even against a write that bypasses the API entirely. A trigger function, enforce_ai_metadata_human_acceptance() (DDL owned by Section 5.4; attached BEFORE INSERT OR UPDATE on chapters where source = 'ai_accepted', and BEFORE UPDATE OF title, description, summary on videos where the corresponding source-tracking column is set to 'ai_accepted'), requires the write to carry a reference to a row in ai_metadata_suggestions whose status is accepted or edited_and_accepted and whose kind and video_id match the write in progress. A write that claims AI origin without a matching accepted or edited-and-accepted suggestion raises ai_metadata_acceptance_violation and aborts the transaction. This makes the invariant directly testable at the database layer, independent of API code: an integration test can attempt the forbidden direct write in isolation (no HTTP layer involved) and assert that the transaction fails.

The product statement is explicit and unconditional, and is now backed by both an application-layer contract (12.9.2) and this database-layer guard: no AI output becomes a video's public metadata without a human acceptance actionaccept or edit_and_accept, and nothing else. Generation only ever populates a suggestion row; it never writes to videos.title, videos.description, videos.summary, or the chapters table directly.

12.9.2 Accept / edit / reject / regenerate flows #

PATCH /v1/videos/{videoId}/ai-metadata-suggestions/{suggestionId}

{ "action": "accept" }

or

{ "action": "edit_and_accept", "editedContent": { "summary": "..." } }

or

{ "action": "reject" }

or

{ "action": "regenerate" }
Action Effect
accept status → accepted; content copied verbatim into the owning entity (12.9.1); reviewedAt/reviewedByUserId set.
edit_and_accept editedContent validated against the same Zod schema as content for that kind; status → edited_and_accepted; editedContent (not content) copied into the owning entity.
reject status → rejected; owning entity is untouched; the suggestion remains visible in history but collapsed by default.
regenerate Only valid when status is pending, rejected, or failed. Enqueues a new chapters.generate/ai-metadata.generate job; the current suggestion's status → superseded; a new suggestion row is created pending. Accepted/edited-and-accepted suggestions are never regenerated over — regenerating after acceptance creates a new pending suggestion alongside the still-accepted one, and accepting the new one is what changes the published value.

Regeneration is rate-limited to 5 calls per (videoId, kind) per rolling 24h window (cost control, 12.11); exceeding it returns 429 with error code ai_regeneration_limit_exceeded (Section 7.6 envelope).

12.9.3 UI states #

State Editor UI treatment
pending Suggestion panel shows the AI content with an "AI suggestion — review" badge, Accept / Edit / Reject / Regenerate actions, and the accepted value (if one already exists from a prior cycle) still shown as the live published value elsewhere in the UI.
accepted Badge "Accepted"; content matches the published value exactly.
edited_and_accepted Badge "Edited & accepted"; a small diff affordance can show the original AI content versus the published editedContent.
rejected Collapsed under "Suggestion history," not shown by default.
generating (transient, not a stored status) Spinner in the suggestion panel; if a prior accepted value exists it continues to display, unaffected, until the new suggestion arrives.
failed Error banner: "AI generation failed. [Retry]" — Retry calls the regenerate action.

The product statement is explicit and unconditional: AI output never becomes a video's public metadata without a human accepting it. Generation populates a suggestion; only accept or edit_and_accept writes to the fields viewers actually see.

12.10 The Fabrication Boundary, Restated for AI Metadata #

Section 11.6 states the editing-side invariant: AI assists editing decisions (what to cut, how to frame) but never alters spoken words, and this section aligns with it exactly for the metadata surface. The AI described in 12.7 and 12.8 summarizes and titles a recording; it never rewrites, paraphrases, or substitutes any word inside the transcript itself, and it never touches the underlying audio. Concretely:

  • Chapter summary text (12.7) and video summary/description text (12.8) are generated about the transcript; they are stored as separate fields (12.9.1) and are never written back into TranscriptWord/TranscriptSegment data.
  • A correction to transcript text (fixing a misheard word in the caption editor, 12.5.5) is always a human edit. It is tracked with editedByUserId, editedAt, and the previous value retained in an edit history, exactly as Section 11.6 specifies for timeline edits — the same audit posture, not a separate one invented for this section.
  • There is no text-to-speech path anywhere in this section, no mechanism that regenerates audio from corrected text, and no reordering of speech. Silence and filler-word removal (Section 11.5) operate on source-time ranges via the EDL (Section 11) — they change what is played, never what was said.

12.11 Cost Control #

12.11.1 Per-minute vendor cost #

Vendor Approx. cost per audio minute Notes
Deepgram Nova (default) $0.0043 Async batch tier; diarization included, no separate line item
Whisper via Groq $0.0011 Lowest cost; used as automatic failover (12.13) and for cost-sensitive workspaces
AssemblyAI $0.015 Highest cost of the three; used as a secondary failover and for language coverage gaps

These figures are indicative vendor list pricing at time of writing and must be reverified against each vendor's current pricing page before launch and periodically thereafter; because every vendor sits behind the TranscriptionProvider interface (12.1.2), changing the default in response to pricing changes is a configuration change, not a code change.

12.11.2 Caching #

A transcript is never regenerated for the same audio content: transcription.request's idempotency key includes a content hash of the extracted audio (not just the video id), so re-processing the same source (e.g. after a metadata-only edit that doesn't touch the EDL) reuses the existing transcript. AI chapter and metadata suggestions are cached per (videoId, kind, promptVersion, transcriptContentHash) — the same inputs never trigger a duplicate paid model call; only an explicit regenerate action (12.9.2) forces a new call.

12.11.3 Plan gate #

AI chapters, AI summaries, and AI suggested titles/descriptions are Pro and Business only (matching the plan table in Section 21). Transcription and captions themselves are available on every plan, including Free — they are treated as an accessibility baseline (Section 23), not a premium upsell.

Free-tier users see: the transcript view and captions exactly as described in 12.5/12.6, plus a locked "AI Chapters, Summary & Title" panel in the editor with a blurred example and an upgrade call-to-action. Free users may still add manual chapter markers — a user-authored chapter list with no AI involvement, stored in the same chapters table (Section 5) with source = 'manual' — since that capability doesn't depend on the AI pipeline at all.

12.11.4 Token and duration limits #

Transcription itself has no duration limit beyond the plan's recording-length cap (Section 21: 5 minutes Free, 4-hour soft cap Pro/Business) — vendors handle long-form audio natively. LLM-based chapter/summary generation is bounded by model context: for recordings under roughly 60 minutes, the full transcript is passed to the prompt directly. Beyond that, a map-reduce strategy applies (detailed in 12.13.5): the transcript is split into ~30-minute chunks, each is summarized independently, and a final reduce pass generates chapters/summary/title from the concatenated chunk summaries plus the full timestamp index (so chapter startMs values remain accurate to the original timeline even though the model never sees the full raw transcript in one call).

12.12 Quality Assurance #

12.12.1 Measuring transcription accuracy #

There is no per-account ground-truth transcript to diff against, so accuracy is tracked two ways:

  1. Continuous, automatic: median and p10 word confidence per transcript are computed at ingest time and rolled up per vendor, per language, per day into an operational metrics table (Section 24 owns observability rollups). A sustained drop in median confidence for a given vendor/language pair is an early signal of a vendor-side regression.
  2. Periodic, manual: an operations sample (a fixed monthly count per supported language, drawn from consenting workspaces or synthetic test recordings) is manually transcribed and diffed against the vendor output to compute word error rate (WER). Trended over time, this is the basis for any decision to change the default vendor or per-language vendor routing.

12.12.2 Confidence threshold and review flagging #

A word with confidence < 0.55 is flagged for review in the transcript and caption editing UI (dotted red underline, per 12.5.5), independent of which vendor produced it. This threshold applies uniformly across languages and tiers (12.3.2) rather than being tier-adjusted — a low-confidence word deserves a human look regardless of which language it's in. Flagged words do not block caption generation or playback; they are a review affordance only, never an error state.

12.12.3 Feedback loop #

Every manual correction made in the transcript/caption editor emits a transcript.word_corrected event (old text, new text, original confidence, language, vendor — no content beyond the single word and its surrounding segment, scoped to the workspace). These events are aggregated monthly, grouped by (vendor, language), and reviewed against the WER trend from 12.12.1 to decide whether the default vendor or per-language routing should change. This is a vendor-selection feedback loop, not per-account model fine-tuning — no workspace's corrections change transcription behavior for any other workspace, or even for that workspace's own future recordings.

12.13 Failure Handling #

Scenario Detection System behavior User-facing state
Vendor outage transcription.request exhausts its 5 attempts One automatic failover: resubmit through the next registered TranscriptionProvider in priority order (Deepgram → Groq Whisper → AssemblyAI, skipping whichever vendor just failed) before giving up "Transcribing…" persists during failover; if failover also exhausts, transcription_status = 'failed' and the video plays without a transcript/captions, with a "Retry transcription" action
Unsupported language Vendor returns an explicit unsupported-language error, or languageConfidence stays near-zero across multiple detected candidates No further vendor retries (retrying won't fix a genuinely unsupported language); transcription_status = 'language_unsupported' Video plays without captions; editor offers "Upload your own SRT/VTT" as a manual fallback, stored as a sidecar with source = 'manual_upload'
Silent audio Vendor returns a result with zero or near-zero words relative to durationMs (heuristic: fewer than 1 word per 15 seconds of audio) Treated as success, not failure: transcription_status = 'completed', captions_status = 'skipped_no_speech'; no AI chapters/summary attempted (12.7.2's "not chaptered" logic extends to any transcript this sparse regardless of duration) No captions shown, no error banner — this is expected behavior for a silent screen recording
Audio-only recordings Video has an audio track but a degenerate/blank video track (e.g. a static screen, or a recording mode with no visual capture) Full pipeline runs unchanged: transcript, captions, AI chapters/summary/title all generate normally since they depend only on audio Poster/thumbnail generation (Section 9) falls back to a generated waveform image instead of a video frame; unrelated to transcription itself
Extremely long recordings (approaching the 4h soft cap) durationMs exceeds 60 minutes Vendor submission proceeds normally (vendors handle multi-hour audio); AI chapter/summary generation switches to the map-reduce strategy from 12.11.4: transcript split into ~30-minute chunks (split points snapped to the nearest segment boundary, never mid-sentence), each chunk summarized independently with the same summary prompt (12.8.1) scoped to that chunk, then a final reduce pass runs the chapter prompt (12.7.3) against the concatenated chunk summaries plus the original full timestamp index so startMs values stay accurate to the source timeline No difference to the user beyond generation taking longer; the editor shows normal "Generating…" progress throughout

13. Screenshot Capture & Beautification #

13.1 Capture Surfaces #

Screenshots are a first-class capture type alongside video, sharing the beautification and annotation pipeline below but producing a static image rather than a timed recording.

Surface Posture
Browser extension Chrome/Edge extension (Manifest V3) using chrome.tabs.captureVisibleTab for visible-viewport capture and a content-script-driven overlay for region selection within the page. Requires the extension to be installed separately from the web app; the web app detects its presence via a window.postMessage handshake and shows an "Install extension" prompt when absent. The extension can capture the current tab's visible content and, via chrome.desktopCapture, a full screen or window — it cannot capture content outside the browser (that is the desktop app's role, matching the capture-matrix boundary established in Section 8).
Desktop app hotkey Electron 43 app (Section 8) registers a global, user-configurable hotkey (default Cmd+Shift+2 macOS / Ctrl+Shift+2 Windows) that immediately opens the capture-mode selection overlay (13.1's full-screen/window/region modes) without requiring the app window to be focused. Uses native OS screenshot APIs for pixel-accurate capture (macOS: ScreenCaptureKit still-image capture; Windows: Windows.Graphics.Capture), not a frame grabbed from a video stream.
In-app upload Drag-and-drop or file-picker upload of an existing PNG/JPEG/WebP file directly into the dashboard, entering the same beautification pipeline (13.2) as a captured screenshot. Max upload size 25 MB, max input dimension 8192px per side (downscaled with a warning if exceeded).
Full-screen mode Captures the entire primary (or selected, on multi-display setups) display at native resolution.
Window mode Presents a picker of currently open application windows with live thumbnails (desktop app only — browser extension window mode is limited to browser windows via chrome.desktopCapture's window picker); captures the selected window's bounds, cropped to its actual visible rect (excluding OS-level drop shadows).
Region mode Crosshair drag-to-select an arbitrary rectangular region of a display; the selection overlay shows live pixel dimensions while dragging and snaps to visible UI element edges when the cursor is within 6px of one, to make clean edge-aligned captures easy.

13.2 The Beautification Pipeline #

Beautification wraps a raw capture in a styled frame. Background presets are shared verbatim with the video engine's background catalogue (Section 10.9 owns the full preset list — gradient set, solid, image, blurred-screenshot, macOS-style desktop); this section does not redefine that catalogue, only how it applies to a static image.

Parameter Default Range
Padding (% of the longer output edge) 8% 0–20%
Corner radius 12px (scaled proportionally for output edges > 1600px) 0–40px
Shadow offset (0, 24px), blur 48px, spread 0px, opacity 22% — identical spec vocabulary to Section 10's shadow spec preset-selectable: none / soft / hard
Inset border 1px, 8% white/black (auto-selected for contrast against the background) on/off

Window chrome (applied to the captured content before background/padding): none (raw content), macos (traffic-light dots top-left, optional title text), windows (title bar with minimize/maximize/ close glyphs top-right), browser-frame (a browser chrome bar with an editable URL field — pre-filled with the captured page's URL when the capture came from the extension, blank and user-editable otherwise).

Device frames: optional presets that wrap the content in a bezel — laptop (matches the aspect ratio of common laptop screens, content is letterboxed to fit if the capture's aspect ratio differs), phone (portrait bezel, intended for capturing mobile-emulator or phone-mirrored screenshots). Device frames and window chrome are mutually exclusive (a screenshot has one or the other, not both).

Balance / auto-crop rules: before padding is applied, the pipeline computes the content's actual bounding box by trimming uniform-color border regions (a common artifact of window captures with OS letterboxing) using a flood-fill from each edge with a tolerance of 3 (out of 255) per color channel, stopping the trim as soon as non-uniform content is found. The trimmed content is then centered within the padded canvas. Auto-crop can be disabled per-screenshot (autoCrop: false) if a user intentionally wants to preserve a border.

13.3 Annotation Tools #

Annotations are stored as an ordered, non-destructive layer array on the screenshot (Section 5.4 owns the screenshots table; the layer array is its annotations jsonb column), so a user can return and edit annotations after saving, the same non-destructive philosophy Section 11 establishes for video edits.

export type AnnotationLayer =
  | { type: 'arrow'; id: string; start: Point; end: Point; color: string; strokeWidthPx: number }
  | { type: 'rectangle'; id: string; rect: Rect; color: string; strokeWidthPx: number; fillColor: string | null }
  | { type: 'ellipse'; id: string; rect: Rect; color: string; strokeWidthPx: number; fillColor: string | null }
  | { type: 'freehand'; id: string; points: Point[]; color: string; strokeWidthPx: number }
  | { type: 'text'; id: string; position: Point; text: string; fontSizePx: number; color: string; backgroundColor: string | null }
  | { type: 'step-badge'; id: string; position: Point; number: number; color: string }
  | { type: 'spotlight'; id: string; shape: 'rect' | 'ellipse'; region: Rect; dimOpacity: number }
  | { type: 'blur'; id: string; shape: 'rect' | 'ellipse'; region: Rect; style: 'gaussian' | 'pixelate'; intensityPx: number };

interface Point { x: number; y: number }       // normalized 0.0–1.0, relative to source image dimensions
interface Rect { x: number; y: number; width: number; height: number } // normalized 0.0–1.0

Coordinates are normalized (0.0–1.0) rather than pixel-absolute so annotations remain correctly positioned if the source image is ever re-rendered at a different resolution (e.g. a higher-res re-capture).

Numbered step badges auto-increment in creation order; deleting or reordering a badge renumbers every badge after it so the sequence stays gap-free and monotonic.

The blur layer type is the redaction tool. It carries the identical security property established for video redaction — same owning sections, Sections 11.7 (editor behavior and render contract) and 22.2 (the security property): the region it covers is burned into the delivered image pixels server-side — never delivered as a client-side overlay a viewer's browser could remove or inspect around. All other annotation types are also flattened into the delivered raster on export/save (13.4), but only blur layers carry a hard security guarantee; the others are a styling convenience that could in principle be re-edited indefinitely, whereas a blur layer, once a screenshot is shared, is permanently part of the shared bytes.

13.4 Output Formats and Sizes #

Format Primary use Default quality Transparency support
PNG Default download format; lossless n/a Yes
JPEG Smaller file size when transparency isn't needed 90 (0–100 scale) No
WebP Share-link preview thumbnails and default web delivery 82 Yes

Output dimensions equal the original capture resolution plus padding (13.2); the maximum output long-edge is 4096px — a beautified canvas that would exceed this (e.g. a 4K capture plus 20% padding) is downscaled proportionally after compositing, never before, so padding/shadow proportions remain correct.

Copy-to-clipboard: the client writes the rendered PNG blob directly to the OS clipboard via the Clipboard API (navigator.clipboard.write with a ClipboardItem), using the client-side preview render (13.7) for immediate responsiveness — the clipboard copy is refreshed with the server-authoritative render if the user copies again after the async server render completes and differs (rare; only when redaction layers are present, since those are never in the client-side preview export path, 13.7).

Direct share link: reuses the exact share-link mechanism of Section 14 (13.5), including the access-controlled delivery path in 13.5.1.

Storage and quota accounting: a screenshot's stored bytes — both media_assets rows described in 13.5 (the screenshot_original capture and the screenshot_edited delivered asset) — count against the workspace's storage quota (Section 21: 2 GB Free / 250 GB per seat pooled Pro / 1 TB per seat pooled Business) via the same usage_counters.storage_bytes_used counter video assets use. Screenshots explicitly do not count against the 25-video library cap on the Free plan: Section 21 owns the exact cap semantics and its plan-by-plan values, and that cap is defined strictly over rows in the videos table — capturing or storing a screenshot never creates a videos row, so it is structurally outside the cap's scope, not merely exempted by a special case. A Free workspace at its 25-video limit can still capture and store screenshots freely, subject only to the 2 GB storage ceiling. This is a deliberate plan-enforcement rule, not an oversight: screenshots are a lighter-weight, higher-frequency capture type and gating them behind the video cap would defeat their purpose.

Section 5.4 is canonical for the screenshots and media_assets table schemas — identifiers, folder placement, creator, source-capture metadata, byte sizes, and timestamps live there. This section defines the capture, beautification, and annotation behavior that populates those tables and does not restate their columns.

Every screenshot has exactly two media_assets rows, using the two media_assets.kind values Section 5.4 defines for this capture type, split across the same two physically separate buckets Section 22.2 defines for every restricted-media asset in the product:

media_assets.kind Contents Bucket
screenshot_original The raw, as-captured image (13.1) — before beautification, annotation, or redaction burn-in. May contain content the user is about to blur, so it is never served directly to a viewer. reelay-media-restricted — no CDN origin, IAM-denied by default; the identical posture Section 22.2 gives a video's unredacted original.
screenshot_edited The beautified, flattened, delivered asset (13.7): background, padding, window chrome/device frame, and every annotation layer — including any blur redaction layers, burned in — composited into the final raster. This is what is served, downloaded, copied to the clipboard, and referenced by share links. reelay-media-delivery — holds every servable rendition, poster, thumbnail, and export, per the same bucket Section 22.2 defines for video.

This is the identical two-bucket separation Section 22.2 establishes for video: the bucket boundary, not an application-level check, is what makes it structurally impossible to serve an unredacted screenshot original even under a misconfigured access-control rule.

Screenshot redaction (the blur annotation layer, 13.3) is burned in server-side exactly like video redaction — same guarantee, same owning sections: Sections 11.7 and 22.2. There is no client-side redaction path for screenshots, matching 13.7's statement that the server-side render is authoritative for every delivered asset.

Screenshots share the identical link-security mechanism Section 14 defines for videos: a share_links row references its subject polymorphically (subjectType: 'video' | 'screenshot', subjectId), and every property Section 14 specifies — visibility levels, optional password, optional expiry, domain allowlist, disable-download, disable-comments, require-email-to-watch, personalized recipient tokens, and the mandatory audit log on every visibility/permission change — applies to a screenshot share link exactly as it applies to a video share link. There is no separate, lighter-weight sharing path for screenshots; the same failure mode Section 14 names (content made public that was meant to be internal) is exactly as possible, and exactly as guarded against, for a screenshot as for a video.

13.5.1 Access-controlled delivery: no permanent guessable URL #

A screenshot's screenshot_edited object, its share preview, and any poster/thumbnail-equivalent used when a screenshot is embedded in a link preview or Slack/email unfurl (Section 20) follow the identical access-controlled, version-scoped path video posters use: the delivered object's key is scoped by the owning share link's playback_key_version, so revoking or downgrading a screenshot's share link invalidates the previously issued path exactly as it does for a video poster, and both the visibility-downgrade and link-revocation flows (Section 14.1.1) purge the CDN cache for the screenshot's delivered asset — not only on hard deletion. There is no permanent, guessable CDN URL of the form cdn.reelay.app/screenshots/{screenshotId}/... for a restricted-visibility screenshot; the one cache policy that applies to this entire asset class applies here too — content-hashed key, Cache-Control: public, max-age=31536000, immutable — because the URL's unguessability and its access scoping, not a short cache lifetime, is what protects it. A public-visibility screenshot's delivered asset is still served through this same versioned path; "public" governs who may resolve the link, not whether the object key format changes.

13.6 Brand Kit Application #

When a workspace has a brand kit configured (Section 18 owns the brand kit entity: logo asset, primary/ secondary colors, font family), screenshot beautification can apply it the same way video export does:

  • Logo watermark: optional placement in one of four corners of the beautified canvas, at a fixed 5% inset from the canvas edge, scaled to 6% of the canvas's shorter dimension.
  • Background restriction: when a Business-plan workspace has brand-kit enforcement turned on (Section 21: "workspace-enforced brand kit"), the background preset picker (13.2) is restricted to the brand kit's approved palette/gradient set rather than the full shared catalogue (Section 10.9) — the same enforcement behavior Section 18 specifies for video, applied here to screenshots.
  • Font override: the text and step-badge annotation tools (13.3) default to the brand kit's font family when one is configured, falling back to the standard web-safe set otherwise.

Brand kit application is a per-screenshot toggle (applyBrandKit: boolean, default true when a brand kit exists and enforcement is on, default false otherwise) rather than an irreversible transform — a user can beautify a screenshot without brand styling even on an enforcing workspace if enforcement is scoped to publish-time only (Section 18 owns the exact enforcement point; this section only states that the toggle exists and its default).

13.7 Performance Targets #

Two render paths exist, and only one is authoritative:

Path Where it runs Purpose Authoritative?
Preview render Client-side, Canvas2D (with a WebGL fallback for large blur-region redaction previews, which are otherwise slow in pure Canvas2D) Live, interactive preview while the user drags annotations, adjusts padding, or switches background presets No — a fast approximation for responsiveness
Delivered render Server-side, apps/worker, using a headless raster pipeline (sharp for compositing/format conversion, with a headless Chromium/Skia-backed canvas for annotation and text layout fidelity) Produces the actual bytes stored, downloaded, copied, and served via share link Yes — server-side is authoritative for every delivered asset

The client-side preview exists purely for interactivity; it is never delivered, downloaded, or served as the final asset — even the "instant" copy-to-clipboard path (13.4) is understood to potentially be superseded by the server render once available, specifically because client-side rendering cannot be trusted to correctly and irreversibly burn in redaction (13.3), and redaction correctness is a security property (Sections 11.7 and 22.2), not a UX nicety.

Performance targets:

Interaction Target
Client-side preview update after a drag/resize/style change ≤ 16ms per frame (60fps) for annotation manipulation; ≤ 100ms for a background/preset swap (a heavier recomposite)
Server-side beautify render (no blur/redaction layers) p95 < 2s from request to delivered-asset availability
Server-side beautify render (with one or more blur/redaction layers) p95 < 5s — Gaussian blur/pixelation at full resolution is the dominant cost
End-to-end capture-to-first-preview (desktop app hotkey to overlay showing the beautified preview) < 500ms, entirely client/local-app side, no network round-trip required before a preview is visible

This section defines the complete access-control surface for a video: who can find it, who can open it, what they can do with it once open, and how every change to that surface is recorded. It is the security feature customers evaluate first, because the failure mode it must prevent — a video that was meant to stay internal becoming reachable by anyone with a URL — is the single most damaging incident this product can produce for a customer's business.

14.1 The visibility model #

Every video has exactly one visibility value at any time, stored on the share_links row that owns the canonical share for that video (a video may have zero, one, or many share links; the video's own default share link is created automatically on first successful upload). Visibility is a property of the share link, not the video row itself — a single video can be shared multiple times with different visibility, expiry, and protection settings on each link, and the video's own row never carries a visibility flag. This is why Section 5's share_links table, not videos, owns the column.

Value Who can open the link Discoverable via search or sitemap Requires authentication
private Nobody except workspace members with at least viewer-equivalent access via folder permissions (Section 14.10) and the video owner No Yes — workspace session required
workspace Any member of the owning workspace, any role, including viewer No Yes — workspace session required
link Anyone possessing the exact share URL No No
public Anyone possessing the exact share URL Yes — included in sitemap.xml, robots.txt allows indexing, Open Graph/Twitter metadata rendered for crawlers (Section 14.11) No

private is the default visibility assigned to every new share link. A share link is never created with public visibility directly — a link must pass through link at least once, because public additionally requires a passing email-verification check on the owner's account (Section 6, anti-spam gate) and triggers the elevated audit alerting in Section 14.9. This ordering is enforced server-side: PATCH /v1/share-links/{id} rejects a transition directly from private to public in a single call with 409 invalid_visibility_transition — the client must issue two calls, or the API accepts an explicit confirmPublic: true body field on the single call to bypass the two-step UX gate while still passing through the same validation and audit path.

14.1.1 Transition rules #

All 12 possible ordered transitions between the four states are legal (visibility can move in any direction at any time by an authorized actor), but three carry mandatory side effects:

Transition Side effect
private/workspacelink/public Requires owner or admin role (Section 6.2). Requires the acting user's email to be verified (Section 6.6). Writes a share_audit_events row (Section 14.9).
any → public Additionally requires confirmPublic: true if not already link. Sends the optional public-alert notification (Section 14.9.5) if enabled at the workspace level.
link/publicprivate/workspace Immediately revokes all outstanding signed playback URLs for that link by rotating the link's playback_key_version (Section 14.8.4). In-flight video players holding an unexpired signed URL continue playing the current segment but the next signed-URL re-issue request returns 403 link_not_accessible. Also purges the link's poster and thumbnail from the CDN cache (Section 14.8.7) — the same versioned-key rotation that invalidates playback URLs invalidates the preview images derived from them, so a downgrade to private/workspace removes public visibility of the poster/thumbnail as completely as it removes video playback, not only when the video is hard-deleted.
any → link/public (link re-created or re-shared after a prior revocation) The poster/thumbnail CDN cache for the link's current playback_key_version is (re)populated on next request; no separate action needed since the versioned key already changed on the prior downgrade.

Who may change visibility: owner and admin roles unconditionally; member only on share links they created. viewer can never change visibility. This mirrors Section 6's role table and is enforced by the same middleware that checks folder-level permissions (Section 14.10).

An explicit "revoke all active viewers" action (Section 14.8.4) carries the identical side effect even when it is not paired with a visibility change: it rotates playback_key_version and purges the poster/ thumbnail CDN cache for that link exactly as a link/publicprivate/workspace transition does. Poster/thumbnail cache purge is therefore driven by two triggers — a visibility downgrade or an explicit revocation — never only by the video's hard deletion (Section 19). Section 14.8.7 states the one cache policy that governs every poster/thumbnail/animated-preview asset in this product; it applies here without exception.

A share link is identified by a 12-character base58 slug, generated with a CSPRNG (crypto.randomBytes), encoded using the Bitcoin base58 alphabet (123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz — no 0, O, I, l) to avoid visual ambiguity when a slug is read aloud or transcribed by hand. 12 characters of base58 yield log2(58^12) ≈ 70.3 bits of entropy, making the slug space large enough that brute-force enumeration is computationally infeasible (at 10,000 guesses/second sustained, exhausting even a single trillionth of the space takes longer than any reasonable expiry window).

The share_links schema — every column, default, and constraint, including visibility, password_hash, expires_at, domain_allowlist, disable_download, disable_comments, require_email, playback_key_version, is_custom_slug, and the widened slug column sized to fit both the 12-character random default and the 4–48 character custom-slug range (Section 14.2.1) — is owned by Section 5.4. This section states only the behavioral rules layered on top of that schema.

The canonical revocation column is share_links.revoked_at: setting it is what retires a link. There is no separate deleted_at or disabled_at column on this table — a revoked link is not a soft-deleted row in the generic sense (Section 4's soft-delete convention), it is a link whose revoked_at is non-null, and every rule in this section that refers to a link being "removed," "revoked," or no longer resolvable means exactly that column being set.

The unique index on slug (partial, excluding revoked rows — i.e. WHERE revoked_at IS NULL, owned by Section 5.4) is what makes slug collision structurally impossible rather than merely improbable: insertion retries on unique-violation up to 5 times with a freshly generated slug before returning 500 slug_generation_failed (an error that should never occur in practice given the entropy above, but the retry loop makes it self-healing against the residual birthday-bound collision probability).

Why never sequential: a sequential or incrementing identifier (auto-increment integer, timestamp- prefixed ID) lets any holder of one valid share URL discover adjacent videos by walking the ID space up or down — a request smuggling / enumeration vector that has caused real disclosure incidents in this product category. The slug is generated independently of the video's own UUIDv7 (Section 5) and carries no ordering information whatsoever.

14.2.1 Custom slugs (Business plan) #

Business-plan workspaces may set is_custom_slug = true and supply their own slug on POST /v1/share-links or PATCH /v1/share-links/{id}. Validation:

Rule Constraint
Length 4–48 characters
Character set [a-zA-Z0-9-] only, must not start or end with -
Reserved words Rejected: api, admin, app, www, watch, embed, share, health, and any existing route segment under /v1
Uniqueness Same partial unique index as random slugs — 409 slug_taken on collision

A custom slug does not reduce or replace any of the entropy-derived protections above — it is purely a vanity/branding feature and does not change how playback tokens are issued or verified.

14.2.2 Watch-page URL structure #

https://reelay.app/watch/{slug}
https://{custom-domain}/watch/{slug}      # Business plan custom domains (Section 14.11, Section 26)

The slug is the only variable path segment. No video ID, workspace ID, or other identifier ever appears in the watch-page URL — the slug is the sole capability token for link/public access, and every other piece of access-relevant state (password requirement, expiry, domain allowlist) is looked up server-side from the share_links row, never encoded in the URL itself.

14.3 Password protection #

Any share link, regardless of visibility, may additionally require a password. Setting password_hash is independent of the visibility enum — a workspace-visibility link can be password protected too (defense in depth against a compromised member session).

Hashing: Argon2id, using the same parameters as Section 6's authentication hashing (m=19456 KiB, t=2, p=1), from the same shared packages/shared hashing utility so the two code paths never drift. The plaintext password is never logged, never included in any analytics event payload, and never stored anywhere except transiently in the request body during the unlock call.

14.3.1 Unlock flow #

Two distinct credentials are involved, deliberately kept separate so that a link's exposure to automated pre-fetching (email security scanners, link-preview bots) never costs the real recipient their unlock:

1. GET  /watch/{slug}                    → server checks share_links.password_hash IS NOT NULL
                                             → renders the password-gate page, no video metadata leaked
2. POST /v1/share-links/{slug}/unlock       body: { "password": "..." }
                                             → Argon2id.verify(password, password_hash)
                                             → on success: sets the session unlock cookie directly in
                                               this response (step 3a) AND, only when the caller
                                               indicates a cross-origin/cookie-restricted context
                                               (`?bootstrap=true` on the unlock call, used by the
                                               password-gate page's own cross-origin embed/email
                                               delivery flow), also returns a one-time bootstrap token
                                               (step 3b)
3a. Cookie: reelay_unlock_{slug}=...     → set by step 2's response for the normal, same-origin,
                                             cookie-capable case (the watch page's own password form).
                                             No URL-borne token is ever involved in this path.
3b. GET /watch/{slug}?unlockToken=...    → the cross-origin/no-cookie fallback path only (email
                                             clients, cross-origin embed contexts that cannot receive a
                                             same-origin `Set-Cookie` from step 2). Redeems the
                                             bootstrap token minted in step 2, then behaves as 3a: sets
                                             the session cookie and 302-redirects to the cookie-bearing
                                             canonical URL, stripping the token from the visible
                                             address bar.

Whichever path is used, the end state is the same: a valid reelay_unlock_{slug} session cookie, which the watch page validates on every subsequent request to render the player and issue the first signed playback URL (Section 14.8).

The session cookie (step 3a): a signed, opaque, HMAC-SHA-256 token (HMAC(secret, slug + expiryTimestamp), base64url-encoded) delivered as an httpOnly, Secure, SameSite=Lax cookie scoped to the watch-page path, never as a URL query parameter in any link the viewer would share, bookmark, or that would appear in server access logs, browser history, or a Referer header sent to a third party. TTL: 24 hours from issuance, matching typical viewing-session expectations (a viewer who unlocks a video today should not have to re-enter the password if they return to finish watching tomorrow, but the exposure window from a stolen token stays bounded). Re-entering the correct password always re-issues a fresh 24-hour cookie; it does not extend an existing one.

The bootstrap token (step 3b) is a categorically different credential, not a URL-borne copy of the session cookie. It exists solely to carry a single successful unlock across a context that cannot accept a same-origin Set-Cookie — a link opened directly from an email client, or a cross-origin embed handshake — into a browser session that can. Its properties:

Property Value
Lifetime 5 minutes from issuance
Use count Single-use — consumed atomically on first redemption
Consumption mechanism UPDATE share_link_unlock_bootstrap_tokens SET consumed_at = now() WHERE token = $1 AND consumed_at IS NULL AND expires_at > now() RETURNING share_link_id — one statement, so two concurrent redemption attempts for the same token race safely and at most one ever succeeds
On successful redemption Sets the 24-hour reelay_unlock_{slug} session cookie for that request/response and 302-redirects to the canonical cookie-bearing URL
On failed redemption (already consumed, or expired) Renders the password-gate page (step 1) fresh — the visitor re-enters the password, which re-issues a new cookie (and, if still in a cookie-restricted context, a new bootstrap token)

Why 5 minutes and single-use, not the previous up-to-24-hour reusable design: a URL-borne credential that is both long-lived and not atomically consumed is exposed to any party who ever sees the URL for as long as it remains valid — including automated email security scanners (Microsoft Defender for Office 365 Safe Links, Proofpoint URL Defense, and similar products), which fetch every link in an inbound email within seconds of delivery, before the human recipient ever opens it. Bounding the bootstrap token's lifetime to minutes and consuming it atomically on first use means: if a scanner's prefetch is what redeems it, the real recipient's subsequent click — which under this design finds the token already consumed — is never a dead end or a silent, unexplained day-long lockout. It lands back on the password-gate page and completes a normal password entry, which mints a fresh cookie in seconds. The previous design's 24-hour, non-atomic ?unlockToken= was strictly worse on both axes it matters on: longer exposure if the raw URL ever leaked into a scanner's or proxy's logs, and — because a future single-use redesign was always the intended direction — no defined behavior for what happens when two parties present the same URL-borne token. This design defines that behavior explicitly and makes the failure mode cheap.

14.3.2 Brute-force rate limiting and lockout #

Enforced server-side, keyed on the tuple (slug, requestIpHash), using the Redis-backed rate limiter described in Section 7.9:

Rule Value
Attempts before short lockout 5 incorrect attempts within 10 minutes
Short lockout duration 15 minutes, returns 429 too_many_attempts with Retry-After
Attempts before extended lockout 20 incorrect attempts within 24 hours (across short-lockout windows)
Extended lockout duration 24 hours
Successful unlock Resets the attempt counter for that (slug, requestIpHash) pair immediately
IP hashing IP is hashed (SHA-256 with a rotating daily salt) before use as a rate-limit key — never stored in plaintext in the rate-limiter's Redis keyspace, though the raw IP is separately captured in the audit event per 14.9 with normal access controls

The lockout is per-(slug, IP-hash), not per-slug globally, so one attacker cannot lock out a legitimate viewer from a different network by mass-guessing. A global per-slug ceiling of 100 failed attempts across all IPs within one hour additionally throttles distributed brute-force attempts, returning 429 link_temporarily_locked for all unlock attempts (correct or not) against that slug for 15 minutes — this global ceiling is logged as a security event visible to workspace admins (Section 14.9.4).

A password is never transmitted or reflected in a URL under any circumstance in this product. It appears only in the JSON body of the unlock POST request over TLS.

14.4 Expiry #

share_links.expires_at is an absolute timestamptz, never a relative duration stored server-side (the UI may offer "expires in 7 days" as an input affordance, but it is resolved to an absolute timestamp at write time). NULL means the link never expires.

14.4.1 Viewer-facing behavior after expiry #

GET /watch/{slug} after expires_at has passed returns the watch page shell with a distinct expired state (HTTP 200, not an error status, because the page itself renders successfully — it just renders an expiry notice instead of the player):

  • No video metadata, poster image, or player is rendered.
  • Copy: "This video is no longer available. It may have expired or been removed." — deliberately generic, so an expired link does not confirm to a probing party that a video did exist versus simply never having existed at that slug.
  • Any previously-issued signed playback URL for this link is invalidated at the same instant expires_at passes — enforced by the Mux signed-token expiry being set to min(expires_at, now + 6h) at issuance time (Section 14.8.1), so a token issued shortly before expiry cannot outlive it.
  • oEmbed and Open Graph endpoints (Section 14.11) return 410 link_expired for machine consumers.

14.4.2 Owner notification before expiry #

A scheduled job (share_link.expiry_warning, BullMQ repeatable job, Section 9) scans for share links where expires_at falls within the next 24 hours and no warning has yet been sent (expiry_warning_sent_at IS NULL), and sends one email to the link's created_by user plus all owner/admin workspace members who have notification_preferences.share_link_expiry = true (default true). The email states the video title, the slug, the exact expiry timestamp in the recipient's detected timezone, and a one-click "extend by 30 days" action that calls PATCH /v1/share-links/{id} with a new expiresAt. No further reminders are sent after the single 24-hour warning; a link that expires simply moves to the state in 14.4.1.

14.5 Domain-restricted viewing (Business plan) #

Business-plan share links may set domain_allowlist: text[], a list of eTLD+1 domains (e.g. acme.com, not www.acme.com or a full URL) from which the video may be viewed when embedded. Domain restriction is only enforceable in loader-script mode (Section 15.1). A domain-restricted link opened via iframe-fallback mode (Section 15.8.4) refuses to play, unconditionally, rather than attempting — and failing — a check that mode structurally cannot support. 14.5.3 explains why, and 14.5.4 states the resulting viewer-facing behavior for both cases.

14.5.1 Why Referer alone is insufficient #

A Referer header is client-supplied, optional (many browsers and privacy extensions strip it by default, and Referrer-Policy: no-referrer on the host page removes it entirely), and trivially forged by any request that isn't a real browser embed. Relying on Referer alone as the enforcement mechanism would mean: (a) legitimate viewers on privacy-hardened browsers get incorrectly blocked, and (b) an attacker bypasses the restriction entirely by omitting or spoofing the header on a direct request to the playback endpoint. Referer is therefore used only as a fast-path UX signal in loader-script mode — if present and it matches the allowlist, the embed renders immediately without waiting on the full handshake below — but it is never the security boundary.

14.5.2 The actual enforcement: at playback-token issuance, loader-script mode only #

The real check happens server-side, at the moment a signed playback URL is issued (Section 14.8), not at page render time, and only ever runs for embeds using loader-script mode:

  1. The watch page or embed player calls POST /v1/share-links/{slug}/playback-token.
  2. If the link has a non-empty domain_allowlist and the request originates from loader-script mode, the server requires — and the loader/player-core has already completed — the domain-attestation handshake described in 14.5.3, which yields a server-verified attestedOrigin.
  3. The server extracts the eTLD+1 from attestedOrigin using the Public Suffix List and checks it against domain_allowlist.
  4. Only on a match does the server mint the Mux signed playback token. On no match, it returns 403 domain_not_allowed and issues no token — there is no unprotected fallback path.
  5. If the link has a non-empty domain_allowlist and the request instead originates from iframe-fallback mode, the server skips straight to refusal: 403 domain_restriction_unsupported_in_iframe_mode. No allowlist comparison is attempted at all, because there is no trustworthy origin signal available to compare (14.5.3).

Because the check gates token issuance rather than trying to inspect the video stream request itself, it is impossible to bypass by stripping headers on the media request: without a valid signed token, the CDN and Mux reject the media request outright (403) regardless of what any header on that request claims.

14.5.3 Embed-context verification, and why iframe mode cannot do it #

The bypass this replaces. An earlier design attempted to identify the embedding host page by reading the browser-enforced Origin header on the token-request fetch issued from inside the embed iframe (https://embed.reelay.app/v1/frame/{slug}, Section 15.8.4). That header is real and unspoofable, but it reflects the origin of the document the fetch actually runs in — and a fetch issued from JavaScript running inside that iframe always has Origin: https://embed.reelay.app, because that is the iframe document's own origin, never the host page's. This is true regardless of what page embeds the iframe or at what domain: the value is constant. Trusting it therefore meant one of two broken outcomes depending on implementation detail — either it never matched any customer's configured domain_allowlist (total denial for every legitimate embed), or embed.reelay.app was special-cased as an implicitly trusted value, which is the actual bypass: any site in the world could embed a domain-restricted video via the iframe path, because the check was structurally incapable of distinguishing "embedded on acme-internal.com" from "embedded anywhere at all." That mechanism is deleted. It has no replacement within iframe-fallback mode — see below for what iframe-fallback does instead.

The replacement, loader-script mode only: a domain-attestation handshake (referred to elsewhere in this document simply as the postMessage handshake, since the host-page script's origin report and the server's nonce challenge are the two "messages" being handshaked — no literal window.postMessage() call is involved, because, as established below, this handshake only ever runs in a same-document context that has no need for the cross-frame postMessage API at all). Loader-script mode (Section 15.1) runs Reelay's own script (embed.js/player-core.js) directly inside the host page's own top-level document — not inside any iframe — which is precisely the property that makes an origin claim trustworthy: the script has direct, unspoofable access to window.location.origin, because it is executing in that document, not a separate browsing context reached only through indirect signals. The handshake:

  1. Before requesting a playback token for a domain-restricted link, the player-core calls POST /v1/share-links/{slug}/domain-nonce. The server generates a single-use, opaque nonce with a 30-second TTL and returns it.
  2. The player-core, still executing in the host page's top-level document, reads window.location.origin directly and calls POST /v1/share-links/{slug}/playback-token with { nonce, claimedOrigin: window.location.origin }.
  3. The server validates, in order, short-circuiting on first failure: (a) the nonce exists, is unexpired, and has not already been consumed — checked and consumed atomically in the same statement (the identical single-use pattern as the unlock bootstrap token in Section 14.3.1), so a captured request/nonce pair cannot be replayed; (b) the browser-attached Origin header on this same POST request equals claimedOrigin — a defense-in-depth cross-check between the browser-enforced header and the script's self-report, so neither signal alone has to be fully trusted; (c) the eTLD+1 of the validated origin is present in domain_allowlist (14.5.2).
  4. Only on all three passing does the server proceed to mint a playback token.

Why this has no counterpart in iframe-fallback mode. A plain <iframe src="https://embed.reelay.app/ v1/frame/{slug}"> tag (Section 15.8.4) runs no Reelay-authored code anywhere in the host page's own top-level document — by construction, that is what makes it the fallback for hosts whose CSP blocks third-party <script src> entirely (Section 15.8.4). The only Reelay code that ever runs is inside the iframe's own document, whose origin is always embed.reelay.app (Section 15.8.4 states this explicitly and cross-references here). There is no vantage point inside that document from which any technique — this handshake, header inspection, or anything else — can observe the host page's real origin, because an iframe is fundamentally unable to attest to its own embedder without the embedder's own cooperating script, and iframe-fallback mode is defined by the absence of any embedder-side script. This is not an implementation gap to be closed later; it is the reason domain restriction requires loader-script mode as a hard product requirement, stated in 14.5.4.

14.5.4 Failure UX #

Two distinct failure states exist, and the product deliberately shows different copy for each so a viewer (and the workspace owner troubleshooting a complaint) can tell an actual denial apart from a known mode limitation:

Domain mismatch (loader-script mode, handshake completed but origin not on the allowlist) — the viewer sees, inline in the reserved player area (never a raw browser error, to preserve layout stability per Section 15.2):

"This video can't be played on this site. Contact the video owner if you believe this is a mistake."

No further detail (allowed domain list, workspace name) is exposed to an unauthorized viewer — that would leak configuration to a party the restriction is explicitly trying to exclude.

Iframe-fallback mode, domain-restricted link (mode cannot support the feature at all) — this is a documented limitation, not a silent failure, and says so:

"This video is domain-restricted and can't play in this simplified embed. An iframe can't prove which site it's embedded on, so this mode isn't supported for domain-restricted videos. [View on Reelay instead]"

The "[View on Reelay instead]" action links to the watch page (14.12), where the same link — opened directly, not embedded — is unaffected by domain restriction (domain restriction governs embedding context, not direct visits to reelay.app). This message is shown identically whether the iframe was hand-authored by the host or generated via oEmbed (14.11.3), since oEmbed's returned markup is the same iframe-fallback frame and therefore inherits this refusal at render time inside that frame — no special-casing is needed in the oEmbed endpoint itself.

In both cases, the workspace owner sees the actual allowlist and a real-time count of blocked attempts (aggregated, not per-request, and tagged with which failure reason produced them) in the share link's settings panel, sourced from the domain_blocked analytics event (Section 16).

14.6 Disable-download #

share_links.disable_download = true removes the following from the watch page and player:

  • The visible "Download" button in the player control surface (Section 15.6) and watch-page action bar.
  • The download endpoint for that share link's viewing context (see below), for any request that is not the workspace owner/admin acting from within the authenticated dashboard.
  • The exported-file share affordance on the watch page.

A video may have multiple share_links rows with different disable_download settings — one link shared with a partner might allow it, another shared publicly might not. A download endpoint keyed only on videoId cannot know which link's setting applies, and worse, gives any viewer who learns the video's public ID (disclosed in every embed snippet and API response, Section 14.11.2) a path to download that bypasses whichever link's setting they were actually viewing under — the per-link control would be decorative, not enforced. The endpoint is therefore link-scoped:

GET /v1/share-links/{slug}/download — requires the same signed viewer-session credential the requester already holds from completing the access chain for that link (Section 14.8.1): either the unlock session cookie (14.3.1) for a password-protected link, or a valid signed playback token / viewer-session token issued to this slug, presented as a bearer credential. The server:

  1. Resolves {slug} to its share_links row and re-validates the credential against that specific row — the same visibility/recipient/password/expiry/domain chain as 14.8.1, so a credential valid for one link is never accepted against another link's download endpoint even for the same video.
  2. Checks disable_download on that row. true403 downloads_disabled, checked against the link actually in use, never a different link on the same video.
  3. On success, redirects to a short-TTL signed URL for the source rendition with Content-Disposition: attachment.

The workspace-authenticated path is separate and unaffected: GET /v1/videos/{id}/download, called from within the authenticated dashboard by an owner/admin acting on their own library, is authorized by ordinary workspace RBAC (authorize(), Section 6.9) and does not go through any share link at all — it is the owner managing their own asset, not a viewer exercising a share link's grant, and no disable_download setting on any individual share link constrains it. Any other caller of /v1/videos/{id}/download — a member/viewer without owner/admin role, or an unauthenticated request — receives 403 insufficient_role; this route never accepts a share-link-scoped credential as a substitute for workspace authentication, which is what keeps the two paths from being confused with each other.

14.6.1 What this genuinely prevents, and what it cannot — stated honestly #

This is an access-friction control, not DRM, and the specification is deliberately explicit about the boundary so no customer is misled about the guarantee:

What it prevents: casual, one-click acquisition of the source file by an ordinary viewer through the product's own UI. It removes the download button, blocks the authenticated download endpoint for that context, and — because signed playback URLs are short-TTL (Section 14.8) — a raw copied media URL stops working within hours rather than remaining a permanent downloadable link.

What it cannot prevent, stated explicitly:

  • A determined viewer can screen-record the playback with OS-level tools. No web player can prevent this; it is outside any browser's security model.
  • A viewer can extract the HLS segments from browser devtools' network tab while the video is playing and reassemble them, since HLS segments must be fetched in the clear (encrypted-at-rest, decrypted by the player) for playback to occur — this product does not implement HLS segment encryption/DRM (no Widevine/FairPlay/PlayReady key system) because that tier of protection is out of scope for this product's threat model (Section 2's scope boundaries) and would materially increase both cost (DRM license server integration and per-stream licensing fees) and playback compatibility risk (DRM playback is unsupported or degraded on many browser/OS combinations).
  • Disabling download raises the cost and technical skill required to obtain a copy; it does not make extraction impossible. Any UI or sales copy referencing this feature must describe it as "prevents casual downloading," never as "prevents copying" or "secure/DRM-protected."

Threat model boundary, explicit statement: this product's access controls (password, expiry, domain restriction, disable-download) are designed to prevent unauthorized discovery and casual redistribution of a video by parties who should not have had access in the first place. They are explicitly not designed to prevent a party who was granted legitimate viewing access from ever being able to retain a copy of what they were shown — that is a DRM problem, and this product does not claim to solve it. This distinction must be preserved in all customer-facing documentation generated from this section.

14.6.2 Interaction with signed-URL TTL #

Because every playback URL is a Mux-signed token with a 6-hour TTL (Section 14.8.1) rather than a permanent public media URL, disable_download compounds with that expiry: even a viewer who captured the raw segment URLs from network traffic has, at most, a 6-hour window before those specific URLs stop resolving — they would need to re-derive fresh signed URLs through the normal player flow to continue, which routes back through the same access checks (password, domain, expiry) each time.

14.7 Per-video "who can watch" #

Independent of the coarse visibility enum, a share link may restrict who specifically may watch:

14.7.1 Named recipients and personalized recipient tokens #

The share_link_recipients schema is owned by Section 5.4: one row per invited recipient, keyed by (share_link_id, email) uniquely, carrying recipient_token (32 random bytes, base64url-encoded, independently unique-indexed), first_opened_at/last_opened_at/open_count for the open-tracking behavior below, and its own revoked_at (the same per-row revocation convention as share_links, Section 14.2 — deleted_at/disabled_at do not exist on this table either).

The owner adds one or more recipient emails to a share link. Each recipient receives a personalized URL https://reelay.app/watch/{slug}?r={recipientToken} (delivered by email, never requiring the recipient to have a Reelay account). The recipientToken grants access without the general link's password (if any is set separately, the recipient token supersedes it) — enforced by evaluating the recipient token before the password gate in the access chain (Section 14.8.1), precisely so a valid token can supersede the password requirement rather than being blocked by it; a request that presents a valid, unrevoked recipient token never reaches the password check at all. It is independently revocable — deleting one share_link_recipients row (setting revoked_at) cuts off that one person without affecting the link or any other recipient, and without requiring a slug rotation. Every open is attributed to the named recipient in per-viewer analytics (Section 16), which is the mechanism that upgrades an otherwise pseudonymous viewer to a known identity per Section 6/16's GDPR posture.

When share_link_recipients rows exist for a link, the link additionally requires the recipient token by default — i.e., adding named recipients implicitly restricts the link to those recipients unless the owner separately keeps visibility = 'link' open (both modes are supported: "restricted to named recipients only" is a link with visibility = 'private' plus recipient rows, versus "open link, but named recipients get a personalized/trackable URL" which is visibility = 'link' plus recipient rows). The distinguishing field is share_links.recipients_only: boolean, defaulting to true whenever the first recipient row is created, editable by the owner independently thereafter.

14.7.2 Require-email-to-watch #

share_links.require_email = true: an anonymous, non-recipient viewer opening a link/public share must submit an email address (client-side format validation plus server-side syntax + MX-record plausibility check, not full verification) before the player loads. This is distinct from named recipients — the viewer self-identifies rather than being pre-invited. The submitted email is stored as an email_captures row (Section 17) and promotes that viewer's analytics from anonymous viewerToken to a known email for the remainder of that viewing session (30-day rotating token, Section 16). require_email and named recipients are independent flags and may be combined: a recipient with a valid token skips the email gate (they are already known); an anonymous visitor to the same link still hits it.

14.7.3 Workspace-only viewing #

visibility = 'workspace' (Section 14.1) is the mechanism for workspace-only viewing — no separate flag is needed since it is a first-class visibility state. It is listed here for completeness because it is frequently combined with the "who can watch" concept in product UI copy: a workspace-visibility link additionally supports disable_download, disable_comments, and all other per-link controls in this section identically to link/public links.

14.8 Signed playback URLs #

No media asset (rendition, poster, or thumbnail) is ever served from a permanent, guessable URL. Every playback session is authorized through a short-lived signed URL.

14.8.1 Issuance and TTL #

POST /v1/share-links/{slug}/playback-token (called by the watch page on load and by the embed player on intent, Section 15.4) performs the full access check chain — visibility, recipient token, password/unlock token, expiry, domain allowlist, require-email gate, in that order, short-circuiting on first failure with the corresponding 403/404 code from the table in 14.8.5 — and on success requests a Mux signed playback token via the Mux server SDK, with:

The recipient-token check runs before the password gate specifically because a valid recipient token is defined (Section 14.7.1) to supersede a link's password: if the chain checked password first, a named recipient without the separately-set password would be incorrectly blocked before their token was ever consulted. Evaluating recipient token first means a request either (a) presents no recipient token and falls through to the password/expiry/domain/email checks unchanged, or (b) presents a valid, unrevoked recipient token and skips the password check entirely, continuing on to expiry/domain/email exactly as before.

  • TTL: 6 hours from issuance, or min(6h, share_links.expires_at - now()) if the link expires sooner (Section 14.4.1).
  • The token is scoped to the specific Mux playback ID for that video's current rendition set — it is not a bearer token valid for any video, only the one it was issued for.
  • The token is delivered as part of the watch-page/player's playback manifest URL, never persisted to local storage or a cookie (it lives only in memory / the <video> element's src/HLS manifest URL for the duration of the tab session).

Actor identity for this entire chain: an anonymous caller of this endpoint — whether or not they hold a recipient token, an unlock cookie, or nothing but a public link/public slug — is a share_viewer actor (Section 6.9's third Actor variant: { type: 'share_viewer', viewerToken, videoId }), evaluated against this chain's own allow-list logic exclusively. A share_viewer is never evaluated against ROLE_CAPABILITIES['viewer'] and never becomes a workspace viewer by virtue of watching a shared video — the two are different populations (an anonymous member of the public versus an authenticated, provisioned member of the workspace) and this section, like every other consumer of authorize(), never lets them share a code path. Every other reference to "viewer" in this section that is not explicitly qualified as the workspace role (Section 14.1's visibility table rows for private/workspace, Section 14.9.4's read-access rule) means an anonymous share_viewer, and this section uses that term (share_viewer) whenever the distinction matters.

14.8.2 Re-issue #

Because 6 hours comfortably exceeds any single viewing session for content under the 4-hour soft cap (Section 7's plan limits), routine re-issue mid-playback is rare, but two cases trigger it:

  1. The watch page re-issues on page reload/revisit (each GET /watch/{slug} that renders a live player calls the token endpoint fresh — tokens are never cached client-side across page loads).
  2. The embed player re-issues automatically if playback is paused and resumed after the token's remaining TTL drops under 15 minutes, by silently re-calling the token endpoint in the background and swapping the HLS manifest URL without interrupting playback (a "hot swap" — implemented via hls.js's loadSource on the existing Hls instance, Section 15.5).

14.8.3 Key rotation #

Mux signing keys are rotated on a scheduled quarterly cadence and immediately on suspected compromise. Reelay maintains the current and immediately-previous signing key simultaneously for a 7-day overlap window so that tokens signed under the previous key remain valid until their own 6-hour TTL naturally expires (the overlap window is far longer than any token's TTL, so no in-flight session is ever interrupted by a scheduled rotation). Key material is stored in the secrets manager referenced in Section 26, never in the database or application code.

Independent of the global Mux signing-key rotation above, each share_links row carries playback_key_version (Section 14.2's schema). When a link's visibility is downgraded (link/publicprivate/workspace, Section 14.1.1) or an owner explicitly clicks "revoke all active viewers," the server increments playback_key_version and includes it as a custom claim embedded in every signed token minted for that link going forward; the token-issuance endpoint (14.8.1) refuses to mint a token whose embedded playback_key_version claim would not match the link's current value, and — critically — the re-issue check in 14.8.2 re-validates the full access chain including this version match, so an already-playing session's next re-issue attempt (or the watch page's next page load) fails with 403 link_not_accessible even though the underlying Mux signing key itself was not rotated. This gives near-immediate effective revocation (bounded by the current segment's remaining buffer, typically under 30 seconds) without the operational cost of rotating the shared Mux signing key on every single visibility change across the platform.

14.8.5 Error codes #

Rows below are listed in the order the access chain (14.8.1) actually evaluates them — visibility, recipient token, password/unlock, expiry, domain allowlist, require-email — so the table doubles as a map of the short-circuit sequence, not just an alphabetized reference:

HTTP code Meaning
404 share_link_not_found Slug does not resolve to a link whose revoked_at IS NULL
403 recipient_required recipients_only = true and no valid recipientToken presented
403 recipient_revoked Valid-format token but the recipient row has revoked_at set
401 password_required Link has a password, no valid recipient token superseded it, and no valid unlock session cookie was presented
401 unlock_token_invalid A bootstrap token (Section 14.3.1) was presented but is expired, malformed, or already consumed
410 link_expired expires_at has passed
403 domain_not_allowed Domain-restricted link accessed in loader-script mode; the domain-attestation handshake (Section 14.5.3) completed but the resulting origin is not on domain_allowlist
403 domain_restriction_unsupported_in_iframe_mode Domain-restricted link accessed via iframe-fallback mode (Section 14.5.4) — refused unconditionally, no allowlist check is even attempted, because the iframe context cannot attest to its embedder (Section 14.5.3)
403 email_required require_email = true and no email captured this session
403 link_not_accessible playback_key_version mismatch (revoked) or visibility no longer permits this requester
429 too_many_attempts / link_temporarily_locked Section 14.3.2 lockout states

14.8.6 Composition with CDN caching #

The media segments themselves (HLS .ts/.m4s fragments) are cached aggressively at the CDN edge with long TTLs, keyed by content-addressed rendition URLs — this is safe and does not leak access, because a segment URL alone is meaningless without a valid signed manifest token to reach it, and Mux's signed-URL model validates the token against the manifest/playlist request, not the individual segment byte-fetch, per Mux's standard signed-playback architecture. The HLS manifest/playlist response (which embeds the signed token) is never cached at the CDN (Cache-Control: no-store on the manifest endpoint) — it is always fetched fresh so that a revoked or expired token is never served from cache. This split (immutable, cacheable segments; non-cacheable, always-fresh signed manifest) is what lets the product get CDN-scale delivery economics without caching away the access-control boundary.

14.8.7 Posters and thumbnails are access-controlled like playback #

The opening line of this section — "no media asset (rendition, poster, or thumbnail) is ever served from a permanent, guessable URL" — applies to poster and thumbnail images with exactly the same force as it applies to video renditions. It is not merely a design intent; it is a specific, testable rule:

Object keys are scoped by playback_key_version. Every poster, thumbnail, and animated preview object for a video is stored under a CDN path that includes the owning share link's current playback_key_version (Section 14.2), not a bare, permanent {videoId} path. When a link's playback_key_version is rotated — on a visibility downgrade or an explicit "revoke all active viewers" action (Section 14.1.1, Section 14.8.4) — the previously-served poster/thumbnail URLs stop resolving to current content at the same moment playback URLs do, because they are keyed by the same version. The old version's CDN cache entries are additionally purged outright (not merely superseded), per the cache policy below, so a party holding a stale URL gets a cache miss followed by a fresh authorization check rather than a stale cached image.

One cache policy for this entire asset class: poster, thumbnail, and animated-preview objects are served with a content-hashed key and Cache-Control: public, max-age=31536000, immutable. There is no short-TTL or stale-while-revalidate variant for these assets anywhere in this product — a shorter TTL would be pointless defense (the content-hashed, version-scoped key is what makes the asset safe to cache forever; a short TTL only shifts when a stale cached image is served, it does nothing for unauthorized access, since the same key is either valid or it isn't) and would only add unnecessary cache-miss cost. Immutable, long-lived caching is safe specifically because the key itself changes on revocation — the same pattern Section 15.14.1 uses for player-core.<hash>.js, applied here to a different asset class for the same structural reason.

What this closes: without version-scoping, a poster URL derived from the bare videoId (which every embed snippet discloses in plaintext, Section 14.11.2) would remain fetchable forever by anyone who ever saw the embed, regardless of the video later being made private, password-protected, or having its share link revoked — a durable information leak entirely independent of, and undermining, every access control elsewhere in this section. Scoping by playback_key_version closes it by construction: revoke the link, and the poster URL a viewer bookmarked or scraped stops resolving, exactly as their ability to play the video does.

Private and password-protected videos never expose a content frame. Social crawlers (Section 14.11.4) cannot present a password, a recipient token, or a workspace session — they are unauthenticated, non-interactive fetchers. A private/workspace link renders no preview metadata at all (14.11.4 already states this). For a link/public link that additionally has a password set (Section 14.3): the og:image/thumbnailUrl social-preview asset is a generic branded placeholder (the Reelay wordmark on a neutral background, not a frame of the video's actual content, not even a blurred one), never the real animated preview or poster — because a crawler fetching that URL cannot prove it is the intended recipient, and a real content frame would leak the video's visual content to anyone capable of guessing or scraping the preview URL, defeating the purpose of the password in the first place. The real, versioned poster/animated-preview asset is served only through the normal viewer access chain (14.8.1), never through the unauthenticated social-preview path, for any password-protected link regardless of its visibility.

14.9 The audit trail #

This is the control that exists specifically to catch and make forensically traceable the failure mode that matters most in this product: a video made public that was meant to stay internal. Every visibility or permission change on any share link, without exception, writes an immutable audit event before the change is considered committed.

14.9.1 What is audited #

Every mutation to any of: visibility, password_hash (set/cleared, never the hash value itself), expires_at, domain_allowlist, disable_download, disable_comments, require_email, recipients_only, and every add/revoke of a share_link_recipients row. Read operations (viewing a link's settings) are not audited here — only state changes.

14.9.2 Event shape #

The schema is canonical in Section 5; referenced here for completeness of this section's guarantee:

{
  "id": "evt_...",
  "shareAudit": {
    "shareLinkId": "lnk_...",
    "videoId": "vid_...",
    "actorUserId": "usr_...",
    "action": "visibility_changed",
    "beforeState": { "visibility": "private" },
    "afterState": { "visibility": "public" },
    "occurredAt": "2026-03-14T09:12:03.441Z",
    "actorIp": "203.0.113.42",
    "actorUserAgent": "Mozilla/5.0 ...",
    "surface": "dashboard"
  }
}

surface is an enum: dashboard (web app settings UI), public_api (Business-plan API key), bulk_op (Section 14.10's bulk-sharing action, itself audited once per affected link with surface: 'bulk_op' and a shared bulkOperationId correlating the batch), or system (automated, e.g. the expiry job never writes one of these since expiry is not a permission change, but an automated downgrade triggered by plan-cap enforcement, Section 21, does).

The write is transactional with the share_links mutation itself — both happen in the same database transaction, so it is structurally impossible for a visibility change to commit without its corresponding audit row also committing (Section 5 owns the transaction boundary detail).

14.9.3 Retention #

share_audit_events rows are retained for the lifetime of the workspace and are explicitly exempt from every retention/deletion policy in Section 19 except full workspace deletion (Section 19's account- closure path) — a video's own deletion, expiry, or the standard inactive-workspace retention window never purges its audit history, because the audit trail's value is realized precisely in incident investigation, which by definition happens after the fact and often after the video itself has been taken down.

14.9.4 Who can read the audit trail #

owner and admin roles only, workspace-scoped (an admin sees only their own workspace's audit events, never cross-workspace). GET /v1/workspaces/{id}/share-audit-events — cursor-paginated per Section 7's convention, filterable by videoId, actorUserId, action, and a date range. member and viewer roles receive 403 insufficient_role on this endpoint.

14.9.5 UI, export, and alerting #

  • UI: a dedicated "Sharing Activity" panel in workspace settings, reverse-chronological, with a visual highlight (distinct badge/color, not merely a text label) on any afterState.visibility === 'public' row.
  • Export: GET /v1/workspaces/{id}/share-audit-events/export streams a CSV of the full filtered result set (same filters as 14.9.4), available to owner/admin, generated synchronously up to 10,000 rows and as an async job (Section 9's job pattern, delivered via the existing export/download mechanism) beyond that.
  • Optional alerting: a workspace-level toggle, notification_preferences.alert_on_public_share (default on for Business plan, default off for Free/Pro to avoid alert fatigue on lower-touch plans, changeable by any admin at any time). When on, any share_audit_events row where afterState.visibility === 'public' triggers an immediate email (not batched, not delayed) to every owner/admin of the workspace, containing the video title, the actor, the timestamp, and a direct link to revert visibility. This is the single most important notification in the product's alerting surface and is explicitly exempted from any digest- batching or quiet-hours logic that governs other notification types (Section 17).

14.10 Bulk sharing operations and folder-level sharing inheritance #

Section 18 owns the folder schema and general permission model; this subsection states only how link security composes with it.

14.10.1 Bulk operations #

POST /v1/share-links/bulk accepts an array of up to 100 videoIds and a single settings payload (any subset of the per-link fields in 14.2–14.7) applied identically to each target video's default share link (creating one if the video has none). The endpoint is atomic per-item, not all-or-nothing across the batch — each video's update succeeds or fails independently, and the response body is an array of per-item results:

{
  "data": {
    "bulkOperationId": "blk_...",
    "results": [
      { "videoId": "vid_1", "status": "ok", "shareLinkId": "lnk_1" },
      { "videoId": "vid_2", "status": "error", "code": "insufficient_role" }
    ]
  },
  "meta": null
}

Every successful item writes its own share_audit_events row with surface: 'bulk_op' and the shared bulkOperationId (14.9.2), so a bulk action that accidentally makes 40 videos public is exactly as traceable, and exactly as visible to the alerting in 14.9.5, as a single manual change — bulk operations receive no exemption from audit or alerting.

14.10.2 Folder-level sharing inheritance #

A folder (Section 18) may itself carry a folder_permissions visibility-like setting that grants workspace members access to every video inside it. This is a separate mechanism from a video's own share_links — folder permissions govern workspace-internal access (who among workspace members can browse into the folder and see its contents), while share_links governs external sharing (link/public access, password, expiry, domain restriction). A video's own share_links settings are never inherited from or overridden by its folder — moving a video into a more restrictive folder does not revoke an existing external share link, and moving a video into a permissive folder does not create one.

Precedence rule: for workspace members evaluating internal access, effective access is the union, not the intersection, of (a) the member's role-based default (Section 6 — admin/owner always see everything; member/viewer see only what they created or were explicitly granted) and (b) any folder-level grant on a folder containing the video. A member who lacks folder access but holds a valid share_links recipient token for a specific video (14.7.1) can still open that one video via its external share link — folder-level restriction constrains browsing/discovery inside the dashboard, it does not add a check to the external playback-token issuance path in 14.8.1, which validates only against the share link's own settings. This separation is intentional: it is what makes it possible for a member to share one video externally with a customer without that customer gaining any visibility into the rest of the folder or workspace.

14.11 Embedding #

14.11.1 Allowed contexts #

A video may be embedded via: (a) the JavaScript embed script (Section 15.1, the primary, recommended path), (b) a plain <iframe> fallback (Section 15.8.4), or (c) oEmbed auto-discovery for platforms that support it, which itself resolves to the same iframe fallback (14.11.3). All three routes converge on the same server-side access checks for password and expiry in 14.8 — there is no embed path that bypasses either. Domain restriction is the one exception, and by design rather than oversight: it is enforced only via route (a) (Section 14.5), and routes (b) and (c) refuse to play a domain-restricted link outright rather than silently skipping the check (Section 14.5.4) — an iframe cannot bypass domain restriction, it simply cannot ever satisfy it.

14.11.2 The embed code the user copies #

The dashboard's "Embed" panel generates:

<div style="position:relative;padding-top:56.25%;">
  <script src="https://embed.reelay.app/v1/embed.js" async
          data-reelay-video="rl3f9Qm2Xk8Bv7Ht"
          data-reelay-slug="8gT4kNc1Xm2P"></script>
</div>

The wrapping <div> with padding-top: 56.25% (the video's own aspect ratio, computed server-side per video, not hardcoded to 16:9) is emitted directly in the copied snippet specifically so a host page that strips or fails to load embed.js entirely still reserves layout space — reinforcing the zero-CLS guarantee from Section 15.2 even in the worst case.

14.11.3 oEmbed support #

GET /oembed?url=https://reelay.app/watch/{slug}&format=json (and &format=xml), implementing the oEmbed 1.0 spec, type: "video", returning html (an iframe snippet), thumbnail_url, width, height, provider_name: "Reelay". Auto-discovery <link> tags are emitted on the watch page itself:

<link rel="alternate" type="application/json+oembed"
      href="https://api.reelay.app/v1/oembed?url=https://reelay.app/watch/8gT4kNc1Xm2P&format=json"
      title="Reelay video" />

oEmbed responses respect the same access checks: a private or expired link's oEmbed endpoint returns 403/410 with no html payload, so third-party embedders (Slack unfurls, Notion embeds) never leak a preview of a video the requester should not be able to see.

Rendered server-side (not client-side, so crawlers that do not execute JavaScript still see it) into the watch page's <head> only for link and public visibility links (a private/workspace link's watch page renders no preview metadata at all, and no title/thumbnail, to avoid leaking content existence to an unfurl bot that reaches the URL without proper access):

<meta property="og:type" content="video.other" />
<meta property="og:title" content="Q3 Product Walkthrough" />
<meta property="og:description" content="A 4-minute walkthrough of the new dashboard." />
<meta property="og:image" content="https://cdn.reelay.app/posters/8gT4kNc1Xm2P/v3/animated.jpg" />
<meta property="og:video" content="https://reelay.app/watch/8gT4kNc1Xm2P" />
<meta property="og:video:type" content="text/html" />
<meta property="og:video:width" content="1280" />
<meta property="og:video:height" content="720" />
<meta name="twitter:card" content="player" />
<meta name="twitter:player" content="https://embed.reelay.app/v1/frame/8gT4kNc1Xm2P" />
<meta name="twitter:player:width" content="1280" />
<meta name="twitter:player:height" content="720" />
<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@type": "VideoObject",
  "name": "Q3 Product Walkthrough",
  "description": "A 4-minute walkthrough of the new dashboard.",
  "thumbnailUrl": "https://cdn.reelay.app/posters/8gT4kNc1Xm2P/v3/poster.jpg",
  "uploadDate": "2026-03-01T10:00:00Z",
  "duration": "PT4M12S",
  "contentUrl": "https://reelay.app/watch/8gT4kNc1Xm2P"
}
</script>

The path segment shown as v3 above is the owning share link's playback_key_version (Section 14.2) at render time — never the bare {videoId} alone. og:image and the JSON-LD thumbnailUrl are the same versioned, access-controlled asset described in Section 14.8.7: rotating the link's playback_key_version (on a visibility downgrade or explicit revocation, Section 14.1.1) changes this path, so a previously-shared social-preview URL stops resolving to current content at the same instant playback does. There is no unversioned /posters/{videoId}/... path served by this product; Section 14.8.7 states the one poster/thumbnail cache policy that governs every asset of this kind, including these.

Animated-preview poster: og:image points at an animated JPEG/WebP (not a static frame) generated by the render worker (Section 9) at share-link creation time — a 3-second, silent, looping clip sampled from three evenly-spaced points in the source video (10%, 50%, 90% of duration), encoded as an animated WebP with a JPEG fallback for consumers that request Accept: image/jpeg only, capped at 300 KB. Not every social platform renders animated Open Graph images, so twitter:image/og:image degrades gracefully to the first frame of that same clip when a static fallback is required by the requesting platform's documented Accept behavior.

Password-protected links: a link/public link that also has a password set (Section 14.3) still renders preview metadata (unlike private/workspace, which renders none) but og:image and thumbnailUrl point at a generic branded placeholder image — the Reelay wordmark on a neutral background — never a frame, blurred or otherwise, of the video's actual content. This is Section 14.8.7's policy applied here: a social crawler is an unauthenticated, non-interactive fetcher that cannot present the link's password, so it can never be trusted with the real content frame, regardless of how the preview URL itself is protected.

14.12 The watch page #

The watch page (/watch/{slug}) is the canonical, full-page viewing surface — distinct from the embeddable player (Section 15), which it also uses internally as its player implementation, but the watch page additionally renders:

Region Content
Header Workspace/brand-kit logo (Section 18) if set, else "Powered by Reelay" wordmark
Player The embeddable player itself (Section 15), full width up to a max content width of 1120px
Title bar Video title, owner name/avatar, upload date, duration
Transcript panel Collapsible side panel (desktop) / below-player accordion (mobile) rendering the full transcript (Section 12) with active-segment highlighting synced to playback position via timeupdate, click-to-seek on any transcript line
Chapters Rendered as a list beneath the title bar and as markers on the player's seek bar (Section 15.6); clicking seeks
Comments Threaded comment list below the player (Section 17), hidden entirely if disable_comments = true
CTA Rendered per Section 17's placement rules, composed within the player per Section 15.11
Footer Legal/privacy links, "Report this video" action

14.12.1 Branding #

Free and Pro-personal-branding plans show the Reelay wordmark in the header; Business-plan workspace-enforced brand kits (Section 18, Section 7's plan table) replace it with the workspace's logo, primary color applied to the play button/accent chrome, and (Business only) can fully suppress the "Powered by Reelay" footer credit. Free-plan viewers additionally see the watermark described in 14.12.2 and Section 15.12 regardless of any brand-kit setting, since the watermark is a monetization control independent of branding.

14.12.2 Free-tier watermark rule #

Any video whose owning workspace is on the Free plan at render time is watermarked — this is determined by the workspace's plan at the time each rendition is produced (Section 9's pipeline), not re-evaluated per-view, which is why an upgrade triggers a re-render job (Section 21) rather than an instant watermark removal. Full placement, opacity, and burn-in mechanics are owned by Section 15.12, since the watermark is rendered by the player and is also burned into every delivered rendition/export — this section states only the plan-gating rule; 15.12 owns the pixels.

14.13 The iron rule #

Stated here in full, jointly owned with Sections 19 and 21, and repeated verbatim in those sections so no reader of any one section misses it:

Hitting a plan cap never breaks an already-shared link. Plan limits (Section 7's table — seat count, library video cap, storage quota) gate the creation of new resources: a new recording, a new upload, a new render, a new share link past a hypothetical creation-time cap. They never gate playback. A video that has already been shared or embedded continues to play indefinitely regardless of the owning workspace's current plan, a downgrade, a cap breach, or a library-count overage. Downgrading a workspace moves it into a read-only-for-creation overage state (Section 21) — existing share links, existing embeds, and existing playback tokens are entirely unaffected by that state. The only things that ever stop a previously working share link from playing are: (1) the owner or an admin explicitly changing that link's visibility or deleting it (Section 14.1, audited per 14.9), (2) the link's own expires_at passing (Section 14.4), or (3) the documented retention/deletion policy in Section 19 running with its required prior notice. No billing event, plan transition, or usage-cap enforcement mechanism anywhere in this product is ever permitted to be a fourth path to stopped playback. This constraint must be honored by every implementation of plan enforcement (Section 21) and every retention job (Section 19) — a code review that finds a cap-enforcement code path touching share_links or the playback-token issuance path in 14.8.1 for any reason other than the three listed above is a defect against this section.


15. The Embeddable Player #

The player is the part of this product least under this product's own control: it runs inside pages Reelay does not own, cannot audit ahead of time, and cannot assume anything about — a marketing site built on a legacy jQuery stack, a help-center article, a Notion page, an email client that cannot run JavaScript at all. It is loaded once and then judged forever by how little it costs the host. This section owns the performance budget, style isolation, playback implementation, accessibility conformance, and every degraded-context fallback (embed iframe, email) required to make that promise hold everywhere it is embedded.

15.1 Architecture: a two-stage load #

The player ships as two independently cacheable artifacts, both built from packages/player (vanilla TypeScript, zero runtime dependencies, zero framework of any kind — no React, no Preact, no Vue, nothing that ships its own component/rendering runtime into the host page):

  1. The loader (embed.js) — the only script tag the host page ever adds. Its entire job is: read its own data-* attributes, reserve layout (the aspect-ratio box, Section 15.2), render the poster <img>, set up an IntersectionObserver (Section 15.4), and — only once intent is established — dynamically import() the player core.
  2. The player core (player-core.js) — fetched lazily, attaches a closed Shadow DOM, builds the actual control surface, initializes hls.js or native HLS (Section 15.5), and starts playback.

15.1.1 Reasoning #

A host page that embeds a video but where the viewer never scrolls to it, or never clicks it, should pay almost nothing — not the cost of a video player's control surface, icon set, or HLS engine, and certainly not the cost of a UI framework runtime. Splitting loader from core means the guaranteed, always-paid cost is the loader's few kilobytes, and the much larger core is paid only by visitors who actually watch. A framework runtime is excluded entirely — not "excluded from the loader," excluded from both artifacts — because: (a) a framework runtime alone typically costs more than this entire player's combined budget (Section 15.2), (b) shipping React/Preact risks a second, conflicting copy of the same framework already used by the host page (version clashes, duplicate global state, hydration bugs the host page never asked for), and (c) the player's actual UI surface (a handful of buttons, a seek bar, a settings menu) does not need componentized declarative rendering to build correctly — direct DOM APIs inside the shadow root are sufficient and strictly smaller.

15.1.2 Packaging #

packages/player builds two entry points via the monorepo's bundler (Section 3), both output as immutable, content-hashed, versioned assets (Section 15.14 owns the full versioning/cache strategy):

packages/player/
  src/
    loader/          # embed.js source — no imports from src/core
    core/             # player-core.js source — the actual player
    shared/           # tiny shared utilities (e.g. base58/id helpers), used by both, kept minimal
  dist/
    embed.js                       # loader, versioned + short-TTL alias (Section 15.14)
    player-core.<hash>.js          # core, immutable long-TTL

loader and core are separate bundler entry points with no shared chunk between them beyond the handful of bytes in shared/ — this is verified in CI by the size-limit job asserting embed.js's bundle-analysis output contains zero references to any module under src/core.

15.2 The budget #

The shorthand version, referenced throughout this document wherever the player's size budget is summarized rather than itemized: loader ≤ 8 KB gz, core ≤ 20 KB gz. Every row below is measured in CI and fails the build on regression — none of these numbers are aspirational; they are enforced gates, and this table is their single source of truth.

Budget Target Measured by Enforcement
Loader script size ≤ 8 KB gzipped size-limit on dist/embed.js (gzip, not brotli, to measure the worst-case transfer cost) CI job player:size fails build if exceeded
Player core size ≤ 20 KB gzipped size-limit on dist/player-core.*.js CI job player:size fails build if exceeded
Host LCP impact 0 ms added Lighthouse CI run against a synthetic host-page harness (15.2.1), comparing LCP with and without the embed present, on 3 runs, median compared CI job player:lcp fails build if the delta exceeds 0 ms beyond a 20 ms measurement-noise tolerance
CLS contribution ≤ 0.0 Same Lighthouse CI harness; CLS score attributable to the embed's DOM region, measured via the Layout Instability API's sources field filtered to the embed container CI job player:cls fails build on any non-zero attributed shift
INP contribution ≤ 50 ms Playwright-driven interaction trace (click play, click seek, open settings menu) against the harness, using the Event Timing API CI job player:inp fails build if the worst interaction exceeds 50 ms
Total blocking time added ≤ 10 ms Lighthouse CI TBT delta, same harness, before/after embed present CI job player:tbt fails build if exceeded
Render-blocking requests 0 Static assertion on the loader script tag (async required) plus a network-trace assertion in the Playwright harness that no request initiated by the embed has renderBlockingStatus: "blocking" CI job player:network fails build on any render-blocking request
Synchronous layout on host 0 forced reflows Playwright trace with Layout Instability/long-task attribution; assert zero forcedStyleAndLayout entries attributable to embed scripts during initial load CI job player:network fails build on any detected forced reflow

15.2.1 The host-page harness #

packages/player/harness/ contains a deliberately hostile synthetic host page: a large above-the-fold hero image (its own LCP candidate, so the test proves the embed does not compete with or delay it), a render-blocking-adjacent stylesheet, and the video embed placed both above and below the fold in two harness variants. CI runs Lighthouse CI against both variants on every PR touching packages/player/**, using a fixed, pinned throttling profile (simulated "Slow 4G" + 4x CPU slowdown, Lighthouse's standard mobile profile) so results are comparable run over run. Results are stored and the PR check fails on regression against the immediately preceding merged baseline, not just against the absolute budget — a build can be under-budget in absolute terms and still fail CI if it silently regressed from a previously-smaller baseline, with an explicit override label (player-budget-exception, requiring a second approving review) for the rare intentional increase.

Section 27 references these budgets as the platform-wide performance targets for the embed surface; the numbers themselves are owned here and must not be restated with different values anywhere else in this document.

15.3 Style isolation #

The player core attaches a closed Shadow DOM (element.attachShadow({ mode: 'closed' })) to a single host element the loader creates. Closed, not open, specifically so host-page JavaScript cannot obtain a reference to shadowRoot and reach into the player's internal DOM even if it tries (element.shadowRoot returns null for a closed root) — this is a deliberate hardening choice beyond what style isolation alone requires, because a host page that can reach into the player's DOM could also interfere with the access-control-gated video element itself.

  • All styles live inside the shadow root, authored as a single bundled stylesheet constructed via CSSStyleSheet + adoptedStyleSheets (falling back to an injected <style> tag inside the shadow root for browsers without adoptedStyleSheets support, per the browser matrix in 15.13). Zero CSS is ever added to the host document's <head> or any host stylesheet.
  • No global CSS: the player never queries or writes document.body, :root, or any selector outside its own shadow root. It never uses a CSS reset or normalize sheet targeting global tags (*, body, html) — resets are scoped to :host and descendant selectors within the shadow root only.
  • No !important leakage in either direction: the player's own stylesheet uses no !important declarations (the Shadow DOM boundary already gives it specificity/encapsulation without needing them), and — because a closed shadow root's internal styles cannot be targeted by host-page selectors at all (shadow DOM's fundamental encapsulation guarantee, independent of !important) — a host page's own !important rules, however aggressive, cannot cross the boundary in either direction. This is verified in the harness (15.2.1) by injecting a maximally aggressive host stylesheet (* { all: revert !important; }-style rules) into the harness page and asserting the player's computed styles are unchanged.
  • Font strategy that never blocks: the player does not @import or <link> any web font. It specifies a system-font stack only (-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif) for all in-player text (control labels, settings menu, captions rendered as overlay text). This guarantees zero font-loading network requests and zero FOIT/FOUT inside the player, at the deliberate cost of not matching a brand kit's custom font (brand-kit font matching, where offered, applies only to the watch page's surrounding chrome per Section 14.12, never to the isolated player's internal control labels).
  • Host-page CSS resets cannot break the player: because the closed shadow root is a separate DOM tree with its own cascade, a host-page reset that zeroes out box-sizing, line-height, or button/ form-control default styling (a common source of embedded-widget breakage in the industry) has no effect on anything inside the shadow root — the player defines its own box-sizing: border-box and full baseline styling for every element it creates, inheriting nothing from the host except the handful of CSS properties that are defined by the platform to cross shadow boundaries by design (e.g. color, font-family if not explicitly set — mitigated by the player always explicitly setting its own font-family at :host scope, per the font strategy above, so even inheritable properties are pinned rather than left to inherit).

15.4 Lazy loading and intent detection #

15.4.1 Default mode #

  1. The loader renders the poster as a plain <img loading="lazy" decoding="async" src="..." width="…" height="…"> outside the shadow root, directly in the reserved aspect-ratio box — a plain <img> rather than a canvas or background-image specifically so the browser's own native lazy-loading and LCP-candidate handling apply to it exactly as they would to any other image, with no custom JavaScript needed to defer it.
  2. An IntersectionObserver (threshold 0.25, rootMargin: "200px 0px") watches the container. Only when the container crosses 25% visible within 200px of the viewport does the loader begin the dynamic import() of the player core in the background — the video element itself is not created, and no media request is issued, at this point; this only warms the core script and initializes hls.js.
  3. preload="none" is set on the underlying <video> element once created — no media byte is fetched until the viewer expresses actual play intent (a click), even after the core has loaded. This two- tier gate (viewport intent warms the code; a click warms the media) is what keeps a page with many embedded videos from issuing dozens of concurrent media requests just because the viewer scrolled past them.
  4. Click-to-load: clicking the poster (or the play button rendered on top of it, still outside the shadow root until the core has attached) triggers: player-core mount (if not already warmed by step 2, a synchronous load-and-mount at this point instead), signed-playback-token fetch (Section 14.8.1), and playback start, in that order.

15.4.2 Optional eager mode #

data-reelay-eager="true" on the loader script tag skips the IntersectionObserver gate and begins fetching the player core immediately on page load, still gated behind the page's load event (never blocking DOMContentLoaded or competing with the host's own critical-path resources) and still gated on preload="none" for the media itself. Stated cost: eager mode forfeits the "pay nothing until scrolled-to" guarantee — it adds the full player-core fetch (≤20 KB gzipped) to every page load regardless of whether the viewer ever reaches the video, and is therefore only recommended for pages where the video is the primary above-the-fold content (e.g. a dedicated landing page built around a single hero video) rather than pages with the embed further down or used incidentally.

15.5 Playback #

15.5.1 HLS strategy #

Browser Mechanism
Safari (macOS and iOS), any browser exposing canPlayType('application/vnd.apple.mpegurl') Native HLS via the <video> element's built-in support — no hls.js loaded, saving the entire hls.js payload for these browsers
All others (Chromium-based, Firefox) hls.js (version line in Section 3), using Media Source Extensions, loaded as part of the player-core bundle

Native-support detection runs once at player-core initialization and permanently decides the code path for that session — there is no runtime fallback mid-playback from native to hls.js or vice versa.

15.5.2 ABR behavior #

hls.js is configured with its standard bandwidth-based ABR algorithm, tuned with: abrEwmaFastLive: 3.0, abrEwmaSlowLive: 9.0 seconds (the exponential-moving-average windows controlling how quickly estimated bandwidth reacts to change), abrBandWidthFactor: 0.95 and abrBandWidthUpFactor: 0.7 (conservative headroom before switching up a rung, to avoid oscillation), maxStarvationDelay: 4 seconds (the buffer-starvation threshold before ABR forces a downward switch regardless of estimated bandwidth). Native Safari HLS uses the platform's own ABR implementation, which is not configurable — this is an accepted platform constraint, not a gap, since Safari's built-in algorithm is well-tuned for its own decode/network stack.

15.5.3 Buffering strategy #

Target forward buffer: 30 seconds (maxBufferLength: 30), backward buffer retained for instant rewind: 10 seconds (backBufferLength: 10, older segments evicted to bound memory on long-running embeds). Maximum buffer size cap: 60 MB (maxBufferSize), preventing unbounded memory growth on very long recordings played for extended periods.

15.5.4 Start-time-to-first-frame target #

Measured from click (play intent) to first decoded video frame painted: p50 ≤ 800 ms, p95 ≤ 1800 ms, on a warmed CDN edge cache and a broadband connection (the same throttling profile as 15.2.1's harness for the p95 figure specifically, to keep the target honest under realistic mobile conditions). This is achieved by: the signed-playback-token fetch (14.8.1) and the HLS manifest fetch being issued concurrently, not sequentially, wherever the token doesn't have to gate manifest URL construction; a short initial segment duration (2-second HLS segments, Section 9's transcode-ladder configuration) so the very first segment fetch is small; and startLevel: -1 (hls.js auto-selects the first rendition based on the initial bandwidth estimate rather than always starting at the lowest rung, balancing fast-start against not starting so low it produces a visible quality jump seconds later).

15.5.5 Quality selector #

A settings-menu quality option listing available renditions (e.g. 1080p, 720p, 480p, 360p) plus "Auto" (default, ABR-driven). Manually selecting a fixed rung disables ABR for the remainder of that session (hls.currentLevel = <index>); selecting "Auto" re-enables it (hls.currentLevel = -1).

15.5.6 Playback speed #

Standard rates: 0.5x, 0.75x, 1x (default), 1.25x, 1.5x, 1.75x, 2x. Implemented via videoElement.playbackRate; persisted per-viewer in localStorage scoped to the player's own origin (embed.reelay.app, inside the iframe/shadow-DOM-hosted context, never the host page's localStorage) so a viewer's preferred speed carries across videos without ever touching host-page storage.

15.5.7 Error recovery #

hls.js error handling follows the standard tiered recovery pattern, implemented explicitly rather than left to defaults:

Error type Recovery
NETWORK_ERROR, fatal Retry hls.startLoad() up to 4 times with backoff (500 ms, 1 s, 2 s, 4 s); on final failure, render the inline error state (15.5.8)
MEDIA_ERROR, fatal Call hls.recoverMediaError() once; if a second MEDIA_ERROR follows within 3 seconds, treat as unrecoverable and render the inline error state
Manifest load failure (expired/invalid token) Trigger an immediate playback-token re-issue (Section 14.8.2) and retry manifest load once; if the re-issued token also fails, render the appropriate access-denied state from Section 14.8.5's error table, translated to viewer-facing copy
Non-fatal buffering stalls No user-visible error; standard hls.js internal recovery, surfaced only as the buffering spinner

15.5.8 Inline error state #

Rendered inside the reserved aspect-ratio box (never collapsing it, preserving the zero-CLS guarantee even on failure): an icon, one line of plain-language copy ("This video couldn't be played. [Retry]"), and a retry button that re-runs the full initialization sequence from 15.4.1 step 4.

15.6 The control surface #

Control Keyboard binding (when player has focus) Focus behavior Touch target
Play/Pause Space or k Primary tab stop; Enter/Space activates 44×44 px minimum
Seek back 10s or j Not a separate tab stop (handled as a player-level keydown, not a focusable button) n/a (keyboard/gesture only)
Seek forward 10s or l Same as above n/a
Frame-accurate scrub , (back one frame, paused only) / . (forward one frame, paused only) Same as above n/a
Volume up/down / Same as above n/a
Mute toggle m Tab stop 44×44 px
Seek bar / when focused (1s steps), Home/End (start/end) Tab stop, role="slider", aria-valuenow/aria-valuemin/aria-valuemax updated continuously 44 px min height hit area (visual track may be thinner)
Fullscreen toggle f Tab stop 44×44 px
Playback speed menu > cycles rates Tab stop, opens a role="menu" popup 44×44 px
Quality selector none (mouse/touch/menu-navigation only) Tab stop, role="menu" popup 44×44 px
Captions toggle c Tab stop 44×44 px
Picture-in-picture none dedicated (standard browser PiP affordance where supported) Tab stop 44×44 px
Download (if not disabled, Section 14.6) none dedicated Tab stop 44×44 px
Transcript panel toggle (watch page only, not the embedded player) t Tab stop 44×44 px

All keyboard bindings are active only when focus is within the player's shadow root or the player has established a "player has keyboard capture" state via a visible focus ring on the outer container (clicking into the player, or tabbing to it) — bindings never intercept keystrokes intended for the host page when the player is merely present on screen but not focused, which is essential given the player lives inside pages it does not control.

15.7 Accessibility in the player #

Section 23 owns the WCAG 2.2 AA standard the whole product conforms to; this subsection states the player's specific conformance mechanisms and must not be read as introducing any different standard.

  • Full keyboard operability: every control in 15.6 is reachable and operable without a pointing device, per the bindings table. No control is mouse-only or hover-only.
  • Focus order: DOM order inside the shadow root matches visual left-to-right order of the control bar (play/pause → seek bar → time display → volume → captions → speed → quality → PiP → download → fullscreen), a single linear tab sequence with no positive tabindex values (only 0 and -1 are used, so order is governed purely by DOM position, which is easier to keep correct as the control bar evolves).
  • ARIA roles and live regions: the outer player container has role="application" with an aria-label naming the video title (aria-label="Video player: {title}"); the seek bar is role="slider"; menus are role="menu"/role="menuitem"; a visually-hidden aria-live="polite" region announces state changes that are not otherwise conveyed by focus movement (e.g. "Playing", "Paused", "Buffering", quality-change confirmations) without interrupting screen-reader users mid- sentence (polite, not assertive, since none of these are time-critical alerts).
  • Captions ON by default: if the video has captions (Section 12), they render enabled by default on first play for every viewer — this is a deliberate default-on choice (not merely "available"), reflecting both accessibility best practice and the reality that a large share of embedded-video views happen with sound off. A viewer's explicit toggle-off is remembered per-viewer (same origin-scoped localStorage mechanism as 15.5.6) and respected on subsequent videos.
  • Transcript view: the embeddable player itself does not render the full transcript panel (that is a watch-page-only affordance per Section 14.12, since the panel's layout footprint is incompatible with the player's minimal embedded budget) but exposes a "View transcript" control that deep-links to the watch page's transcript-visible state (?transcript=open) — so transcript access is never fully unavailable from an embedded context, only one click further away.
  • Reduced motion: the player reads prefers-reduced-motion (via matchMedia, evaluated inside the shadow root's own script context) and, when set to reduce, disables all non-essential CSS transitions/animations in its control-surface chrome (fade-ins, control-bar slide/hide animations) — controls instead appear/disappear instantly. This has no effect on the video content itself, only the player UI chrome.
  • Contrast: all control-surface icons, text, and the focus ring meet WCAG 2.2 AA's 3:1 (non-text/ UI components) and 4.5:1 (text, e.g. the time display and menu labels) contrast ratios against their immediate background at every supported theme (the control bar renders on a semi-opaque scrim over the video regardless of the underlying frame's content, specifically so contrast is guaranteed independent of what's playing).

15.8 The embed API #

15.8.1 Script tag and data attributes #

<script src="https://embed.reelay.app/v1/embed.js" async
        data-reelay-video="rl3f9Qm2Xk8Bv7Ht"
        data-reelay-slug="8gT4kNc1Xm2P"
        data-reelay-autoplay="false"
        data-reelay-muted="false"
        data-reelay-controls="true"
        data-reelay-eager="false"
        data-reelay-start-at="0"
        data-reelay-theme="light"></script>
Attribute Type Default Notes
data-reelay-slug string required The share link slug (Section 14.2)
data-reelay-video string optional Public video ID, used only for the JS API's addressing when a page embeds multiple players; not a security boundary (the slug is)
data-reelay-autoplay boolean false If true, implies data-reelay-muted="true" is enforced (browsers block unmuted autoplay; the player will not attempt and fail silently — it forces muted autoplay and surfaces an unmute affordance)
data-reelay-muted boolean false Initial mute state
data-reelay-controls boolean true false hides the control surface for a fully custom-chrome integration driven entirely by the JS API
data-reelay-eager boolean false Section 15.4.2
data-reelay-start-at integer (ms) 0 Initial seek position
data-reelay-theme light | dark light Control-surface color scheme

15.8.2 JavaScript API surface #

The loader exposes a global window.Reelay namespace once loaded, with a per-embed handle obtainable via Reelay.get(videoId) or from the Reelay.ready promise resolved per instance:

interface ReelayPlayer {
  play(): Promise<void>;
  pause(): void;
  seek(ms: number): void;
  getCurrentTime(): number;
  getDuration(): number;
  setVolume(level: number): void;   // 0.0–1.0
  setMuted(muted: boolean): void;
  setPlaybackRate(rate: number): void;
  destroy(): void;                  // tears down the player, releases the media element and Shadow DOM
  on(event: ReelayPlayerEvent, handler: (payload: unknown) => void): () => void;  // returns unsubscribe
}

type ReelayPlayerEvent =
  | 'ready' | 'play' | 'pause' | 'ended' | 'seek' | 'timeupdate'
  | 'volumechange' | 'error' | 'ctaClick' | 'emailSubmit';

timeupdate fires at most every 250 ms (throttled), carrying { currentTimeMs, durationMs }. All handler invocations are wrapped in a try/catch inside the player core so a throwing host-page handler can never break internal player state.

15.8.3 postMessage protocol for cross-origin control #

When the player is mounted inside a cross-origin iframe (15.8.4's fallback mode — the primary shadow-DOM mode is same-document and needs no postMessage), the same API surface is exposed via a postMessage-based RPC:

Host → iframe:  { reelayRpc: true, id: "call_1", method: "play", args: [] }
iframe → Host:  { reelayRpc: true, id: "call_1", result: undefined }
iframe → Host:  { reelayRpc: true, event: "timeupdate", payload: { currentTimeMs, durationMs } }

Every message is tagged with the literal reelayRpc: true marker and the receiving side validates event.origin against the known counterpart origin (the iframe's src origin on the host side; the fixed parent origin recorded at handshake time on the iframe side) before processing — messages from any other origin, or without the marker, are ignored. The wrapper library shipped alongside the loader (Reelay.wrap(iframeElement)) implements this transparently so host code calling player.play() behaves identically whether the underlying mount is shadow-DOM-same-document or cross-origin-iframe.

15.8.4 The iframe fallback and its trade-offs #

<iframe src="https://embed.reelay.app/v1/frame/{slug}" loading="lazy" allow="fullscreen; picture-in- picture" style="aspect-ratio: 16/9; width:100%; border:0;"></iframe> is offered for hosts that cannot execute the loader script (strict CSP environments that block third-party <script src>, some CMS sandboxes, README/markdown renderers that strip <script> tags but permit <iframe>).

Aspect Shadow-DOM loader mode Iframe fallback
Bundle cost counted against host loader ≤ 8 KB gz, core ≤ 20 KB gz, per 15.2 The entire iframe document's own resources, not counted against the same budget rows since it's a separate browsing context — but real cost to the user's connection is comparable
Style isolation Shadow DOM (15.3) Full browsing-context isolation — strictly stronger, an iframe cannot be styled by the host at all beyond its own box
JS API Direct synchronous calls Async postMessage RPC (15.8.3) — every call is a message round-trip
LCP/CLS accounting Counted precisely per 15.2's harness methodology Browsers generally do not attribute an iframe's internal LCP to the host page's LCP at all, which understates real user-perceived cost — this is a known limitation of iframe-based embeds industry-wide, not specific to this product, and is exactly why the shadow-DOM loader is the recommended default
CSP-hostile host compatibility Requires the host to allow script-src for embed.reelay.app Requires only frame-src/child-src allowance, which many stricter CSPs already permit
Domain-restricted link support Supported — the domain-attestation handshake (Section 14.5.3) Not supported, by construction — see below

The iframe fallback is documented as the compatibility path, not the default — the embed-code generator (14.11.2) always emits the loader-script snippet first, with the iframe alternative available behind a secondary "having trouble embedding?" link in the same UI panel.

Why domain restriction cannot work here, stated explicitly: the iframe's own document — everything that loads at https://embed.reelay.app/v1/frame/{slug} — always has document origin https://embed.reelay.app, regardless of what page embeds it or at what domain. This is a structural property of how browsers assign document origins to iframe content, not a bug or a gap in this particular implementation, and it cannot be worked around from inside the iframe by any technique — not document.referrer (blank or spoofable), not a postMessage probe, not a fetch's Origin header (which would itself just read back embed.reelay.app). Section 14.5.3 covers this in full, including why an earlier design that tried to use the iframe's Origin header for domain-allowlist checks was a genuine security bypass, not merely a limitation: it let every site on the internet pass a check meant to restrict viewing to a specific set of domains. That mechanism is deleted. A domain-restricted share link opened via this iframe-fallback path refuses to play and shows the explanatory message in Section 14.5.4, rather than attempting a check it cannot perform correctly.

15.9 Email embeds #

Email clients cannot run JavaScript (a hard, universal constraint across every client in the compatibility table below), so no embed described in 15.1–15.8 functions inside an email body. The email fallback pipeline is a distinct, fully server-rendered artifact.

15.9.1 Fallback pipeline #

  1. When a video is inserted into an email (via the product's own transactional/notification emails, or copied by a user into their own email tool as an "email-friendly embed" from the share panel), the render worker (Section 9) generates an animated GIF and a static poster with a play-button overlay, both pre-rendered and stored as CDN-hosted assets keyed by video ID and rendition version.
  2. The email HTML embeds the chosen asset as a plain <img> wrapped in an <a href="{watch page URL}"> — clicking anywhere on the image navigates to the real watch page (14.12), where actual playback happens.
  3. Which asset renders (animated GIF vs. static poster) is chosen per-client from the compatibility table in 15.9.3, driven by looking up the recipient email client from available signals (the sending ESP's client-detection where available) or, absent reliable detection, defaulting to the safest common denominator (static poster + play button) with the animated GIF offered as an opt-in for sends known to target GIF-supporting clients.

15.9.2 GIF generation parameters #

Parameter Value
Duration 3 seconds, sampled from the same three points as the Open Graph animated preview (Section 14.11.4) — same source clip, re-encoded as GIF instead of WebP, so the two are visually consistent
Frame rate 10 fps (sufficient for a preview loop, keeps file size down)
Dimensions 480px wide, height computed from the video's own aspect ratio, capped at 480×480
Color palette Adaptive 128-color palette (via FFmpeg's palettegen/paletteuse two-pass filter) — full 256-color GIF palettes rarely improve perceived quality for this content type and cost file size
Play-button overlay A static, semi-transparent (72% opacity) circular play icon composited center-frame on every frame, so the play affordance is visible throughout the loop, not just on a static poster
Target file size ceiling 700 KB hard cap; if the two-pass encode exceeds it, frame rate is reduced to 8 fps and re-encoded once; if still over, duration is reduced to 2 seconds — this fallback ladder runs automatically in the render worker, never surfaced as a manual step

15.9.3 Per-client size limits and compatibility table #

Client Animated GIF support Effective size limit applied Fallback used
Gmail (web and mobile app) Yes, full support, autoplays the loop 1 MB (Gmail's own image-display practicality ceiling; the 700 KB generation cap in 15.9.2 stays comfortably under it) N/A — animated GIF used directly
Apple Mail (macOS and iOS) Yes, full support 700 KB (15.9.2 cap) N/A — animated GIF used directly
Superhuman Yes (renders standard HTML email, inherits underlying webview GIF support) 700 KB N/A — animated GIF used directly
Outlook desktop (Windows, classic rendering engine — Word-based HTML renderer) No — historically renders only the first frame of an animated GIF, no looping N/A Static poster + play-button overlay (single frame, same composited overlay as the GIF's frames, so visual treatment matches)
Outlook.com / Outlook web Yes, full support 700 KB N/A — animated GIF used directly
Outlook mobile (iOS/Android app, uses a different rendering engine than desktop classic Outlook) Yes, full support 700 KB N/A — animated GIF used directly

The per-client table above is the concrete implementation of the general rule: any client without confirmed animated-GIF support receives the static poster with the play-button overlay, never a raw first-frame-only accidental render — the static-poster asset is generated with the play button already composited so that even a client that silently drops animation (like classic desktop Outlook) still shows an intentional, clickable-looking image rather than an oddly frozen mid-loop frame.

15.10 Analytics emission from the player #

Section 16 owns the analytics data model (ingestion endpoint, storage, rollups); this subsection owns only the player's emission contract — what it sends, when, and how.

  • Transport: navigator.sendBeacon() exclusively for all analytics emission from the player, with a fetch(..., { keepalive: true }) fallback only for the rare browser environment where sendBeacon is unavailable (per the browser matrix, 15.13). sendBeacon is used specifically because it guarantees delivery is attempted even when the tab is being closed or navigated away from mid-playback — a standard fetch without keepalive would be cancelled by the browser in that scenario, silently losing the final events of a viewing session.
  • Batching: discrete events (play, pause, seek, complete, cta_click, email_submit, reaction, comment) are queued client-side and flushed as a single batched sendBeacon call either every 5 seconds (aligned with the heartbeat interval below) or immediately if the queue reaches 20 events, whichever comes first — batching exists specifically to keep the number of outbound requests low regardless of how event-dense a viewing session gets (e.g. rapid seeking).
  • Heartbeat interval: every 5 seconds of active playback, a heartbeat event carrying { currentTimeMs, bufferedMs } is queued (subject to the same batching above) — this is what drives the per-second engagement/drop-off curve in Section 16, at a resolution coarser than per-second on the wire but reconstructed to the bucket granularity Section 16 defines during rollup.
  • No third-party cookies: the player sets no cookie on the host page's domain under any circumstance. The anonymous viewer identity (viewerToken, Section 16) is a value generated and stored via localStorage scoped to the player's own first-party origin (embed.reelay.app/api.reelay.app, reached inside the shadow-DOM context via the same-origin API calls, or inside the iframe fallback's own document for that mode) — never a host-page cookie, and never relies on third-party cookie support, which is disabled by default in an increasing share of browsers this product must support regardless. All analytics traffic is therefore first-party-to- Reelay from the player's own execution context, not a third-party resource from the host page's perspective in the cookie-partitioning sense.

15.11 CTA, email-capture and reaction rendering #

Section 17 owns the product behavior (when a CTA fires, what an email-capture gate requires, what reaction options exist); this subsection owns only how those render inside the player without breaking the isolation and budget guarantees above.

  • All CTA, email-capture form, and reaction-picker UI is rendered inside the closed shadow root, using the same system-font, no-external-CSS constraints as the rest of the control surface (15.3) — there is no separate styling system for these product features; they are built from the same internal component primitives as the control bar.
  • An email-capture form (when require_email, Section 14.7.2, or a mid-video opt-in CTA triggers it) submits via the same first-party API origin as analytics (api.reelay.app), never a host-page form submission, so no host-page CSRF/form-handling code ever sees or intercepts it.
  • CTA and reaction rendering is included in the ≤20 KB player-core budget (Section 15.2) — it is not an additional lazy-loaded chunk, because CTAs frequently need to render within the first few seconds of playback (an early CTA overlay) and a further lazy-load hop would risk missing that window; the size-limit CI job's 20 KB ceiling is measured on the core bundle inclusive of this functionality, which is why the ceiling is 20 KB rather than a lower number reflecting playback-only code. functionality.
  • Rendering a CTA overlay or reaction picker never resizes the reserved aspect-ratio box (Section 15.2's CLS guarantee) — these elements are always absolutely positioned within the existing player bounds (as an overlay atop the video, or a bottom-anchored bar with a max-height that was already accounted for in the box's reserved height when a CTA is known to be configured at embed-render time), never inserted as new box-model siblings that would push subsequent host-page content.

15.12 The free-tier watermark #

  • Placement: bottom-right corner of the video frame, inset 16px from each edge at the video's native resolution (scaled proportionally for delivered renditions below source resolution), sized to 12% of the frame width, never overlapping the control-surface scrim area (the control bar's presence does not move the watermark; the watermark is composited into the video image itself, not the player chrome, so it remains visible even in fullscreen or when an embedding host's chrome differs).
  • Opacity: 65% — visible enough to be an effective, unmissable brand/upgrade signal, low enough not to meaningfully obscure content in that corner.
  • How it is applied — two independent mechanisms, both required, not either/or:
    1. Player-rendered overlay: the player core (15.1) composites the "Reelay" wordmark as a DOM element positioned absolutely atop the <video> element, inside the shadow root, for every video belonging to a Free-plan workspace. This is what a viewer sees during normal streaming playback.
    2. Burned into every delivered rendition and export: the render worker (Section 9) additionally composites the identical watermark graphic directly into the pixel data of every HLS rendition and every exported file (MP4/GIF/WebM, Section 13) for a Free-plan-owned video, at encode time, via the FFmpeg overlay filter — an operation that runs as part of the standard transcode ladder, not as a conditional post-step, so there is no code path that produces a Free-tier rendition without it.
  • Why both: mechanism 1 alone is a DOM overlay, and DOM overlays can be removed by any viewer with browser devtools access (inspect element, delete node) — trivial and instant. Mechanism 2 exists specifically to close that gap: even a viewer who strips the DOM overlay from their own view, or who downloads/screen-records the video, still gets the watermark, because it is part of the video's actual pixel content in the delivered file, not an artifact of how the page happens to render it. Stated explicitly: the watermark cannot be removed by DOM manipulation, because DOM manipulation only ever affects mechanism 1, and mechanism 2's burned-in copy is what the underlying media actually contains regardless of what the player's DOM shows.
  • Upgrading a workspace off the Free plan does not retroactively un-watermark previously rendered files; it flags existing videos for re-render (Section 21) so the burned-in copies are regenerated without the watermark, and the player-rendered overlay stops appearing immediately (gated on the workspace's live plan, not a per-video flag) since mechanism 1 is evaluated at each playback, not baked at render time.

15.13 Browser support matrix and graceful degradation #

Browser Playback mechanism Shadow DOM sendBeacon Support tier
Chrome/Edge (Chromium) 120+ hls.js (MSE) Native Native Fully supported
Firefox 120+ hls.js (MSE) Native Native Fully supported
Safari (macOS) 16+ Native HLS Native Native Fully supported
Safari (iOS) 16+ Native HLS Native Native Fully supported
Safari (macOS/iOS) 14–15 Native HLS Native (closed mode supported since Safari 10) Native Fully supported, minor CSS custom-property fallback for a small number of newer properties
Chromium-based browsers 90–119 hls.js (MSE) Native Native Fully supported
Any browser without MSE and without native HLS (legacy/unsupported) None available Degraded: see below
Any browser without sendBeacon (playback unaffected) fetch(keepalive) fallback (15.10) Fully supported for playback; analytics uses fallback transport

15.13.1 Graceful degradation for unsupported browsers #

When neither native HLS nor MSE-based hls.js playback is available (detected at player-core initialization, before attempting to construct an Hls instance or set an HLS src), the player renders, within the same reserved aspect-ratio box:

  • The static poster image (already loaded per 15.4.1, so no additional request is needed).
  • A centered message: "Your browser doesn't support video playback here. [Watch on the web instead]" — the link target is the watch page URL (14.12), which performs the same capability check server-side-informed client-side and, if the visiting browser genuinely cannot play HLS at all, falls back to a progressively- downloaded MP4 rendition (always present in the rendition set per Section 9) via a plain <video src= "..."> with no HLS/MSE involvement — the ultimate fallback that works in effectively every browser capable of playing video at all.
  • No JavaScript error is thrown or surfaced to the host page; the degradation is fully contained.

15.14 Versioning and cache strategy #

The player ships to potentially millions of already-embedded <script> tags across the internet that the product cannot ask anyone to update — a fix must reach them without any embedder taking action.

15.14.1 Immutable-versioned-asset plus short-TTL-loader pattern #

  • player-core.<contenthash>.js: served with Cache-Control: public, max-age=31536000, immutable. Safe to cache forever because the filename itself changes on any code change (content-hashed) — there is never a need to invalidate a specific version's cache; a new version simply has a new URL.
  • embed.js (the loader, the one URL every embedded <script src> tag on the internet points at permanently): served with Cache-Control: public, max-age=300, stale-while-revalidate=86400 — a short, 5-minute freshness window plus a 24-hour stale-while-revalidate grace period, so: (a) a fix to the loader itself, or a change to which player-core.<hash>.js version it references, reaches every existing embed within minutes without requiring a single embedder to touch their page, while (b) the stale-while- revalidate window means even a CDN cache miss or brief origin issue serves the last-known-good loader from cache rather than failing the embed outright.
  • The loader always references the current player-core.<hash>.js version by reading a small, separately fetched (or inlined-at-build, re-deployed with the loader) version manifest — deploying a fix to the player core means: (1) build and publish the new immutable player-core.<newhash>.js asset, (2) deploy a new embed.js that points at it, (3) within the loader's 5-minute cache TTL, every embed on the internet begins fetching the new core on next load. No embed code snippet ever needs to change, because the snippet only ever references the stable embed.js URL, never a version-specific one.

15.14.2 Rollback #

Because both the previous and new player-core.<hash>.js assets remain published indefinitely (immutable assets are never deleted, only superseded — garbage collection of truly ancient unreferenced versions is a manual, rare operational action, not automatic), rolling back a bad player-core release is simply re-deploying embed.js pointing at the previous hash — the same 5-minute propagation applies, and no previously-cached player-core.<hash>.js reference becomes invalid, since that asset is still being served.

15.14.3 Version skew safety #

Because the loader and core are versioned and deployed together but fetched at different times (loader cached up to 5 minutes, core fetched fresh against whatever the loader currently references), a brief window can exist where two different site visitors load two different loader versions referencing two different core versions simultaneously during a rollout — this is safe by construction because embed.js and player-core.<hash>.js are versioned as a matched pair (the loader never references a core version it wasn't built against) and the postMessage/API contract (15.8) is additive-only across versions (new event types and methods may be added; existing ones are never removed or redefined), so an older loader talking to what would be a newer core is a non-issue in practice since the loader always fetches the core version it itself was deployed with, never an independently "latest" one.

16. Viewer Analytics #

This section defines the complete analytics pipeline: the event catalogue, the collection endpoint, viewer identity resolution, storage and rollups, the watch-time and drop-off model, per-viewer analytics, dashboard surfaces, real-time versus batch freshness, privacy posture, and accuracy guarantees. The success/error envelopes, pagination, and authentication conventions referenced throughout are defined once in Section 7 and are not restated here.

16.1 The event catalogue #

Every analytics event shares a common envelope. A single call to the collection endpoint (16.2) carries a batch of one or more of these event objects.

{
  "eventId": "evt_9G2xQhq1qh1MvVfZq9nR3a",
  "type": "heartbeat",
  "videoId": "vid_3kP1n8s6yWq2xR7bLZ0aQm",
  "shareLinkId": "lnk_7Yb2Vn4Xp9Kq1sT3wR8mLc",
  "sessionId": "6b1f7c2e-6b3a-4a3e-9e0b-6a4b2f7c1d9a",
  "occurredAt": "2026-08-19T14:22:07.318Z",
  "positionMs": 48210,
  "payload": { "positionMs": 48210, "bufferedMs": 61000, "playbackRate": 1, "quality": "1080p", "volume": 0.8, "fullscreen": false },
  "page": { "url": "https://acme.example.com/demos/q3-rollout", "referrer": "https://mail.google.com/" }
}

Common fields:

Field Type Notes
eventId string evt_ + 22-char base58, client-generated UUIDv7-derived. Doubles as the idempotency key for dedup (16.10).
type enum One of the 14 types below.
videoId string vid_ public id. Must match the videoId bound to the batch's playbackToken (16.2) or the event is rejected.
shareLinkId string | null lnk_ id if the view originated from a share link; null for authenticated in-workspace preview. Mandatory (per-event rejection if absent) when the target video has any domain-restricted share link, and must match the playbackToken's bound shareLinkId — see 16.2.
sessionId string (uuid) Generated once per player mount; stable for the life of the playback session.
occurredAt string (ISO 8601) Client clock. Server clamps skew per 16.4.2.
positionMs integer | null Player position at time of event, in source-time milliseconds. null for events with no playback position (e.g. share).
payload object Type-specific fields, see below.
page object { url, referrer }, origin + path only — query strings are stripped client-side before send.

Type-specific payload fields:

type Fires when payload fields
view_start Player mounts and begins loading autoplay: boolean, muted: boolean, playerVariant: 'inline'|'lightbox'|'watch-page', embedContext: 'direct'|'email'|'slack'|'iframe'
heartbeat Every 5000 ms of actual playing wall-clock time (paused time does not advance the timer) positionMs: int, bufferedMs: int, playbackRate: number, quality: string, volume: number (0-1), fullscreen: boolean
play Playback starts or resumes positionMs: int, trigger: 'user'|'autoplay'|'resume'
pause Playback stops positionMs: int, trigger: 'user'|'buffer'|'blur'|'end'
seek Player position jumps outside normal playback advance fromMs: int, toMs: int, trigger: 'scrub'|'chapter-click'|'comment-marker'|'keyboard'
quality_change Active rendition changes fromQuality: string, toQuality: string, trigger: 'auto'|'manual'
complete Position reaches ≥ 98% of durationMs, fires once per session watchedMs: int, positionMs: int
cta_click Viewer clicks a CTA (17.5) ctaId: string, ctaType: 'button_link'|'calendar_booking'|'custom_html', targetUrl: string | null
email_submit Viewer completes an email-capture gate (17.6) gateSource: 'before_playback'|'timestamp'|'after_playback', consent: boolean, hasName: boolean. The email address itself is never sent to this endpoint — see 17.6.
reaction Viewer drops a timestamped reaction (17.4) emoji: string, positionMs: int
comment_open Comment panel opens source: 'timeline-marker'|'panel-toggle'|'notification-link'
transcript_open Transcript panel opens source: 'panel-toggle'|'search'
download Viewer downloads an export renditionLabel: string, format: 'mp4'|'gif'|'webm'
share Viewer uses a share affordance from the watch page/player channel: 'copy-link'|'email'|'slack'|'native-share'

positionMs at the envelope level always mirrors the position carried inside payload when the event is playback-positional; it is duplicated at the top level so downstream queries never need to branch on event type to find "where in the video did this happen."

The shared client/server type (packages/shared, Section 4) models this as a discriminated union so a malformed payload is a compile-time error in both apps/web and apps/desktop:

type CollectEventBase = {
  eventId: string;
  videoId: string;
  shareLinkId: string | null;
  sessionId: string;
  occurredAt: string; // ISO 8601
  positionMs: number | null;
  page: { url: string; referrer: string };
};

type CollectEvent =
  | (CollectEventBase & { type: 'view_start'; payload: { autoplay: boolean; muted: boolean; playerVariant: 'inline' | 'lightbox' | 'watch-page'; embedContext: 'direct' | 'email' | 'slack' | 'iframe' } })
  | (CollectEventBase & { type: 'heartbeat'; payload: { positionMs: number; bufferedMs: number; playbackRate: number; quality: string; volume: number; fullscreen: boolean } })
  | (CollectEventBase & { type: 'play'; payload: { positionMs: number; trigger: 'user' | 'autoplay' | 'resume' } })
  | (CollectEventBase & { type: 'pause'; payload: { positionMs: number; trigger: 'user' | 'buffer' | 'blur' | 'end' } })
  | (CollectEventBase & { type: 'seek'; payload: { fromMs: number; toMs: number; trigger: 'scrub' | 'chapter-click' | 'comment-marker' | 'keyboard' } })
  | (CollectEventBase & { type: 'quality_change'; payload: { fromQuality: string; toQuality: string; trigger: 'auto' | 'manual' } })
  | (CollectEventBase & { type: 'complete'; payload: { watchedMs: number; positionMs: number } })
  | (CollectEventBase & { type: 'cta_click'; payload: { ctaId: string; ctaType: 'button_link' | 'calendar_booking' | 'custom_html'; targetUrl: string | null } })
  | (CollectEventBase & { type: 'email_submit'; payload: { gateSource: 'before_playback' | 'timestamp' | 'after_playback'; consent: boolean; hasName: boolean } })
  | (CollectEventBase & { type: 'reaction'; payload: { emoji: string; positionMs: number } })
  | (CollectEventBase & { type: 'comment_open'; payload: { source: 'timeline-marker' | 'panel-toggle' | 'notification-link' } })
  | (CollectEventBase & { type: 'transcript_open'; payload: { source: 'panel-toggle' | 'search' } })
  | (CollectEventBase & { type: 'download'; payload: { renditionLabel: string; format: 'mp4' | 'gif' | 'webm' } })
  | (CollectEventBase & { type: 'share'; payload: { channel: 'copy-link' | 'email' | 'slack' | 'native-share' } });

16.2 The collection endpoint #

POST /v1/collect carries no Authorization header — it is unauthenticated in the conventional sense — but it is not a trust-free endpoint. Every batch must carry a signed playbackToken minted by the access chain in Section 14.8.1 at the moment playback was authorized: the watch page/player requests one at the same time it obtains its signed playback URL, and attaches it to every subsequent /v1/collect call for that playback session. The token is short-TTL and bound to videoId, shareLinkId (or null for an authenticated in-workspace preview), and the viewerToken that requested it. This binding is what makes the endpoint trustworthy despite accepting no bearer credential: a caller can no longer poison another workspace's analytics by hand-crafting a viewerToken and an arbitrary videoId/shareLinkId pair, because it cannot forge a valid playbackToken for a video it was never authorized to play.

It follows the success/error envelope of Section 7, with one exception: on partial success it still returns 202 Accepted (never a 4xx) so a single malformed or rejected event in a batch never discards the valid ones.

Request:

{
  "playbackToken": "pbt_1A2b3C4d5E6f7G8h9I0jKl.eyJhbGciOiJFZERTQSJ9...",
  "viewerToken": "vwr_5f3KpQnB7Ht0RzM8YsD1eL",
  "recipientToken": "rcp_9Qa2X4vTf6JmW1yZ3nHkPo",
  "events": [ /* up to 50 event objects, see 16.1 */ ]
}

recipientToken is present only when the video was opened via a personalized recipient link (16.3).

Response:

{ "data": { "accepted": 46, "rejected": 4, "results": [{ "eventId": "evt_...", "status": "accepted" }] }, "meta": null }

16.2.1 Batch-level validation #

Performed once per request, before any per-event validation:

  1. playbackToken is present, has a valid signature, and is not expired. If any of these fail, the whole batch is rejected — see the error table below.
  2. videoId (as bound to the playbackToken) resolves to an existing, non-deleted video. If it does not, the server takes no distinguishable action: this is a silent no-op. The response still reports every event's status as "accepted", no row is persisted, and no error is returned. An unknown video id must never produce a response distinguishable from a genuine successful write, because a distinguishable error would turn this public, unauthenticated-transport endpoint into a video-id enumeration oracle. This holds even though step 1 already authenticated the caller — a playbackToken's signature and TTL stay structurally valid even for a video that was deleted after the token was minted.
  3. If the video (per the resolved playbackToken) has any share link with domain restriction enabled, the token — and therefore every event in the batch — must carry a non-null shareLinkId. If it does not, the whole batch is rejected with 400 share_link_required.

16.2.2 Per-event validation #

For each event in a batch that passed 16.2.1:

  • Schema validation against the shared Zod schema (Section 4).
  • videoId must equal the playbackToken's bound videoId. An event carrying a different videoId than the token it rode in on is rejected — this stops a batch minted for one video from writing events against another.
  • shareLinkId, if present on the event, must equal the playbackToken's bound shareLinkId. A mismatch is rejected — the same anti-poisoning check as batch validation, applied per event rather than assuming every event in a batch is uniform.
  • Bot/prefetch signals are evaluated per 16.9's mechanism and flag rather than reject.

Each rejected event is reported in results[] with its eventId and a rejection reason; rejection of one event never affects acceptance of its siblings in the same batch.

Batching and transport:

  • The player core buffers events client-side and flushes on whichever comes first: 50 events buffered, or 4000 ms elapsed since the oldest buffered event, or the page is being hidden/unloaded (visibilitychangehidden, pagehide).
  • Flush uses navigator.sendBeacon; if unavailable or the beacon is rejected (sendBeacon returns false), the player falls back to fetch with keepalive: true.
  • sendBeacon cannot set custom headers or a Content-Type the server can trust, so the server parses the request body as JSON regardless of the declared Content-Type.
  • Payload size limits: maximum 50 events per batch, maximum 32 KB request body (post-decompression). A batch exceeding either limit is rejected whole with 400 event_batch_too_large.
  • Heartbeats are suspended while the page is hidden (Page Visibility API) and do not backfill missed beats on resume — the gap is simply absent from the coverage reconstruction in 16.5.

Rate limiting and abuse protection:

This endpoint's rate limits — per IP and per viewerToken — are stated once, in Section 7.9, and are not restated here; a breach returns 429 with Retry-After set per those conventions. Independent of rate limiting, the following controls apply:

Control Behavior Response on breach
playbackToken validity Signature and TTL checked per 16.2.1 401 playback_token_invalid for the whole batch
Domain-restriction gate shareLinkId mandatory whenever the resolved video has a domain-restricted link, per 16.2.1 400 share_link_required for the whole batch
Video/share-link consistency Every event's videoId/shareLinkId must match the token's bound values, per 16.2.2 Per-event rejection inside the 202 response
Origin allowlist Checked only when the target video's share link has domain restriction enabled (Section 14) 403 origin_not_allowed
Schema validation Shared Zod schema (Section 4) validates every event in the batch independently Per-event 400-class rejection inside the 202 batch response, details[] names the offending event and field
Bot / prefetch Sec-Purpose: prefetch / Purpose: prefetch header, or user agent matches the maintained bot signature list Accepted but flagged (is_bot / is_prefetched), never hard-rejected — see 16.10

There is no Idempotency-Key header on this endpoint — that mechanism (Section 7.5) is reserved for billable, side-effecting POSTs. Duplicate delivery here (a sendBeacon retry after a flaky network) is instead absorbed by the unique constraint on (video_id, event_id) owned by Section 5.4, applied via ON CONFLICT DO NOTHING and described further in 16.10. The endpoint never blocks player UI: the client fires the beacon and does not await the response.

Error codes (error envelope per Section 7.6):

HTTP code Meaning
400 event_batch_too_large More than 50 events, or body exceeds 32 KB post-decompression.
400 event_batch_invalid Whole request body is not valid JSON, or playbackToken/viewerToken/events missing. Per-event schema failures do not use this code — they are reported per-item in the 202 response.
400 share_link_required The resolved video has a domain-restricted share link and no shareLinkId is bound to the token (16.2.1).
401 playback_token_invalid playbackToken missing, malformed, expired, or fails signature verification.
403 origin_not_allowed The video's share link has domain restriction enabled and the request Origin does not match (Section 14).
429 rate_limited Per-IP or per-viewerToken bucket exhausted per Section 7.9; Retry-After header set accordingly.

An unresolvable videoId deliberately has no row in this table: per 16.2.1 it is a silent no-op, not an error, and produces the same 202 shape as full success.

16.3 Viewer identity #

16.3.1 The anonymous token #

Every player instance mints or reuses a viewerToken (vwr_ + 22-char base58, cryptographically random) on first mount. It is stored first-party on the Reelay domain — never as a cookie on the host page, per the player budget in Section 15 — with a 30-day rolling expiry that refreshes on every event sent under that token. Under Safari ITP and Firefox ETP, storage partitioning by top-level site means a viewer who watches embeds of the same workspace's videos on two different host pages will, in practice, be seen as two separate anonymous viewers; this is a stated accuracy limitation, not a bug, and is factored into 16.10.

If a viewerToken goes 30 days with no event, it expires. A later visit mints a new token. The old and new tokens are not automatically linked — a gap of more than 30 days is treated as a new anonymous viewer.

16.3.2 Becoming named #

A viewer becomes NAMED in exactly three ways:

Path Trigger Resulting identity
A. Authenticated workspace user Viewer holds a valid session (Section 6) and has workspace access to the video viewers.user_id set to their users.id
B. Email submission Viewer completes an email-capture gate (17.6) A viewers row keyed by (workspace_id, email) is found or created; identified_via = 'email_capture'
C. Personalized recipient link Viewer opens a link carrying a recipientToken pre-bound to a known email by the sender (Section 14) The bound viewers row is resolved immediately at view_start, no viewer action required; identified_via = 'recipient_token'

16.3.3 Identity resolution precedence #

At ingest time, the server resolves the effective viewer identity for every event in a batch using this precedence (highest wins):

  1. A valid recipientToken accompanies the batch → use the bound identity (path C).
  2. A valid authenticated session resolves to a user with workspace access to the video → use that user's identity (path A).
  3. The viewerToken is already linked to a named viewers row from an earlier email_submit within the current 30-day token lifetime → use that identity (path B, remembered).
  4. Otherwise → anonymous; identity is the viewerToken alone.

16.3.4 Merge behavior #

Raw video_view_events rows are append-only and are never rewritten once written — this preserves audit integrity for the analytics stream. When an anonymous viewer later identifies (path B or C) under the same viewerToken, the merge is recorded on the viewers row itself rather than by mutating history:

-- viewers.current_token: the viewerToken currently mapped to this identity.
-- viewers.merged_tokens:  all prior anonymous tokens now known to be this same person.
update viewers
set merged_tokens = array_append(merged_tokens, $1::text)  -- $1 = the anonymous viewerToken being merged in
where id = $2
  and not ($1::text = any(merged_tokens));

Every read that aggregates "this viewer's" history (rollups in 16.4, the per-viewer timeline in 16.6) resolves through merged_tokens in addition to current_token, so the merged identity's timeline reads as one continuous history even though the underlying raw rows still carry whichever token was active at the time each event was recorded.

16.4 Storage and rollups #

The raw event table video_view_events is monthly-partitioned; its full schema — every column, type, CHECK constraint, and index, including the (video_id, event_id) uniqueness constraint that 16.10's deduplication depends on — is owned by Section 5.4. The queries in this section (16.5.4, 16.10) read that schema directly; nothing about the table definition is redefined here.

Two derived tables carry the aggregates the dashboard reads from:

  • video_view_daily — one row per (video_id, day). Columns: views, qualified_views, unique_viewers (exact, recomputed at closing), watch_ms_total, completes, cta_clicks, email_captures, avg_watch_pct, is_final, updated_at.
  • video_engagement_curve — one row per video_id, upserted in place (not a time series — it is the current retention shape, not a history of shapes). Columns: computed_at, bucket_count, bucket_width_ms, retention_pct (a real[] array, one entry per bucket), sample_size (views included in the computation), version.

16.4.1 Rollup jobs #

Job Queue name Schedule Behavior
Hourly rollup analytics.rollup.hourly Top of every hour, for the just-completed hour Aggregates that hour's raw events per video and UPSERTs into video_view_daily for the containing day (ON CONFLICT (video_id, day) DO UPDATE incrementing the additive counters). Leaves is_final = false.
Daily close analytics.rollup.daily-close 02:00 UTC, for the previous day Recomputes the day's row from scratch by a full scan of that day's raw events (not incremental — this absorbs any hourly drift), sets is_final = true.
Curve recompute analytics.rollup.curve Nightly, 03:00 UTC, per video with new views since last run Recomputes video_engagement_curve per 16.5 and upserts by video_id.
Late-event sweep analytics.rollup.late-sweep Nightly, 04:00 UTC Re-runs the hourly + daily close for the trailing 3 days (idempotent full recompute, not incremental), absorbing events that arrived after their day's initial close.
Partition drop analytics.partition.drop Monthly, 1st at 05:00 UTC Detaches and drops video_view_events partitions older than 13 months.

All jobs follow the BullMQ conventions of Section 9: max 5 attempts, exponential backoff, per-queue concurrency, dead-letter queue on the .dlq suffix, idempotent handlers.

16.4.2 Late-event handling #

occurred_at is client clock and may be skewed or arrive after a network outage; received_at is server clock and is authoritative for partition routing — an event is always inserted into the partition matching received_at's month, so a late-arriving event never targets an already-dropped partition. If occurred_at differs from received_at by more than 24 hours in either direction, the server clamps occurred_at to received_at and sets a clock_skew_clamped: true flag inside payload (an anti-abuse guard against backdated or forward-dated events). Rollups bucket by (clamped) occurred_at. An event arriving more than 3 days after its occurred_at day is still stored raw (for audit/debugging) but falls outside the late-event sweep window and is excluded from rollups — a stated accuracy limitation, see 16.10.

16.4.3 Retention and partition-drop schedule #

Raw video_view_events are retained 13 months, independent of Section 19's video-content retention policy (which governs the video and its media, not analytics events). The monthly partition-drop job removes partitions older than 13 months. video_view_daily and video_engagement_curve are not subject to this 13-month window — they are small, video-scoped aggregates that persist for the life of the video and are hard-deleted only when the video itself is purged (Section 19).

16.5 Watch time and drop-off #

16.5.1 Definitions #

Term Definition
View One playback session (session_id) that produced at least one play or heartbeat event for a given (video_id, viewer).
Qualified view A view whose distinct-second watch coverage (16.5.2) meets qualifyThresholdMs = LEAST(30000, GREATEST(3000, durationMs * 0.10)) — at least 3 s, scaling to 10% of the video's duration, capped at 30 s for long videos.
Watch time (per view) The sum of DISTINCT seconds of the source timeline actually rendered to the viewer during the session — never a raw sum of heartbeat deltas, which would double-count rewinds.
Completion rate completes / views over a period.
Average watch percentage AVG(LEAST(watch_ms / duration_ms, 1.0)) across views in the period — each view's contribution is capped at 100% before averaging.

16.5.2 Reconstructing coverage from raw events #

The server does not require the client to report a coverage bitmap. Because heartbeat events fire every 5000 ms of active playback (16.2) and carry positionMs, the server treats each heartbeat (and each play) as evidence that the 5-second source-time window ending at positionMs was played: [positionMs - 5000, positionMs], clamped to [0, durationMs). This is a deliberate 1-second-resolution approximation — analytics-grade, not frame-accurate — and is the same primitive used for both watch time and the engagement curve.

Because coverage is computed as DISTINCT seconds/buckets touched per (session_id, viewer_id), a seek backward and rewatch of the same range contributes no additional coverage beyond what was already counted — this is the mechanism that keeps watch time and the engagement curve from ever exceeding 100%.

16.5.3 Bucket sizing for the engagement curve #

The curve is capped at 1800 buckets. Let durationSec = CEIL(durationMs / 1000.0):

  • If durationSec <= 1800: bucketWidthSec = 1, bucketCount = durationSec (one bucket per second — covers effectively all product-demo-length videos at full resolution).
  • If durationSec > 1800: bucketWidthSec = CEIL(durationSec / 1800.0), bucketCount = CEIL(durationSec / bucketWidthSec) (always ≤ 1800).
function bucketSizing(durationMs: number): { bucketWidthMs: number; bucketCount: number } {
  const durationSec = Math.ceil(durationMs / 1000);
  const bucketWidthSec = durationSec <= 1800 ? 1 : Math.ceil(durationSec / 1800);
  const bucketCount = Math.ceil(durationSec / bucketWidthSec);
  return { bucketWidthMs: bucketWidthSec * 1000, bucketCount };
}

16.5.4 The curve query #

-- $1 = video_id, $2 = period start, $3 = period end, $4 = bucketWidthMs, $5 = bucketCount
with covered_ranges as (
  select
    e.session_id,
    coalesce(e.viewer_id::text, e.viewer_token) as viewer_key,
    greatest(0, (e.payload->>'positionMs')::int - 5000) as range_start_ms,
    least(v.duration_ms, (e.payload->>'positionMs')::int) as range_end_ms
  from video_view_events e
  join videos v on v.id = e.video_id
  where e.video_id = $1
    and e.type in ('heartbeat', 'play')
    and e.is_bot = false
    and e.is_prefetched = false
    and e.occurred_at >= $2 and e.occurred_at < $3
),
touched_buckets as (
  select distinct
    session_id,
    viewer_key,
    generate_series(
      (range_start_ms / $4)::int,
      least((range_end_ms / $4)::int, $5::int - 1)
    ) as bucket_index
  from covered_ranges
),
total_views as (
  select count(distinct (session_id, viewer_key)) as n from covered_ranges
)
select
  b.bucket_index,
  count(distinct (b.session_id, b.viewer_key)) as views_reached,
  round(100.0 * count(distinct (b.session_id, b.viewer_key)) / nullif(t.n, 0), 2) as retention_pct
from touched_buckets b, total_views t
group by b.bucket_index, t.n
order by b.bucket_index;

views_reached at bucket_index = 0 is, by construction, the view count for the period; each subsequent bucket can only stay flat or drop relative to the number of DISTINCT sessions that ever touched it — rewatch-heavy chapters can produce a local plateau, never a value above the total view count, because of the distinct (session_id, viewer_key) grouping.

16.6 Per-viewer analytics #

The per-viewer timeline lists, for a given viewer on a given video: every session (session_id), its watched_ms, its merged coverage segments ([startMs, endMs] ranges, computed the same way as 16.5.2 but at full 1-second resolution rather than bucket resolution, since it concerns a single viewer rather than an aggregate), device/browser/OS (parsed server-side from User-Agent), entry channel (from the session's share events and HTTP referrer), and timestamp. Repeat views are simply additional session_id rows for the same identified viewer.

Plan gate (Section 21 owns the authoritative limits table; restated here for this feature):

Plan Per-viewer analytics
Free Aggregate only. GET /v1/videos/{videoId}/viewers returns 403 with error code plan_gate and an upgradeUrl in details.
Pro Full per-viewer, subject to 16.9's opt-in + notice requirement.
Business Full per-viewer, subject to 16.9's opt-in + notice requirement.

Even on Pro/Business, per-viewer identity is not exposed until the link owner explicitly enables enableViewerIdentification on the share link or video AND the viewer-facing notice is active (16.9). Without that opt-in, the endpoint returns anonymized rows (viewerToken visible, email/name/user_id withheld) even on a paid plan.

GET /v1/videos/{videoId}/viewers response, identification enabled:

{
  "data": [
    {
      "viewerId": "vwr_5f3KpQnB7Ht0RzM8YsD1eL",
      "identifiedVia": "email_capture",
      "email": "buyer@acme.example.com",
      "name": "Jamie Lin",
      "firstSeenAt": "2026-08-10T09:12:00Z",
      "lastSeenAt": "2026-08-19T14:22:41Z",
      "sessions": [
        {
          "sessionId": "6b1f7c2e-6b3a-4a3e-9e0b-6a4b2f7c1d9a",
          "watchedMs": 187000,
          "segments": [[0, 42000], [40000, 187000]],
          "device": "desktop",
          "browser": "Chrome 127",
          "entryChannel": "email",
          "startedAt": "2026-08-19T14:20:11Z"
        }
      ],
      "repeatViewCount": 2
    }
  ],
  "meta": { "nextCursor": "eyJvZmZzZXQiOjI1fQ", "hasMore": true }
}

Identification disabled (Free plan, or Pro/Business without opt-in): the same shape, but each row omits email, name, and identifiedVia, retaining only viewerId, sessions, and repeatViewCount.

16.7 The dashboard surfaces #

Surface Endpoint (illustrative) Contents
Per-video analytics GET /v1/videos/{videoId}/analytics Summary stats (views, qualified views, unique viewers, total watch time, completion rate, avg watch %), CTA performance, comment/reaction counts, device/geo breakdown, traffic sources.
Per-video curve GET /v1/videos/{videoId}/analytics/curve video_engagement_curve row plus computed drop-off points (largest bucket-to-bucket deltas).
Per-viewer list GET /v1/videos/{videoId}/viewers Gated per 16.6. Cursor-paginated per Section 7.
Workspace-level GET /v1/workspaces/{workspaceId}/analytics Aggregated across all videos visible to the requester (role-scoped: member sees only their own videos' data unless also admin/owner), top videos by views, trend chart.
Folder rollups GET /v1/folders/{folderId}/analytics Same shape as workspace-level, scoped to the folder subtree (18.2).
Comparison over time GET /v1/videos/{videoId}/analytics?compareFrom=...&compareTo=... Two video_view_daily range queries, returned as { current, previous, deltaPct } per metric.
CSV export POST /v1/videos/{videoId}/analytics/exports Async job (below).

CSV export is asynchronous: the POST enqueues a analytics.export.csv job, tracked via the jobs_audit entity (Section 5), and returns 202 with a job reference. GET /v1/analytics-exports/{jobId} polls status (queued, processing, done, failed); on done the response includes a signed, time-limited (24h) download URL to the generated CSV in object storage. This avoids blocking the request on a potentially large export and avoids holding generated files indefinitely. Export field set: one row per view/session with viewer identity (if resolvable and permitted per 16.6/16.9), watch time, completion, device, geo, entry channel, timestamp.

GET /v1/videos/{videoId}/analytics response shape:

{
  "data": {
    "videoId": "vid_3kP1n8s6yWq2xR7bLZ0aQm",
    "period": { "from": "2026-07-20T00:00:00Z", "to": "2026-08-19T00:00:00Z" },
    "views": 842,
    "qualifiedViews": 611,
    "uniqueViewers": 503,
    "watchMsTotal": 96812000,
    "completes": 298,
    "completionRate": 0.354,
    "avgWatchPct": 0.612,
    "ctas": [{ "ctaId": "cta_1Kx...", "label": "Book a call", "clicks": 47, "clickThroughRate": 0.0558 }],
    "comments": 31,
    "reactions": 118,
    "devices": { "desktop": 0.71, "mobile": 0.24, "tablet": 0.05 },
    "topCountries": [{ "country": "US", "views": 401 }, { "country": "GB", "views": 88 }],
    "trafficSources": [{ "channel": "email", "views": 512 }, { "channel": "slack", "views": 140 }]
  },
  "meta": null
}

Error codes on the analytics surfaces: 404 video_not_found, 403 plan_gate (per-viewer only, see 16.6), 422 invalid_period (compareFrom/compareTo malformed or from > to).

16.8 Real-time versus batch #

Surface Mechanism Freshness target
"Watching now" live counter Redis key per active session, refreshed by each heartbeat, 30 s TTL (two missed heartbeats = considered gone) 5-10 s
Total view count (video card) Redis counter incremented on view_start, reconciled hourly against video_view_daily ≤ 60 s (eventually consistent), corrected to exact within 1 hour
Engagement curve Batch, nightly recompute (16.4.1) Next day; UI displays the computed_at timestamp explicitly
Per-viewer timeline Computed on demand directly from raw events at query time (cheap at single-viewer scope, no rollup needed) As fresh as ingestion — seconds
Comments / reactions Direct table read, live-updated on the watch page via polling Sub-second to a few seconds
CSV export Batch job Snapshot at job enqueue time

16.9 Privacy #

Analytics are pseudonymous by default. The identity precedence in 16.3 resolves a viewer to a token or a name for internal joins, but per-viewer identity is never exposed to the link owner unless both of the following hold:

  1. The link owner explicitly enables enableViewerIdentification on the share link or video.
  2. A viewer-facing notice is active — rendered in the player's first-run overlay and in the watch page footer, shown once per session, stating that the video owner may see who viewed it and their engagement. The notice cannot be silently suppressed by the owner; disabling enableViewerIdentification removes both the notice and the identification.

Without this opt-in, per-viewer endpoints (16.6) return viewerToken-only rows even on Pro/Business. The compliance framework this posture sits inside is canonical in Section 22; this section states the mechanics that implement it.

Independent of enableViewerIdentification, cross-session viewer tracking — the 30-day rolling viewerToken described in 16.3.1 — is active by default for every viewer, whether or not the link owner ever enables named identification. This is a separate disclosure from the opt-in notice above: the watch page and player surface a small, non-blocking disclosure (a footer link, never a blocking banner or interstitial) stating that pseudonymous cross-session tracking happens by default for analytics purposes. This disclosure is shown to every viewer on every video regardless of enableViewerIdentification state, and viewing it never requires an acknowledgment before playback continues.

Because the viewerToken is a first-party identifier — never a third-party cookie — and no event is written until the viewer's browser is executing the player's own script on Reelay-controlled storage, many jurisdictions' cookie/ePrivacy-style consent regimes do not require a blocking consent banner for this baseline pseudonymous tracking. This is regional guidance, not a universal guarantee: some jurisdictions' interpretation of "non-essential storage or access on a viewer's device" reaches further than third-party cookies, and a workspace operating primarily in a jurisdiction with stricter local guidance is responsible for adding its own banner, on top of the product's default non-blocking disclosure, via the compliance framework in Section 22. The non-blocking footer disclosure is the product default, not a substitute for a workspace's own regional compliance review.

Do Not Track and Global Privacy Control: the collection endpoint reads the DNT: 1 and Sec-GPC: 1 request headers. When either is present, the event is tagged respects_dnt: true; rollups exclude such sessions from cross-session viewerToken persistence beyond the current session and from per-viewer identification, regardless of plan or opt-in. An explicit, affirmative act — submitting an email through an email-capture gate — is honored even under DNT/GPC, since consent for that specific interaction is separate from passive behavioral tracking; DNT/GPC still suppresses linking that viewer's other, non-consented sessions to the identified record.

IP handling: the server truncates the source IP before any use — IPv4 zeroes the last octet (/24), IPv6 zeroes the last 80 bits (keeps /48) — and uses the truncated address only for a coarse country/region geo lookup at ingest time. The truncated IP itself is never persisted in video_view_events; only the derived country code is stored. The untruncated IP exists transiently in the request and in web-server access logs, which fall under Section 22's log retention policy — never in the analytics tables.

Bot and prefetch filtering: the collection endpoint only ever receives traffic from a browser actually executing the player's JavaScript, so classic non-JS crawlers never reach it — bot flagging here targets headless-browser scrapers and monitoring services that do execute JS. is_bot is set when the User-Agent matches a maintained bot-signature list, is absent, or matches a known headless-browser fingerprint. is_prefetched is set when the request carries Sec-Purpose: prefetch or Purpose: prefetch (Chrome speculative prerendering); the player additionally guards against firing events during a Speculation-Rules prerender by checking document.prerendering client-side, which is the primary defense — the server flag is defense-in-depth.

16.10 Accuracy #

  • Deduplication: the unique index on (video_id, event_id) owned by Section 5.4 makes duplicate delivery (a sendBeacon retry after a flaky connection) a no-op via ON CONFLICT DO NOTHING.
  • Bot exclusion: rollup queries filter is_bot = false; raw rows retain the flag rather than being rejected, preserving full data for debugging.
  • Prefetch exclusion: rollup queries filter is_prefetched = false, same rationale.
  • Token-rotation limitation: a viewerToken gap exceeding 30 days is counted as a new viewer (16.3.1); storage partitioning under ITP/ETP further limits cross-site persistence of the same token.
  • Late-event limitation: events arriving more than 3 days after their occurred_at day are stored but excluded from rollups (16.4.2).
  • Stated accuracy expectation: view and watch-time metrics are accurate to within an estimated 2-3% of ground truth for typical business-demo traffic patterns, after bot and prefetch exclusion. This is a product analytics system, not an ad-verification or billing-grade metering system, and is not designed to defeat sophisticated fraud.

17. Engagement — Comments, Reactions, CTAs & Email Capture #

17.1 Comments #

Comments are timestamped and anchored to a source-time position on the video. A top-level comment requires position_ms; a reply to it does not carry its own position (it renders inline under the anchor, not as a second timeline marker). Threading is one level deep — top-level comments plus replies to them; a reply cannot itself be replied to. This keeps the timeline-marker model unambiguous: one marker per top-level comment, replies grouped underneath it in the panel.

Comment fields:

Field Type Notes
id cmt_ id
videoId vid_ id
parentCommentId cmt_ id | null Set only for a reply; replies cannot themselves have replies.
authorUserId usr_ id | null Set when the commenter is an authenticated workspace user.
viewerId viewer id | null Set when the commenter is an identified or anonymous non-member viewer (16.3).
displayName string Required whenever authorUserId is null — the name the commenter typed. Max 60 chars.
positionMs integer | null Required for top-level comments; null for replies.
body string Max 4000 chars. Markdown-lite: bold, italic, and auto-linked URLs only — no raw HTML, no images, no arbitrary markdown constructs.
status enum visible, flagged, removed_by_moderator, removed_by_author
editedAt timestamptz | null
deletedAt timestamptz | null Soft delete, per the Section 7 convention that comments soft-deletes.
createdAt timestamptz

Authorization for posting, editing, and moderating comments treats an authenticated workspace member and a non-member commenter as fundamentally different actors, never as the same population wearing different hats: a signed-in member is evaluated against their workspace role (Section 6), while every non-member commenter — anonymous, or identified only via one of 16.3's viewer paths — is a share_viewer actor (Section 6.9), evaluated solely against the link's commentAccess setting below. A share_viewer is never evaluated against, and never granted, the workspace viewer role's capabilities; the two are disjoint populations even though both can "watch and comment."

Who may comment is a per-share-link setting, commentAccess, with four values:

Value Who can post
workspace_members Authenticated members of the owning workspace only.
named_recipients Workspace members plus recipients on the link's recipient list (share_link_recipients, Section 14).
anyone_with_link Anyone who has the link, including anonymous viewers (display-name required, no account).
nobody Comments disabled entirely on this link; the panel is read-only or hidden per 17.3.

Anonymous commenting (anyone_with_link or named_recipients without an account) requires a display name, entered once per browser and persisted alongside the viewerToken for the remainder of its 30-day lifetime so repeat comments don't re-prompt.

Editing: the author (matched by authorUserId or, for anonymous commenters, by the same viewerToken that created it within the same 30-day token lifetime) may edit their own comment at any time; there is no edit-window cutoff. Editing sets editedAt and the UI shows an "edited" indicator. Editing does not change createdAt or the comment's position in the thread order.

Deletion is soft (deleted_at set, status = 'removed_by_author' or 'removed_by_moderator' depending on actor). A soft-deleted top-level comment with existing replies is not physically removed from the thread: the panel renders a [comment removed] placeholder in its position and keeps the replies visible underneath it, preserving thread continuity. A soft-deleted reply simply disappears from the thread (no placeholder needed, since nothing nests under a reply).

POST /v1/videos/{videoId}/comments:

{ "parentCommentId": null, "positionMs": 48210, "body": "Can we bump the price shown here?", "displayName": "Jamie Lin" }
{
  "data": {
    "id": "cmt_2Hq8Vn3Xp1Kq9sT4wR7mLc",
    "videoId": "vid_3kP1n8s6yWq2xR7bLZ0aQm",
    "parentCommentId": null,
    "authorUserId": null,
    "viewerId": "vwr_5f3KpQnB7Ht0RzM8YsD1eL",
    "displayName": "Jamie Lin",
    "positionMs": 48210,
    "body": "Can we bump the price shown here?",
    "status": "visible",
    "editedAt": null,
    "createdAt": "2026-08-19T14:23:10Z"
  },
  "meta": null
}

Error codes: 400 comment_body_too_long (> 4000 chars), 400 comment_display_name_required (anonymous commenter, no displayName), 403 comment_access_denied (commentAccess = 'nobody', or the requester's role/recipient status does not satisfy the link's commentAccess setting), 404 comment_not_found (replying to or editing a comment that does not exist or is soft-deleted), 409 duplicate_comment, 422 reply_to_reply_not_allowed (parentCommentId points at a comment that is itself a reply), 429 comment_rate_limited.

17.2 Comment moderation #

Workspace owner and admin roles (Section 6) may delete any comment on any video in their workspace, regardless of who posted it. member may delete comments only on videos they own. Deletion by a moderator sets status = 'removed_by_moderator'.

Reporting: any viewer who can see the comment can report it (reason enum: spam, abusive, off_topic, other with free-text up to 280 chars). Three or more reports on the same comment auto-transitions status to flagged and hides it from the public panel pending owner/admin review; it remains visible to owner/admin with a "flagged" badge.

Rate limiting: an identity (authenticated user, or viewerToken for anonymous) may post at most 5 comments per minute and 30 per hour on a given video; breach returns 429 comment_rate_limited.

Spam heuristics (applied at write time, before persistence): a comment containing 3+ URLs is auto-flagged (status = 'flagged') rather than rejected; a comment whose body is byte-identical to another comment posted by the same identity within the last 60 seconds is rejected outright with 409 duplicate_comment; velocity beyond the rate limit above is the primary spam control.

Profanity posture: there is no automatic profanity filter by default — false positives on technical/product vocabulary are judged a worse experience than occasional profanity. Business-plan workspaces may configure an optional word-mask list (admin-managed, per workspace) that replaces listed words with *** at render time without altering the stored body.

Notification behavior: a new top-level comment notifies the video owner; a reply notifies the parent comment's author and the video owner (if different); an @mention of a workspace member notifies that member. Delivery channels are in-app (always), email (per the recipient's notification_preferences, 17.7), and Slack when the workspace has an active Slack integration — the Slack message mechanics are canonical in Section 20; this section only defines what triggers the notification.

17.3 Rendering #

In the player, each top-level comment renders as a marker on the scrub bar at its positionMs, grouped/clustered when multiple markers fall within the same on-screen pixel at the current zoom level. Clicking a marker seeks the video to that position (seek event, trigger: 'comment-marker', 16.1) and opens the comment panel scrolled to that thread (comment_open, source: 'timeline-marker'). On the watch page, the full comment list renders alongside the player, sorted by positionMs ascending by default, with a toggle for newest-first; replies render nested one level under their parent, ordered by createdAt.

17.4 Reactions #

Reactions are timestamped, single-emoji, anchored drops on the video timeline — distinct from comments (no body text, no thread, no display name required beyond what identity resolution already provides). The fixed emoji set is: 👍 ❤️ 🎉 😂 😮 👀. This set is not configurable per workspace at launch — a fixed, universally-understood set keeps the timeline overlay simple to render and to aggregate.

Fields: id, videoId, workspaceId, viewerId (nullable — anonymous reactions key on viewerToken), emoji, positionMs, sessionId, createdAt. Reactions do not soft-delete (not in the Section 7 soft-delete list); a viewer retracting a reaction hard-deletes the row. As with comments (17.1), an anonymous or non-member reactor is authorized as a share_viewer actor (Section 6.9), never as the workspace viewer role.

Aggregate display: the player renders a low-profile heat overlay on the scrub bar — reaction density per bucket (same bucket-sizing rule as 16.5.3, computed from the reactions table rather than video_view_events) rendered as a translucent bar whose height scales with count, with the dominant emoji shown on hover.

Rate limits: one reaction per (identity, emoji, 2-second window) to prevent a single click-holding gesture from flooding the timeline; a hard cap of 20 reactions per identity per session. Anonymous reactions are deduplicated by viewerToken exactly like named ones — the token, not an account, is the rate-limit key.

17.5 CTAs #

Type Description Plan gate
button_link A labeled button linking to any URL. All plans.
calendar_booking A button that opens an embedded scheduling widget (iframe to the configured booking URL) or, as a fallback, links out to it. All plans.
custom_html Arbitrary sanitized HTML block (e.g. a third-party embed snippet) rendered in the CTA slot. Business only — gating enforced server-side; a custom_html CTA created on Free/Pro is rejected with 403 plan_gate at creation time.

Placement: end_of_video (renders as a full-screen or lower-third card once the player reaches complete), timestamp (a specific positionMs, rendered as a pausing or non-pausing overlay per a per-CTA pauseOnShow flag), persistent_overlay (a small badge visible for the entire playback, e.g. bottom-right).

Styling: CTA button color, text color, and corner radius default to the workspace brand kit (18.5) and may be overridden per-CTA; custom_html CTAs are exempt from brand-kit color inheritance since their appearance is defined by the embedded markup itself.

Click tracking: every click fires a cta_click analytics event (16.1) AND writes a row to the dedicated cta_events table (ctaId, videoId, viewerId, sessionId, clickedAt, targetUrlSnapshot) via the same ingest path, synchronously. The dedicated table exists because CTA reporting (click-through rate per CTA, over time, exportable) needs fast, CTA-scoped queries that would otherwise require scanning the much larger partitioned analytics table, and because CTA clicks also drive real-time notifications (17.7) that should not depend on the analytics rollup schedule.

A/B posture: CTAs carry a nullable variantKey column reserved for future multivariate testing. At launch, exactly one active variant per CTA slot is supported — the field exists so a future A/B testing feature is additive (no migration) but is not exercised by any product surface today; there is no traffic-splitting logic in this version.

POST /v1/videos/{videoId}/ctas:

{
  "type": "calendar_booking",
  "placement": "end_of_video",
  "label": "Book a 15-minute call",
  "targetUrl": "https://cal.acme.example.com/sales/15min",
  "pauseOnShow": false,
  "style": { "backgroundColor": null, "textColor": null }
}

style fields left null inherit from the workspace brand kit (18.5) at render/display time rather than freezing a color at creation, so a later brand-kit color change propagates to existing CTAs without editing each one. Error codes: 400 cta_target_url_required (button_link/calendar_booking without targetUrl), 400 cta_position_required (placement = 'timestamp' without positionMs), 403 plan_gate (type = 'custom_html' outside Business), 422 cta_custom_html_unsafe (submitted HTML fails the server-side sanitizer allowlist — script tags, inline event handlers, and javascript: URLs are stripped; if stripping would materially change the content, the request is rejected outright rather than silently mutated).

17.6 Email capture #

Gate modes, set per share link or per video default:

Mode Behavior
before_playback The gate blocks playback entirely until the form is submitted (or skipped, if optional).
timestamp Playback pauses at a configured positionMs and shows the gate; resumes on submit or skip.
after_playback The gate appears once the player reaches complete; does not block anything already watched.

Optional versus required is an independent boolean (emailRequired) on the gate configuration. When emailRequired = false, a visible "Skip" affordance is always present.

Form fields: email (required, RFC 5322 validated), name (optional by default, can be marked required per gate config), and — Business plan only — up to 3 workspace-configurable custom fields (label, fieldType: 'text'|'select', required: boolean, options: string[] for select).

GDPR consent: the gate renders a checkbox with the fixed legal copy:

"I agree to share my email with [Workspace Name] so they can follow up about this video. See their privacy policy for details."

The checkbox is unchecked by default (no pre-ticked consent) and, when emailRequired = true, must be checked before submission is accepted. The legal basis for processing is consent (aligned with Section 22's compliance framework, Article 6(1)(a)-equivalent) — this is stated plainly because it determines the withdrawal and deletion obligations Section 22 defines. Double opt-in is deliberately not implemented: this is an identify-to-watch interaction with an explicit, in-the-moment consent checkbox at the point of collection, not a marketing newsletter subscription flow, and a confirmation-email round-trip would add friction without a corresponding compliance requirement for this use case.

The email itself is never sent through the pseudonymous analytics pipeline (16.1's email_submit payload intentionally omits it). Instead, submission goes to a dedicated, form-specific endpoint:

POST /v1/videos/{videoId}/email-captures

{ "email": "buyer@acme.example.com", "name": "Jamie Lin", "consent": true, "customFields": { "role": "VP Engineering" }, "gateSource": "before_playback" }

The handler (1) writes/updates a row in email_captures keyed by (video_id, lower(email)) — resubmission on the same video updates captured_at, refreshes the consent record, and increments a capture_count, rather than creating a duplicate row — and (2) fires a synthetic email_submit analytics event into video_view_events (without the address) for funnel completeness. Consent record fields stored on email_captures: consent_given, consent_text_shown (the exact copy rendered at submission time, for audit even if the copy is edited later), consent_ip (truncated per 16.9's truncation rule), captured_at.

This consent record — lawful basis (consent, stated above), the exact copy shown, the timestamp, and the truncated IP — is retrievable in full for a data-subject access request. The email_captures row itself, and any video_view_events/viewers rows referencing the same resolved viewer, are in scope for a data-subject erasure request. Because an anonymous or email-captured viewer is by definition not an account holder, the erasure path that removes or anonymizes these rows without an account to delete from is defined in Section 19.6.

Deduplication scope is per (video_id, email), not per workspace — this preserves accurate per-video conversion data; a workspace-wide contact list is produced by querying across a workspace's videos at export/sync time, not by a separate stored entity.

CSV export follows the same async-job pattern as 16.7 (analytics.export.csv job type email_captures), with the fields above.

HubSpot sync: when the workspace has an active HubSpot connection (integration_connections, mechanics canonical in Section 20), each new or updated email_captures row is queued for upsert as a HubSpot contact, with synced_to_hubspot_at set on success. Sync failures are retried per the BullMQ conventions of Section 9 and surfaced in the integration's health status (Section 20).

Error codes: 400 email_invalid (fails RFC 5322 validation), 400 consent_required (gate config has emailRequired = true and consent = false), 400 custom_field_invalid (a Business custom field marked required is missing, or a select field's value is not among its options), 403 plan_gate (more than 0 custom fields submitted on Free/Pro — custom fields are Business-only), 404 video_not_found.

17.7 The notification system #

Trigger Default channel Default cadence
New top-level comment on your video in-app + email Immediate
Reply to your comment in-app + email Immediate
@mention in a comment in-app + email Immediate
Comment flagged 3+ times (17.2) in-app (owner/admin only) Immediate
CTA click on your video in-app Daily digest
Email capture on your video in-app + email Immediate
Weekly analytics summary email Weekly digest
Share link visibility/permission change (Section 14 audit) in-app + email (owner/admin) Immediate

Per-user preferences (notification_preferences) allow each trigger category to be set independently to immediate, daily_digest, or off for email; in-app notifications cannot be fully disabled (they are the low-friction default), but can be marked "muted" per video.

Digest emails batch all triggers configured as daily_digest for a user into a single email sent at 08:00 in the workspace's configured timezone (default UTC), grouped by video.

Email templates (transactional, one per trigger type plus the digest) are maintained as versioned templates in the codebase (not user-editable at launch) and share the workspace's brand kit logo and accent color (18.5) in the header when the workspace is on a plan that has branding enabled.

Unsubscribe: every notification email carries a one-click, token-authenticated unsubscribe link (no login required) scoped to that specific trigger category — unsubscribing from "new comment" emails does not affect "weekly summary" emails. The link updates notification_preferences for that category to off and is logged in email_log. This satisfies CAN-SPAM/GDPR unsubscribe requirements referenced in Section 22.

18. Video Library, Folders, Collections & Brand Kit #

18.1 The library #

The library supports list and grid views (a per-user UI preference, persisted), sortable by createdAt, updatedAt, title, views (from video_view_daily aggregates), and durationMs. Filters: folder, tag, date range, "has transcript," and video status (processing, ready, failed — Section 9 owns the processing state machine).

Tags are stored directly on the video as videos.tags text[] (workspace-scoped free text, not a separate lookup table — this keeps tagging lightweight and avoids a tag-management surface at launch) with a GIN index for containment queries:

create index videos_tags_idx on videos using gin (tags);
-- filter: where tags @> array['onboarding']::text[]

Full-text search covers title, description, and transcript text. Since transcript storage (transcript_segments) is owned by Section 12, this section denormalizes the concatenated transcript text onto the video row specifically to support search:

alter table videos add column transcript_text text; -- denormalized, refreshed by a job whenever
                                                      -- transcript_segments for the video change

alter table videos add column search_vector tsvector
  generated always as (
    setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
    setweight(to_tsvector('english', coalesce(description, '')), 'B') ||
    setweight(to_tsvector('english', coalesce(transcript_text, '')), 'C')
  ) stored;

create index videos_search_vector_idx on videos using gin (search_vector);

Query:

select v.id, v.title, ts_rank_cd(v.search_vector, query) as rank
from videos v, websearch_to_tsquery('english', $1) query
where v.search_vector @@ query
  and v.workspace_id = $2
  and v.deleted_at is null
order by rank desc
limit 25;

websearch_to_tsquery is used (rather than plainto_tsquery) so users can type natural search phrases, including quoted exact phrases and -exclusions, without learning tsquery syntax.

Poster and thumbnail images displayed in the library grid — and everywhere else in this section a poster/thumbnail is surfaced, including the folder view (18.3) and any social preview generated for a shared folder — use the same versioned, access-controlled path as the player poster (Section 15), scoped by the resource's current playback_key_version (Section 14). None of these surfaces ever renders a permanent, guessable CDN URL for a poster or thumbnail; downgrading a video's visibility or revoking the share link it was viewed through invalidates the poster exactly as it invalidates playback.

Saved views: a user may save the current (sort, filters, view mode) combination with a name; saved views are personal (not shared across the workspace at launch) and listed in a sidebar shortcut.

Bulk operations, available from a multi-select in list/grid view: move to folder (each video's move is independently subject to the destination-folder permission check in 18.2 — a bulk move partially succeeds if the requester lacks access to some videos' destination folder, per-item, exactly like the single-video move endpoint), add/remove tag, delete (soft, per Section 7), and export (queues one analytics.export.csv-style job per selected video's requested export format, per Section 9/13).

18.2 Folders and collections #

Folders nest to a maximum depth of 5 levels (a root folder plus four nested levels below it). Attempting to create or move a folder past that depth returns 422 folder_depth_exceeded.

Moving a video between folders requires edit-or-manage-level access (18.2.1) on the destination folder specifically — access to the video being moved is necessary but not sufficient on its own, since without this check a member who merely owns a video could move it into a folder they cannot otherwise browse into, or out of a folder they cannot manage. The move endpoint (Section 7.11.7) returns 403 folder_access_denied when the requester lacks the required access on the destination folder; otherwise the move is a folder_id update on the video row.

Folders are workspace-scoped and hard-delete (not in the Section 7 soft-delete list). Deleting a non-empty folder never deletes its contents: videos and subfolders inside the deleted folder move up to the deleted folder's parent (or to the workspace root if the deleted folder had no parent) before the folder row is removed. This is enforced server-side in the same transaction as the delete.

Folder-level sharing exists so one team's library is not visible to everyone in the workspace by default. Each folder has a visibility of private (default for folders created by member) or workspace (visible to every workspace member). Explicit grants live in folder_permissions: (folderId, principalType: 'user'|'role', principalId, permissionLevel: 'view'|'comment'|'edit'|'manage', grantedBy). A role-typed principal (e.g. member, viewer) grants that permission to every workspace member holding that role — a blanket grant without enumerating users.

18.2.1 Composition rule with workspace roles #

This is the single rule every client and every server-side authorization check must implement identically, and it is the canonical default-deny model for folder access — Section 6.8.1 implements this same rule for the general workspace permission model. Both use one shared four-value enum for folder access, ('view','comment','edit','manage'); there is no separate view|edit|none variant anywhere in the system.

  1. If the requesting user's workspace role is owner or admin: always allow, at manage level, regardless of folder visibility or any folder_permissions row. This matches Section 6: admin manages "all workspace videos."
  2. Else, walk the folder chain from the target folder up to the root. At each folder in that walk, starting with the target folder itself, check for an explicit folder_permissions override row for this user (principalType = 'user') or for any role they hold (principalType = 'role'). The nearest ancestor that has an override row wins — evaluation stops at the first override found walking upward, and a more distant ancestor's override (even a more permissive one) is never consulted once a nearer one has answered the question. Access is granted at that override's permissionLevel.
  3. Else — no folder anywhere in the chain, including the target folder itself, has an override row — the target folder's own visibility setting governs, unconditionally: visibility = 'workspace' grants view; visibility = 'private' denies access to everyone (owner/admin already exited at step 1). There is no third "no restriction" fallback — a private folder with no override anywhere in its chain is denied, full stop. This is the default-deny behavior that makes "one team's library is not visible to everyone" actually true from the moment a private folder is created, rather than only once someone remembers to lock it down.
  4. A member always retains access to videos they personally own regardless of the containing folder's permission state — ownership is a separate access path from folder browsing, matching Section 6's member capability to "manage own videos."

viewer-role workspace members (Section 6: "watch + comment only, cannot record") have no implicit library-browsing access at all; steps 2/3 above apply to them exactly as to member, but in practice a viewer typically only ever holds explicit view-level grants or workspace-visible folders, since viewer is intended for internal stakeholders consuming shared content rather than browsing broadly.

GET /v1/folders/{folderId}/permissions response:

{
  "data": [
    { "id": "fld_9Qa2X4vTf6JmW1yZ3nHkPo", "principalType": "role", "principalId": "member", "permissionLevel": "view", "grantedBy": "usr_1Ab...", "createdAt": "2026-06-01T00:00:00Z" },
    { "id": "fld_7Yb2Vn4Xp9Kq1sT3wR8mLc", "principalType": "user", "principalId": "usr_4Kx8Vn3Xp1Kq9sT4wR7mL", "permissionLevel": "manage", "grantedBy": "usr_1Ab...", "createdAt": "2026-06-02T09:00:00Z" }
  ],
  "meta": null
}

Error codes: 403 folder_access_denied (composition rule in 18.2.1 evaluates to deny, including on the video-move endpoint per 18.2), 422 folder_depth_exceeded, 422 folder_permission_principal_invalid (principalType = 'role' with a value that is not one of the four roles in Section 6), 404 folder_not_found.

18.3 The shareable folder/collection view #

A folder or collection can itself be the target of a share link — the link security model (visibility, password, expiry, domain allowlist, audit logging on every permission change) is canonical in Section 14 and applies identically whether the shared resource is a video or a folder. This section defines only the folder-specific rendering rule.

A folder share link renders a grid of the videos directly inside that folder (not recursively into subfolders, to avoid surprising exposure of nested private material through a parent link). Inheritance rule: a video appears in the folder link's view only if the video's own sharing has not been explicitly restricted below what the folder link grants — concretely, a video whose own visibility (Section 14) is set more restrictively than link (e.g. explicitly private) is excluded from the folder view even though it sits inside the shared folder. A video with no explicit visibility override of its own inherits the folder link's access level. This means locking down one sensitive video inside an otherwise-shared folder is always possible and always wins.

Any social preview generated for a folder share link — the Open Graph og:image and JSON-LD thumbnailUrl shown when the link is unfurled in Slack, iMessage, or a chat client — uses a representative video's versioned, access-controlled poster path (18.1), never a permanent CDN URL. A folder link that is revoked or downgraded stops resolving that preview image exactly as it stops resolving playback for the videos inside it.

18.4 Video metadata management #

Field Constraints
title Required, 1-200 chars. Defaults to the recording's capture date/time if left blank at creation.
description Optional, markdown, max 5000 chars.
thumbnail Either an auto-generated candidate or a custom upload (JPEG/PNG/WebP, max 5 MB, min 640×360).
tags text[], each tag 1-40 chars, max 20 tags per video.

Thumbnail candidates: at ingest, the media pipeline (Section 9) extracts 5 candidate frames at 10%, 25%, 50%, 75%, and 90% of durationMs, stored as media_assets rows of type thumbnail. The editor presents all 5 plus an "upload your own" option; the selected one is flagged is_selected = true on its media_assets row. Whichever candidate is selected is served everywhere in the product through the same versioned, access-controlled poster path described in 18.1 — the underlying media_assets row is never linked to directly by a permanent URL.

Duplicate/copy: "Duplicate" creates a new videos row referencing the same underlying media_assets/renditions (the source recording is immutable and shared, per Section 9/11 — no re-transcode needed) but with its own copy of the edit_decision_lists row, so editing the duplicate never touches the original. The duplicate's title defaults to "Copy of <original title>", its share links are not copied (a duplicate starts unshared), and its analytics start at zero — it is a distinct video for every purpose except the underlying media bytes.

PATCH /v1/videos/{videoId} (partial metadata update) accepts any subset of title, description, tags, thumbnailAssetId. Error codes: 400 title_required (attempting to clear title to empty), 400 tag_limit_exceeded (> 20 tags), 404 thumbnail_asset_not_found (thumbnailAssetId does not reference a thumbnail-type media_assets row belonging to this video), 413 thumbnail_too_large (custom upload > 5 MB).

POST /v1/videos/{videoId}/duplicate returns the new video's full resource with data.sourceVideoId set to the original, for UI attribution ("duplicated from...").

18.5 The brand kit #

The brand kit is workspace-level so demo videos look consistent without every member configuring their own — this matters because it is the plan-gated differentiator between Pro's personal branding and Business's enforced branding.

Fields on brand_kits: logo (media asset reference), colorPalette (primary, secondary, accent, background — hex), typography (fontFamily from a curated list, fontWeight), backgroundPresets (list of gradient/image preset ids available in the recording/editing tools), introClipId / outroClipId (18.6), playerAccentColor (hex, overrides colorPalette.accent for the player specifically if set), watermarkPosition (top-left|top-right|bottom-left|bottom-right|center), emailTemplateBranding (useLogo: boolean, useAccentColor: boolean — applies to the notification templates in 17.7), scope (personal|workspace), ownerUserId (set only when scope = 'personal'), version (integer, incremented on every save), enforced (boolean, workspace scope only), enforcedFields (text[], names the specific fields locked when enforced = true).

Plan gate:

Plan Brand kit behavior
Free No custom branding. The player shows the forced, non-removable "Made with Reelay" watermark, fixed at bottom-right, not configurable.
Pro Each member may maintain their own scope = 'personal' brand kit, applied to recordings they create. No workspace-wide enforcement exists at this tier.
Business A single scope = 'workspace' brand kit, managed by owner/admin. Setting enforced = true locks the fields listed in enforcedFields for every workspace member's new recordings — personal kits, where they exist from a prior Pro period, are ignored for any field listed in enforcedFields.

"Enforced" means precisely this: when enforced = true, any field named in enforcedFields is applied from the workspace kit to every new recording/render, and the recording/editing UI hides the per-field override control for that field — a member cannot select a different accent color, logo, or watermark position for a field the admin has locked. Fields not listed in enforcedFields remain individually customizable even under enforcement (e.g. an admin can lock the logo and colors workspace-wide while leaving backgroundPresets selectable per member). The default when an admin first flips enforced = true is enforcedFields = ['logo', 'colorPalette', 'playerAccentColor', 'watermarkPosition'] — the identity-critical fields — leaving typography and background presets open unless the admin explicitly adds them.

Existing videos and the brand kit version: a video's rendered output bakes in whichever brand_kits.version was current at render time, recorded on the video as applied_brand_kit_version. Videos keep their originally applied version — changing the brand kit (a new save, bumping version) never silently changes already-rendered videos, since the redaction/render burn-in model (Sections 11.7 and 22.2) means brand elements are burned into delivered renditions the same way redaction is. A re-apply flow lets an owner/admin (or the member who owns the video, if the kit is not enforced for that field) trigger "reapply brand kit," which queues a video.render.brand-refresh job (Section 9 conventions: idempotent, keyed video.render.brand-refresh:<videoId>:<targetBrandKitVersion>) that re-renders the video's current EDL (Section 11) against the current kit version and updates applied_brand_kit_version on success. This can be triggered per-video or in bulk across a folder (18.1's bulk operations) after a rebrand.

GET /v1/workspaces/{workspaceId}/brand-kit:

{
  "data": {
    "id": "ws_7Yb2Vn4Xp9Kq1sT3wR8mLc",
    "scope": "workspace",
    "ownerUserId": null,
    "logo": { "assetId": "img_9Qa2X4vTf6JmW1yZ3nHkPo", "url": "https://cdn.reelay.app/brand/acme/logo.svg" },
    "colorPalette": { "primary": "#1B2A4A", "secondary": "#F4F6FB", "accent": "#3D6BFF", "background": "#FFFFFF" },
    "typography": { "fontFamily": "Inter", "fontWeight": 500 },
    "backgroundPresets": ["gradient-cool-01", "solid-brand-primary", "blurred-desktop-01"],
    "introClipId": "med_1Ab...", "outroClipId": "med_2Cd...",
    "playerAccentColor": "#3D6BFF",
    "watermarkPosition": "bottom-right",
    "emailTemplateBranding": { "useLogo": true, "useAccentColor": true },
    "enforced": true,
    "enforcedFields": ["logo", "colorPalette", "playerAccentColor", "watermarkPosition"],
    "version": 4,
    "updatedAt": "2026-08-01T10:00:00Z"
  },
  "meta": null
}

Error codes: 403 plan_gate (scope = 'workspace' requested on Free/Pro; Free additionally rejects any brand kit write with plan_gate), 403 brand_kit_field_locked (a member attempts to change a field listed in enforcedFields while enforced = true), 422 brand_kit_color_invalid (non-hex value), 404 media_asset_not_found (introClipId/outroClipId/logo reference does not resolve).

18.6 Intro/outro clips #

Intro and outro clips are uploaded video files (MP4/MOV, H.264, max 30 seconds each, max 200 MB), stored as media_assets and referenced from the brand kit (introClipId/outroClipId). Transition handling: a hard cut is the default; an optional 500 ms cross-fade may be enabled per brand kit.

Interaction with the EDL (Section 11 owns the EDL schema): intro/outro clips are represented as EDL segments of type: 'intro_clip' / type: 'outro_clip' that reference the separate intro/outro media asset rather than a range of the source recording. They are prepended/appended at render time and are not part of the source-time scrubbing range the viewer interacts with — the on-screen scrub bar reflects only the primary recording's timeline. Viewers may skip an intro via a "Skip Intro" control that appears after 3 seconds of intro playback; outros are not skippable (they typically carry a CTA, 17.5).

18.7 Custom domains (Business) #

Custom domains let a Business workspace serve watch pages from their own subdomain instead of the default Reelay domain.

Setup flow:

  1. Admin enters the desired hostname (e.g. videos.acme.com) in workspace settings.
  2. The system returns a CNAME target and a TXT verification record; the admin creates both DNS records at their registrar/DNS host.
  3. A verification job polls DNS for both records. On success, the domain status transitions to verifying → active; the CDN layer provisions a TLS certificate automatically for the new hostname once the CNAME resolves.
  4. From active, the watch page for every video in that workspace becomes reachable at https://videos.acme.com/{shareSlug} in addition to the default domain; the default-domain URL continues to work (no forced migration of previously shared links).

Domain status states: pending (entered, not yet verified), verifying (DNS check in progress), active (serving traffic), failed (verification or certificate issuance did not succeed within the retry window). The embed loader script (embed.js, Section 15) continues to be served from the global Reelay CDN domain regardless of custom domain status, to preserve its shared-cache effectiveness across all customers — only the watch page HTML and the signed playback URLs it embeds resolve on the custom domain.

Failure states and handling:

Failure Detection Behavior
CNAME not found / mismatched DNS poll finds no matching CNAME after 3 attempts over 24h Status → failed; admin sees remediation instructions with the exact expected record.
TXT verification record missing Same polling cycle as CNAME Status stays pending; no certificate is requested until both records resolve.
Certificate issuance failure CDN layer reports issuance error (e.g. CAA record blocking issuance) Status → failed; admin sees the raw provider error and remediation guidance (e.g. add/adjust a CAA record).
Domain later stops resolving (DNS changed after activation) Periodic re-check (weekly) of active custom domains Status → failed; the workspace automatically falls back to the default domain for new links, existing custom-domain links degrade to a clear "domain unavailable, use this link instead" page rather than a broken TLS error.

POST /v1/workspaces/{workspaceId}/custom-domains:

{ "hostname": "videos.acme.com" }
{
  "data": {
    "id": "ws_2Cd8Vn3Xp1Kq9sT4wR7mLc",
    "hostname": "videos.acme.com",
    "status": "pending",
    "cnameTarget": "cname.reelay.app",
    "txtRecordName": "_reelay-verify.videos.acme.com",
    "txtRecordValue": "reelay-verify=8f2a1c...",
    "lastCheckedAt": null,
    "createdAt": "2026-08-19T14:00:00Z"
  },
  "meta": null
}

Error codes: 403 plan_gate (non-Business), 409 hostname_already_claimed (hostname already verified on another workspace), 422 hostname_invalid (not a valid DNS hostname, or is a bare apex domain — apex domains cannot carry a CNAME and must use a subdomain), 404 custom_domain_not_found.

18.8 Workspace settings surface #

The workspace settings area is the admin control surface referenced throughout this section group and elsewhere. Its pages, and who can reach them, are:

Page Minimum role
General (name, timezone, default retention notice) admin
Members & invites admin
Roles overview (read-only matrix, Section 6) member (read-only)
Brand kit (18.5) admin to manage the workspace kit; any member to manage their own personal kit on Pro
Folders & permissions (18.2) admin for workspace-wide grants; folder manage-level grantees for their own folder
Custom domains (18.7) admin, Business only
Retention policy (Section 19) admin
Billing & plan owner only (Section 21)
Integrations (Section 20) admin
API keys (Section 7, Business only) admin
Audit log (Section 22) admin
Notification defaults (workspace-level fallback for 17.7) admin

19. Retention, Deletion & Data Lifecycle #

This section defines how long content lives, how the platform warns before removing it, and what "deleted" actually means at the storage layer. It is written to a single non-negotiable premise, stated in full in 19.8 and again in Section 21.6: hitting a plan cap never breaks an already-shared link. Retention (this section) and plan caps (Section 21) are two entirely separate mechanisms with different triggers, different notice requirements, and different blast radii. Nothing in this section describes cap enforcement, and nothing in Section 21 describes automatic deletion.

19.1 Retention Policy Per Plan #

Plan Retention window Clock starts Automatic deletion?
Free 90 days when the video becomes inactive (19.1.1) Yes, after the full notification ladder (19.2)
Pro 730 days (2 years) when the video becomes inactive (19.1.1) Yes, after the full notification ladder (19.2)
Business Unlimited never starts No automatic deletion. Videos persist until an explicit user action (19.4) or a data-subject erasure request (19.6).

Retention is not measured from upload date. A video that is watched or edited regularly never starts its retention clock, regardless of age. This rewards active use and only reclaims storage for content nobody is touching.

19.1.1 Definition of "inactive" #

A video is inactive when it has received zero view events (video_view_events, Section 16) and zero edit operations for 30 consecutive days. Edit operations include: any edit_decision_lists mutation, any metadata change (title, description, folder, tags), any redaction region change, any restore-from-trash, and any manual "keep this video" action taken from a retention notice (19.2). Comments and reactions from viewers do not count as activity — they are viewer-generated, not owner-generated, and counting them would let a single spam comment indefinitely defer retention.

The videos row (canonical schema in Section 5) carries the columns this policy operates on:

Column Type Purpose
last_activity_at timestamptz Updated by trigger on any qualifying view or edit event.
activity_status textactive | inactive Computed nightly (below).
inactive_since timestamptz NULL Set the moment activity_status flips to inactive.
retention_deadline timestamptz NULL inactive_since + plan retention window. NULL on Business.

A nightly BullMQ job video.retention.scan (queue concurrency 4, one run per workspace timezone midnight batch) performs the transition:

-- Flip active videos with 30 days of silence to inactive and set the deadline.
UPDATE videos v
SET activity_status = 'inactive',
    inactive_since = now(),
    retention_deadline = now() + (
      CASE (SELECT plan_tier FROM workspaces w WHERE w.id = v.workspace_id)
        WHEN 'free' THEN interval '90 days'
        WHEN 'pro'  THEN interval '730 days'
        ELSE NULL -- business: never scheduled
      END
    )
WHERE v.activity_status = 'active'
  AND v.deleted_at IS NULL
  AND v.last_activity_at < now() - interval '30 days'
  AND (SELECT plan_tier FROM workspaces w WHERE w.id = v.workspace_id) <> 'business';

Any qualifying activity on an inactive video immediately reverses the transition — the same trigger that updates last_activity_at also resets activity_status = 'active', clears inactive_since and retention_deadline, and cancels any pending deletion_requests row (status set to cancelled, see 19.3). A workspace upgrade (Free → Pro, Pro → Business) recomputes retention_deadline for every inactive video in that workspace immediately (job video.retention.recalculate), extending or clearing deadlines as appropriate — a downgrade does the same in the other direction, but a downgrade never shortens a deadline below 30 days out, guaranteeing the full notification ladder always has room to run.

19.1.2 Timezone handling #

video.retention.scan runs once per hour, not once per workspace-local midnight — inactivity is a 30-day rolling window measured in absolute UTC time (last_activity_at is timestamptz), so there is no timezone ambiguity in when the 30-day threshold is crossed. What is timezone-sensitive is the notification ladder (19.2): the "30 days before deadline" / "7 days before" / "1 day before" thresholds are evaluated in UTC, but the human-readable deletion date shown in each notice email and banner is rendered in the workspace's configured workspaces.timezone (IANA identifier, default UTC if never set). This keeps the underlying schedule deterministic while the copy a human reads is always locally correct.

19.1.3 Interaction with trash #

A video that is in the trash (19.4, deleted_at IS NOT NULL) is excluded from the video.retention.scan query entirely (WHERE deleted_at IS NULL is implicit in the trigger's target set) — a trashed video is already on its own, shorter, 30-day countdown to permanent deletion and does not need a second, parallel retention clock running against it. If a video is restored from trash while it was already inactive before being trashed, its inactivity state resumes exactly where it left off: last_activity_at is bumped by the restore action itself (restore counts as an edit, 19.1.1), so it re-enters as active with a fresh 30-day inactivity window, never skipping straight back to a stale retention_deadline.

19.2 The Notification Ladder #

Commitment: nothing is ever deleted by retention without prior notice. Every video that reaches its retention_deadline has already received three notices, on a fixed schedule, in two channels:

Notice Sent at Channels Recipients
First notice 30 days before retention_deadline In-app banner + email All owner and admin members; the video's member owner if different
Second notice 7 days before retention_deadline In-app banner + email Same
Final notice 1 day before retention_deadline In-app banner + email Same

A single scheduled job, video.retention.notify, runs hourly and evaluates every video with a non-null retention_deadline against the three thresholds. Idempotency is tracked directly on the video's open deletion_requests row (19.3) via three nullable timestamp columns — notice_30d_sent_at, notice_7d_sent_at, notice_1d_sent_at — each set exactly once. A video never receives the same notice twice, and a workspace with many videos approaching deletion in the same window receives a single digest email per threshold rather than one email per video.

Each notice email and in-app banner:

  • Names every affected video by title and thumbnail.
  • States the exact deletion date (ISO 8601, workspace-local timezone rendering).
  • Offers a one-click "Keep this video" action — an authenticated GET/POST link that fires the same effect as an edit: it updates last_activity_at, which reverses the inactivity flag and cancels the pending deletion, per 19.1.1.
  • Offers a one-click "Upgrade plan" CTA linking to the billing portal (Section 21.2), since upgrading extends or removes the retention window entirely.

Retention notices are compliance-critical and are not subject to the opt-out controls in notification_preferences — a workspace admin can change delivery channel preferences for marketing or comment-reply email, never for pending-deletion notices.

19.3 True Deletion — The Complete Purge #

When a video's retention_deadline passes with no reactivation, or a user explicitly empties it from trash (19.4), the platform performs true deletion: every byte of the video is removed everywhere it was ever copied to. Marking a database row as deleted is not true deletion and is never treated as sufficient on its own — hard media deletion is mandatory (Section 7.5's soft-delete convention explicitly excludes media assets, which always hard-delete on purge).

19.3.1 Complete artifact inventory #

Restricted media lives in two physically separate buckets (canonical model, Section 22.2.2): reelay-media-restricted (STORAGE_BUCKET_RESTRICTED) holds unredacted originals only, has no CDN origin, and is IAM-denied by default; reelay-media-delivery (STORAGE_BUCKET_DELIVERY) holds every servable rendition, poster, thumbnail and export. A purge that clears only the delivery bucket leaves the unredacted original behind — the worst possible residue — so every artifact below states which bucket it lives in, and the purge workflow (19.3.2) touches both explicitly.

# Artifact Location Deletion mechanism
1 Unredacted original (source recording) reelay-media-restricted bucket; media_assets.kind = 'original' — or 'redaction_unredacted_original' when the video has redaction regions (Section 5.4), the same physical object under a different kind value. Never served to a viewer, never referenced by a share link. DeleteObject
2 Every rendition (transcode ladder outputs, redaction burned in) reelay-media-delivery bucket DeleteObjects (batch)
3 ABR package (HLS/DASH manifests + segments) Mux asset Mux DELETE /video/v1/assets/:id
4 Poster and thumbnail set reelay-media-delivery bucket + Mux thumbnail service DeleteObjects + Mux asset deletion cascades its own thumbnails
5 GIF, WebM, MP4 export artifacts reelay-media-delivery bucket DeleteObjects
6 Cursor telemetry blob cursor_telemetry_blobs row + internal object storage (non-delivery, never CDN-fronted) Row delete + DeleteObject
7 Audio extraction Internal object storage (non-delivery) DeleteObject
8 Transcript and captions transcripts, transcript_segments, captions rows + VTT files in the reelay-media-delivery bucket (captions are served to viewers alongside playback) Row cascade delete + DeleteObjects
9 Waveform cache Internal object storage (non-delivery, editor-only) DeleteObject
10 CDN cached objects CDN edge caches, every rendition/poster/thumbnail/export URL ever served from reelay-media-delivery — the restricted bucket has no CDN origin (Section 22.2.2), so item 1 is never cached at the edge in the first place CDN invalidation/purge API call per URL prefix
11 Search index entry Postgres full-text search document for the video DELETE FROM video_search_documents WHERE video_id = $1
12 Analytics rows video_view_events, video_view_daily, video_engagement_curve Deleted, with one anonymized aggregate rolled up first — see rationale below
13 Comments, reactions, CTAs, email captures, share links, share link recipients comments, comment_reactions, ctas, cta_events, email_captures, share_links, share_link_recipients Cascade delete (19.5)
14 Database row videos Deleted last, after every artifact above is verified gone

Analytics disposition (item 12): raw per-viewer events and per-video rollups are deleted, not retained — a deleted video has no legitimate business purpose for per-viewer engagement data, and retaining it after the underlying content is gone is a pure liability under 19.6. Before deletion, the purge job folds a single anonymized figure — total historical view count, with no viewer identity, no timestamps, no video reference — into the workspace's running lifetime-views counter (workspaces.lifetime_view_count, incremented once), so workspace-level trend reporting is not silently corrupted by deletions. No per-video or per-viewer data survives.

Poster/thumbnail CDN purge is not exclusive to full deletion. Poster and thumbnail object keys are content-hashed and scoped by the owning share link's playback_key_version, cached with a one-year-immutable Cache-Control (Section 14.1.1) — so a plain hard delete of the underlying row is not enough to stop a previously-cached poster from being served. Any visibility downgrade or link revocation (the trigger is owned by Section 14.1.1) invokes the same CDN purge routine as stage 6 below (video.deletion.purge-cdn), scoped to the affected link's key version rather than the whole video, immediately invalidating every edge-cached poster and thumbnail for that link. Full video deletion is simply the case where every key version for the video is purged at once.

19.3.2 The staged, resumable, idempotent workflow #

Deletion is tracked by a deletion_requests row and executed as an ordered chain of BullMQ jobs (Section 9's queue conventions apply: max 5 attempts per stage, exponential backoff, dead-letter queue video.deletion.dlq). Each stage is independently idempotent — it checks whether its target is already absent before acting, so re-running a stage after a crash or a retry is always safe.

The deletion_requests schema — including target_type ('video' | 'workspace' | 'account' | 'gdpr_erasure'), target_id, the 8-value status enum ('pending' | 'notified' | 'scheduled' | 'in_progress' | 'verifying' | 'completed' | 'verification_failed' | 'cancelled'), and every column this section operates on — workspace_id, reason, requested_by, scheduled_for, the three notice_*_sent_at timestamps, purge_progress (an index into the ordered stage list below, 0..10), verification_report, started_at, completed_at — is owned by Section 5.4. This section only describes the behavior those columns drive.

Ordered stages (queue name video.deletion.<stage>), each keyed with job.id = video:<deletionRequestId>:deletion:<stage>:v1 so BullMQ itself rejects a duplicate enqueue of the same stage:

Stage index Job name Action Idempotent check
1 video.deletion.lock Flip deletion_requests.status = 'in_progress', videos.status = 'purging', revoke all active signed playback tokens (Section 14), return 410 Gone from watch/embed resolution for this video's share links from this instant forward. No-op if already purging.
2 video.deletion.purge-media Delete the unredacted original from the reelay-media-restricted bucket (item 1), every rendition from the reelay-media-delivery bucket (item 2), and the Mux ABR asset (item 3) — both media buckets are cleared in this single stage, so the workflow can never advance past it with one bucket still holding content. HEAD/GET each target first; skip if already absent.
3 video.deletion.purge-derived Delete posters/thumbnails (4) and exports (5) from reelay-media-delivery, plus waveform cache (9) and audio extraction (7) from internal object storage. Same pattern.
4 video.deletion.purge-telemetry Delete cursor telemetry blob (6). Same pattern.
5 video.deletion.purge-text Delete transcripts, transcript_segments, captions rows and VTT files (8), chapters, ai_metadata_suggestions, redaction_regions rows. DELETE ... WHERE video_id = $1 is naturally idempotent.
6 video.deletion.purge-cdn Issue CDN invalidation for every URL prefix this video ever served under from reelay-media-delivery (10), including every poster/thumbnail key version issued for it — the same routine Section 14.1.1 invokes on visibility downgrade or link revocation, scoped here to the entire video rather than one link. CDN purge APIs are idempotent by design (purging an already-purged path is a no-op success).
7 video.deletion.purge-search Remove the search index document (11). DELETE idempotent.
8 video.deletion.anonymize-analytics Roll up and delete analytics rows (12). Rollup increment is guarded by a processed_deletion_id check to prevent double-counting on retry.
9 video.deletion.purge-row Cascade-delete comments/reactions/CTAs/email captures/share links/recipients (13, detail in 19.5), then delete the videos row (14). Cascade FKs + DELETE ... WHERE id = $1.
10 video.deletion.verify Re-check every artifact category is absent; write verification_report. Read-only; always safe to re-run.

Each completed stage advances deletion_requests.purge_progress and enqueues the next stage. A worker recovery sweep (video.deletion.resume-sweep, run on every worker boot and every 15 minutes) finds rows with status = 'in_progress' and no enqueued job for purge_progress + 1, and re-enqueues from there — this is what makes the workflow resumable across worker restarts or crashes mid-purge.

19.3.3 Verification and operator visibility #

Stage 10 (video.deletion.verify) is the authority on whether deletion actually happened. It independently re-checks every category in 19.3.1 (object storage listings by prefix, a Mux asset GET expecting 404, a CDN purge-status confirmation, the absent DB row, the absent search document) and writes a structured result:

{
  "videoId": "vid_2n8fQe7pXk3mZbR9",
  "checkedAt": "2026-08-19T04:12:03.000Z",
  "checks": [
    { "artifact": "restricted_bucket_original", "status": "absent", "verifiedVia": "s3_head_404_reelay-media-restricted" },
    { "artifact": "delivery_bucket_renditions", "status": "absent", "verifiedVia": "s3_list_empty_reelay-media-delivery" },
    { "artifact": "mux_asset", "status": "absent", "verifiedVia": "mux_get_404" },
    { "artifact": "cdn_cache", "status": "purge_confirmed", "verifiedVia": "cdn_purge_receipt" },
    { "artifact": "search_index", "status": "absent", "verifiedVia": "pg_row_count_zero" },
    { "artifact": "database_row", "status": "absent", "verifiedVia": "pg_row_count_zero" }
  ],
  "allPassed": true
}

If every check passes, deletion_requests.status = 'completed', completed_at = now(). If any check fails, status = 'verification_failed' — the row is not marked complete, an audit_events row is written with severity = 'critical', and an operator-facing alert fires (paging integration, Section 24). The specific failed stage is retried up to its 5-attempt ceiling; if still failing, the request sits in verification_failed for manual operator remediation and is surfaced on an internal operations dashboard listing every request in that state, sorted oldest first. A video is never reported to the requesting user as "deleted" until verification passes.

19.3.4 Stage processor contract #

Every stage handler implements the same shape, so the resume sweep (19.3.2) and the DLQ retry path can treat all ten stages uniformly:

interface DeletionStageContext {
  deletionRequestId: string;
  videoId: string;
  workspaceId: string;
  attempt: number; // 1..5
}

interface DeletionStageResult {
  alreadySatisfied: boolean; // true if the target was already absent — still a success
  detail?: Record<string, unknown>; // stage-specific evidence, folded into verification_report
}

type DeletionStageHandler = (ctx: DeletionStageContext) => Promise<DeletionStageResult>;

// Registered in stage order; the runner advances purge_progress only after a handler resolves.
const DELETION_STAGES: Record<string, DeletionStageHandler> = {
  'video.deletion.lock': lockVideoAndRevokePlayback,
  'video.deletion.purge-media': purgeOriginalAndRenditions,
  'video.deletion.purge-derived': purgeDerivedMedia,
  'video.deletion.purge-telemetry': purgeCursorTelemetry,
  'video.deletion.purge-text': purgeTranscriptsAndCaptions,
  'video.deletion.purge-cdn': purgeCdnCache,
  'video.deletion.purge-search': purgeSearchDocument,
  'video.deletion.anonymize-analytics': anonymizeAndPurgeAnalytics,
  'video.deletion.purge-row': cascadeDeleteAndRemoveRow,
  'video.deletion.verify': verifyAllArtifactsAbsent,
};

A handler throwing lets BullMQ's retry/backoff take over per Section 9's queue conventions (max 5 attempts, exponential backoff); a handler resolving with alreadySatisfied: true is logged but treated identically to a fresh deletion for progression purposes — this is what makes re-running a stage after a crash safe rather than merely tolerated.

19.3.5 API surface #

Method & path Actor Effect
POST /v1/videos/:id/trash member (own video) or admin/owner (any video) Soft delete (19.4 step 1)
POST /v1/videos/:id/restore Same Restore from trash (19.4 step 2a)
DELETE /v1/videos/:id Same, with ?permanent=true required as an explicit confirmation query param Enqueues true deletion immediately, bypassing the remaining trash window (19.4 step 2b)
GET /v1/workspaces/:id/trash admin/owner (workspace-wide view); member sees own videos only Cursor-paginated (Section 7.5) list of trashed videos with purge_at
POST /v1/workspaces/:id/trash/empty admin/owner Enqueues true deletion for every trashed video in the workspace
GET /v1/deletion-requests/:id admin/owner Poll status/progress of an in-flight deletion — returns status, purgeProgress, and verificationReport once available

A DELETE /v1/videos/:id call without ?permanent=true is rejected with 422 and error code permanent_delete_confirmation_required — this endpoint shape makes an accidental, unqualified DELETE request (e.g. a naive REST client assuming the standard verb) safe by default; it always falls back to trash (19.4 step 1) unless the caller explicitly opts into skipping the recovery window.

19.4 Trash / Recycle Bin #

Deleting a video from the library is a two-step, reversible-then-final process.

Step 1 — soft delete (trash). The user clicks Delete. The video row is updated: deleted_at = now(), status = 'trashed', purge_at = now() + 30 days. This is an explicit user deletion, one of the only two things (alongside noticed retention, 19.2) that the Iron Rule (19.8) permits to stop playback — so it takes effect immediately: every share link and embed for this video begins returning 410 Gone ({"error":{"code":"video_deleted", ...}}) from that instant. During the 30-day trash window, the underlying media still exists in full — nothing in 19.3.1 has been deleted yet. Only playback is suspended.

Step 2a — restore. Any workspace member with edit rights on the video (or any admin/owner) can restore it from the trash UI at any point before purge_at. Restoring clears deleted_at and purge_at, sets status = 'active', and playback resumes immediately — the same share links and embed codes work again with no re-sharing required, because the underlying share_links rows were never deleted, only the resolution check was gated on deleted_at IS NULL.

Step 2b — permanent delete / empty trash. From the trash UI, a user can "Delete Forever" a single video, or "Empty Trash" for every trashed video in the workspace. Either action immediately creates a deletion_requests row (reason = 'user_trash_purge', scheduled_for = now()) and enqueues stage 1 of the workflow in 19.3.2 — it does not wait out the remaining trash window.

Step 3 — automatic purge. A nightly sweep (video.trash.sweep) finds every video with status = 'trashed' and purge_at <= now() and creates the same deletion_requests row automatically. After purge completes and verification passes, the video cannot be recovered under any circumstances — there is no extended undo, no admin override, no "contact support to restore." This is stated to the user at the moment they enter trash ("permanently deleted after 30 days") and again in the confirmation dialog for "Delete Forever."

19.5 Cascade Rules #

19.5.1 Video deletion cascade #

Related entity Effect at trash (soft delete) Effect at purge (true deletion)
comments, comment_reactions Hidden from viewers (query filters deleted_at IS NULL on the parent video) Hard-deleted (ON DELETE CASCADE)
video_view_events, video_view_daily, video_engagement_curve Untouched Deleted after anonymized rollup (19.3.1 item 12)
share_links, share_link_recipients Resolution returns 410 Gone; rows untouched Hard-deleted (ON DELETE CASCADE)
share_audit_events Untouched A summary line ("video permanently deleted", actor, timestamp) is copied into the workspace-level audit_events table before the video-scoped share_audit_events rows are cascade-deleted, so audit continuity survives the content's removal
ctas, cta_events, email_captures Hidden from viewers Hard-deleted (ON DELETE CASCADE)

19.5.2 Member removal from a workspace #

When an owner or admin removes a member (or a member leaves voluntarily), their videos are never deleted. Every video where videos.created_by = <removed user id> is reassigned: created_by is set to the workspace's owner (Section 6 guarantees exactly one owner per workspace), and a boolean ownership_transferred = true plus an audit_events row recording the original creator is written. The videos remain in their existing folders, keep their existing share links (Iron Rule, 19.8), and remain fully visible to the workspace. The removed user loses access to the workspace itself but this has no retroactive effect on content they created while a member.

19.5.3 Workspace deletion #

An owner can request workspace deletion. This does not delete anything immediately: workspaces.status = 'pending_deletion', purge_at = now() + 30 days. During this 30-day grace period the workspace behaves exactly like a workspace over a plan cap (Section 21.6): all creation is blocked, but every existing share link and embed keeps playing — this is the same creation-read-only mechanism reused, not a special case. The same 30/7/1-day notice ladder (19.2) is sent to the owner, each notice offering a one-click cancel deletion action that immediately reverts status = 'active' and clears purge_at.

If the grace period elapses with no cancellation, a job (workspace.deletion.execute) enqueues the full video-deletion workflow (19.3.2) for every video in the workspace, then — once every video reports completed — hard-deletes the workspace itself: workspace_members, workspace_invites, brand_kits, folders, custom_domains, api_keys, webhook_endpoints, feature_flags overrides, and cancels the Stripe subscription (Section 21.8) without a refund beyond what 21.8 specifies. subscriptions and invoices rows are retained per 19.6/19.7's legal-retention carve-out even though everything else is gone.

19.5.4 Account (user) deletion #

A user requesting deletion of their own account (self-service, or via a GDPR erasure request, 19.6) is handled as follows:

  1. Sole owner of a workspace with other members: blocked. The user must transfer ownership (Section 6's ownership-transfer flow) before account deletion can proceed. The UI surfaces this requirement with the specific workspace(s) that need a new owner.
  2. Sole owner of a workspace with no other members: the workspace is scheduled for deletion under 19.5.3 in the same request.
  3. Member, admin, or viewer elsewhere: their workspace_members rows are deleted; any videos they created transfer per 19.5.2.
  4. The user's own personal data is scrubbed per the erasure procedure in 19.6: users.name is replaced with the literal "Deleted User" and users.email is replaced with a synthetic, non-routable tombstone address of the fixed form deleted-<user_id>@tombstone.reelay.invalid; password_hash is cleared, and all oauth_accounts/mfa_credentials rows are deleted. The users.id row itself is retained as a tombstone — it remains the foreign-key anchor for historical comments, audit_events, and jobs_audit rows so that "who did this" history stays internally consistent, while carrying no personal data.
  5. Email delivery logs are scrubbed, not deleted. Every email_log row whose to_email matches the deleted user's address has to_email overwritten with the same deleted-<user_id>@tombstone.reelay.invalid value produced in step 4. Every non-PII column on that row — email_type, sent_at, delivery status, provider message id — is left untouched. This preserves delivery-audit continuity (bounce/complaint history feeding sender reputation, Section 20) while removing the only personal data the row carried; a full row delete would erase that audit trail entirely, which is not required by, and goes further than, the erasure obligation itself.

19.6 GDPR / CCPA Data-Subject Requests #

The compliance framework (roles, lawful basis, DPA, sub-processor list) is owned by Section 22. This section defines the mechanics of the two data-subject rights that touch retention: export and erasure.

19.6.1 Identity verification #

A request is only actioned once the requester's identity is confirmed:

  • Self-service (from account settings while authenticated): identity is already established by the active session; a re-authentication step (password or MFA re-entry) is required immediately before submission to guard against a hijacked, unattended session.
  • Support-initiated (a data-subject request submitted outside the product, e.g. by email to a privacy contact): the requester must reply from the email address on file for the account and complete an emailed magic-link confirmation tied to that account. Requests that cannot be matched to an existing account, or where the reply channel doesn't match the account email, are logged and rejected with a request for further proof (a signed statement, matched against account metadata) before any data movement occurs.

19.6.2 SLA #

Step Target
Acknowledge receipt Within 3 business days
Complete export or erasure Within 30 calendar days (the more stringent of GDPR's "without undue delay, within one month" and CCPA's 45-day window)
Extension (complex requests) One additional 30-day extension, with the requester notified of the reason before the original deadline expires

19.6.3 Export #

An export request produces a downloadable ZIP, generated asynchronously (privacy.export.generate) and emailed to the account holder when ready (never generated synchronously — a workspace with years of video can be large). Contents:

export-<userId>-<generatedAt>.zip
├── manifest.json              # index of every file below, with sizeBytes and sha256
├── profile.json                # users row (minus password_hash), oauth_accounts (provider only), mfa status
├── workspaces.json             # every workspace_members row, role, joined date
├── videos-metadata.json        # every video the user created: title, description, duration, created/edited timestamps
├── comments.json                # every comment/reaction the user authored
├── billing.json                 # subscriptions and invoices tied to workspaces the user owns
└── media/
    └── download-links.json      # one signed, time-limited URL per owned video's original file (7-day TTL)

Video originals are not embedded in the ZIP itself (they can be gigabytes each); instead media/download-links.json lists one signed URL per video, valid for 7 days from generation, after which the export must be regenerated. This keeps the ZIP itself small and fast to produce while still delivering the complete underlying media.

19.6.4 Erasure #

An erasure request creates a deletion_requests row with reason = 'gdpr_erasure' and target_type = 'account', and follows the account-deletion cascade in 19.5.4 exactly, with one difference: erasure requests skip the 30-day workspace grace period (19.5.3) for workspaces the requester solely owns with no other members — GDPR/CCPA erasure timelines take priority over the standard cancel-window UX. Workspaces with other members still require ownership transfer first (19.5.4 rule 1); the requester's own membership and personal data are erased immediately regardless.

19.6.5 What is retained, and why #

Data Retained after erasure? Legal basis
invoices, subscriptions history Yes, 7 years Tax and financial recordkeeping law requires records to survive the customer relationship; PII on invoices is limited to name and billing address, which are legally required invoice fields, not marketing data
audit_events referencing the tombstoned user id Yes, indefinitely Security/audit trail integrity (Section 22); contains no personal data beyond the already-tombstoned user id
Video content, comments, telemetry, analytics No No legal basis to retain once the data-subject relationship and the underlying video are gone

19.6.6 API surface #

Method & path Actor Effect
POST /v1/users/me/export Authenticated self, re-authenticated within the last 15 minutes Creates the export job (19.6.3); returns 202 with a job reference immediately, ZIP delivered by email when ready
GET /v1/users/me/export/:jobId Same Poll export job status
POST /v1/users/me/erasure-request Authenticated self, re-authenticated within the last 15 minutes Creates a deletion_requests row (reason = 'gdpr_erasure'), begins the account-deletion cascade (19.5.4, 19.6.4)
POST /v1/privacy/erasure-requests Internal support tooling only, requires the support-initiated verification flow (19.6.1) to have already completed and be attached as evidence Same effect as above, for support-initiated requests

Both self-service endpoints require re-authentication (password or MFA) within the 15 minutes preceding the call — a stale but still-valid session cookie is not sufficient authority to trigger an irreversible erasure or a full personal-data export.

19.6.7 Erasure for non-account-holder data subjects #

19.6.1–19.6.6 assume the requester is, or was, a platform account holder — identity is verified against an existing users row. But the platform also holds personal data about people who never created an account: a named viewer who typed their email into an email-capture gate (Section 17), a recipient who received a personalized share link with an embedded recipientToken (Section 14), or a contact synced from a connected CRM (Section 20). These people are, statistically, the population most likely to submit an erasure request — they frequently didn't ask for the video, don't know its owner personally, and have no account to log into to reach 19.6.6's authenticated endpoints. A platform whose only erasure path requires an account cannot honor their rights at all, so this subsection defines a parallel path scoped to the data actually held about them: an email address, optionally paired with a specific video.

Scope. A request is scoped to (video_id, email) when the requester names a specific video or link they interacted with, or to email alone when they want every trace of that address removed across every workspace that ever captured it. The narrower scope is offered as the default (it resolves faster) but the broader email-only scope is honored in full whenever requested.

Identity verification — no account match required. There is no session to re-authenticate, so verification is a standalone email-ownership check, not an account lookup: the requester submits the target email (and, optionally, a video or link URL) to a public, unauthenticated endpoint; the platform emails a magic-link confirmation to that address with a 30-minute TTL; clicking it confirms ownership and immediately activates the request. The initial submission always returns the same 202 with a generic body regardless of whether the address is found anywhere in the system (the same enumeration-resistant posture as the signup endpoint, Section 6.1.2) — the confirmation email is sent only when the address actually appears in email_captures, a named video_view_events row, or a synced CRM contact, so an unrecognized address produces no visible difference in the response and receives no email.

What gets erased.

Source Action
email_captures Every row matching the email: hard-deleted.
video_view_events (rows named via a recipientToken or an email-capture gate submission) The identifying email/token linkage is scrubbed to a tombstone value; the row's playback/engagement measurements are retained in anonymized form — the same disposition as the account-holder path (19.3.1 item 12) — rather than hard-deleted, because deleting individual rows out of a partitioned analytics table by email would corrupt the aggregate rollups every other viewer on that video depends on. Anonymization achieves the same privacy outcome without that side effect.
Synced CRM contacts (Section 20) The platform issues a delete call against every connected CRM integration that received this contact via sync, and removes its own copy of the synced record. If a downstream CRM's API offers no delete call, the integration is flagged and the workspace admin is notified to complete erasure directly in the CRM — the platform cannot compel a third-party system it doesn't control, but the obligation is surfaced, never silently dropped.
Comments/reactions authored under the captured (non-account) email The author email is scrubbed to the tombstone form used in 19.5.4; comment text itself is retained by default (other participants in a shared thread may rely on it), unless the requester explicitly also asks for content removal, which is honored as a separate, distinct request.

A confirmed request creates a deletion_requests row (target_type = 'gdpr_erasure', target_id a synthetic id representing the email-scoped request, since no single video or account row anchors it) and drives the table above through the same staged, idempotent job pattern as 19.3.2.

API surface.

Method & path Actor Effect
POST /v1/privacy/viewer-erasure-requests Public, unauthenticated Body { "email": string, "videoId"?: string }. Always 202 with a generic, enumeration-resistant body; triggers the magic-link confirmation email only when a matching row exists
GET /v1/privacy/viewer-erasure-requests/confirm?token=... Public, token-bearing link only Confirms ownership, converts the pending request into an active deletion_requests row, begins the erasure table above

Rate-limited per Section 7.9, IP-keyed, matching the auth rate-limit posture — this prevents the confirmation-email trigger itself being used to spam arbitrary addresses.

19.7 Backups #

Scope. Backups cover the PostgreSQL system of record only. Media (originals, renditions, ABR packages) is not separately backed up by this platform — durability for media is delegated to the object storage provider's built-in multi-zone replication and to Mux's own storage guarantees for ABR assets; re-deriving a rendition from a surviving original is also always possible via the media pipeline (Section 9) and is cheaper than maintaining a parallel media backup system.

Property Value
Mechanism Continuous WAL archiving + automated daily full base snapshot
Retention 35 days, rolling
RPO (Recovery Point Objective) ≤ 5 minutes (bounded by WAL shipping interval)
RTO (Recovery Time Objective) ≤ 60 minutes for a full point-in-time restore to a fresh instance
Restore procedure Point-in-time recovery: provision a new instance from the nearest base snapshot, replay WAL to the target timestamp, validate against a staging checklist (row counts, foreign-key integrity spot checks, application smoke tests), then promote
Restore drills Quarterly, against a non-production environment, with results logged to audit_events

Interaction with erasure requests. Backups are immutable point-in-time snapshots — an erasure request executed today cannot reach into a snapshot taken yesterday. This is the standard, disclosed posture: backups roll off naturally within the 35-day window and are never used for anything beyond disaster recovery. If a backup is ever restored and promoted to serve live traffic (a true disaster-recovery event, not a routine drill), the operations runbook requires replaying the full deletion_requests log for every erasure/purge event recorded since that snapshot's timestamp against the restored instance before it is reconnected to any client-facing system. This guarantees an erasure is never accidentally undone by a disaster-recovery restore.

19.8 The Iron Rule #

Hitting a plan cap never breaks an already-shared link.

Retention (this section) and plan caps (Section 21) are different mechanisms, and they must never be conflated in implementation:

  • Caps gate creation. A plan cap (library size, storage quota, seat count, recording length, AI minutes, export count) only ever blocks the start of something new — a new recording, a new upload, a new render, a new export. A cap is checked once, at the moment of creation, server-side (Section 21.5). It is never re-evaluated against content that already exists, and it never touches playback.
  • Retention deletes only with prior notice. The only mechanisms in this entire document that can remove a video's ability to play are (a) the retention policy in this section, which is contractually required to deliver the full 30/7/1-day notice ladder (19.2) before it acts, or (b) explicit user deletion (19.4), which the user themselves initiated with full knowledge of the consequence.
  • A workspace that goes over its plan cap, downgrades its plan, or has its subscription lapse into non-payment enters a creation-read-only state (Section 21.6). Every video that was already shared or embedded — in an email, in a help center article, in a customer's inbox from six months ago — keeps playing, indefinitely, at full quality, with no watermark added retroactively, for as long as the video itself has not been deleted or reached a noticed retention deadline. This holds through downgrades, through failed payments, through library overage, and through seat overage.

This rule is enforced structurally, not just by policy: the playback resolution path (Section 14/15) never queries workspaces.plan_tier or any usage counter. It queries exactly one thing — videos.deleted_at IS NULL AND share_links.revoked_at IS NULL (plus link-level security checks: password, expiry, domain allowlist). Plan state is architecturally absent from the playback code path, which is what makes the guarantee true by construction rather than by convention.


20. Integrations — Slack, Notion, HubSpot & Webhooks #

Every native integration in this product follows the same shape: an OAuth connection stored once per workspace (or per user, where noted) as an integration_connections row (Section 5), a token refresh path that runs transparently before expiry, a narrowly scoped set of vendor permissions, and a UI surface in Settings → Integrations plus contextual entry points where the integration is actually used (the share sheet, the video detail page). All four subsections below — Slack, Notion, HubSpot, and the generic outbound-webhook surface — are available on every plan; connecting and configuring an integration is not a Business-plan feature. Only the developer-facing webhook mechanics of Section 7.13 (custom endpoint registration via the API, delivery log via API) are gated to Business; the in-app webhook configuration UI described in Section 20.4 is available on every plan and simply has no API-key path to manage it below Business.

20.1 Common Integration Model #

20.1.1 Connection Storage #

Every native integration connection is one row in integration_connections (Section 5 owns the column list; referenced here by name): id (itg_), workspaceId, provider (slack|notion|hubspot), connectedByUserId, accessTokenEncrypted, refreshTokenEncrypted, scopes (text array, the exact vendor scope strings granted), externalAccountId (the vendor's workspace/org ID, e.g. Slack team ID), externalAccountName (display name, e.g. Slack workspace name, shown in the UI so an admin can tell which vendor org is connected), status (active|expired|revoked), lastRefreshedAt, createdAt, updatedAt. Uniqueness is enforced on (workspace_id, provider) — one connection per provider per workspace; reconnecting replaces the row rather than creating a second one, so there is never ambiguity about which vendor org receives a given share or push.

Access and refresh tokens are encrypted at rest with the deployment's KMS-backed envelope encryption (the same mechanism used for other sensitive columns per Section 22) and are never returned in any API response — GET on a connection returns only provider, externalAccountName, status, connectedByUserId, createdAt, scopes (scope names are not secret).

20.1.2 The IntegrationProvider Interface #

All three native integrations, and any future one, implement the same TypeScript interface so a fourth integration is a new file in apps/api/src/integrations/, not a new architecture:

// packages/shared/src/integrations/provider.ts

export type IntegrationName = "slack" | "notion" | "hubspot";

export interface OAuthTokenSet {
  accessToken: string;
  refreshToken: string | null;
  expiresAt: Date | null; // null = vendor tokens do not expire (e.g. some Notion grants)
  scopes: string[];
}

export interface IntegrationContext {
  workspaceId: string;
  connection: IntegrationConnectionRecord; // decrypted token access happens only inside provider methods
}

export interface IntegrationProvider {
  readonly name: IntegrationName;

  /** Build the vendor authorize URL (PKCE/state included) for the OAuth start step. */
  getAuthorizationUrl(params: { workspaceId: string; redirectUri: string; state: string }): string;

  /** Exchange the callback code for tokens. Called once, from the OAuth callback route. */
  exchangeCode(params: { code: string; redirectUri: string }): Promise<OAuthTokenSet>;

  /** Refresh an expiring/expired token. Called by the shared refresh scheduler, never ad hoc. */
  refreshToken(current: OAuthTokenSet): Promise<OAuthTokenSet>;

  /** Revoke the connection both locally and, where the vendor supports it, at the vendor. */
  disconnect(ctx: IntegrationContext): Promise<void>;

  /** Provider-specific health check used by the Settings UI status indicator. */
  checkHealth(ctx: IntegrationContext): Promise<{ healthy: boolean; reason?: string }>;

  /** Map a vendor-specific rate-limit response into the shared backoff shape (Section 20.1.3). */
  parseRateLimit(response: Response): { retryAfterMs: number } | null;
}
  • Each provider's outbound actions (e.g. Slack's "post message," Notion's "create page," HubSpot's "log engagement") are NOT part of this interface — they are provider-specific service classes (SlackService, NotionService, HubspotService) that consume a connection resolved through this interface. The interface's job is exclusively the connection lifecycle (auth, refresh, disconnect, health), which is genuinely identical in shape across vendors; the action surface is deliberately NOT forced into a common shape, because Slack "share to channel," Notion "push page," and HubSpot "log engagement" have no meaningful common abstraction beyond "make an authenticated vendor API call," and forcing one would produce a leaky, useless interface.
  • A shared IntegrationRegistry (packages/shared/src/integrations/registry.ts) maps IntegrationName → IntegrationProvider instance, used by the OAuth callback route, the token refresh worker, and the Settings UI's connection list — every consumer looks the provider up by name rather than importing a specific class, so adding a fourth integration means: implement the interface, register it, done.

20.1.3 Token Refresh #

  • A BullMQ scheduled job integration.token.refresh-sweep runs every 15 minutes, selecting integration_connections rows where status = 'active' and the decrypted token set's expiresAt is within 30 minutes (for vendors with expiring tokens — Slack bot tokens do not expire under the granted scope model below and are excluded from this sweep; Notion and HubSpot tokens do expire and are included). For each candidate, it calls provider.refreshToken(), re-encrypts and stores the new token set, and updates lastRefreshedAt.
  • A refresh failure (vendor returns invalid_grant or equivalent — the user revoked access outside Reelay) sets status = 'revoked' and enqueues a notification job so the connecting user and all workspace admins get an in-app + email notice ("Your Slack connection needs to be reconnected") with a direct link to Settings → Integrations. No feature silently no-ops without surfacing this; every UI entry point that would use a revoked connection (the share sheet's "Send to Slack" button, etc.) checks status first and shows a "Reconnect" affordance instead of the action.
  • A transient refresh failure (network error, vendor 5xx) is retried with the standard BullMQ backoff (Section 9) up to 5 attempts before being treated as the failure case above.

20.1.4 Disconnection Handling #

  • Disconnecting (DELETE on the connection, Settings UI action) calls provider.disconnect() (best-effort vendor-side token revocation — some vendors, like Notion, have no revoke endpoint and rely on the user removing the integration from their own vendor-side settings; the local effect is identical regardless) then deletes the integration_connections row (hard delete — connections are not soft-deleted, since a stale disabled connection has no product value and Section 19's soft-delete list does not include this table).
  • Any in-flight or scheduled action tied to the connection (a queued Slack notification, a pending Notion push) that has not yet executed at disconnect time is dropped, not retried; a connection removed mid-flight is treated as explicit user intent to stop.
  • Vendor-initiated revocation (the user removes the Reelay app from their Slack/Notion/HubSpot admin console) is detected on next use (a 401 from the vendor API) or at the next refresh sweep, and follows the same status = 'revoked' + notification path as a refresh failure above — there is exactly one revocation code path regardless of who or what triggered it.

20.2 Slack #

20.2.1 OAuth App Setup & Scopes #

Reelay registers one Slack app (distributed via the Slack App Directory) using OAuth 2.0 with the standard https://slack.com/oauth/v2/authorize flow. The connecting user must be a Slack workspace member with permission to install apps (Slack's own admin-approval flow applies if the Slack workspace requires it — Reelay does not bypass Slack's own governance).

Scope Why it's needed
chat:write Post messages (video shares, notifications) to channels/DMs the app is a member of or is explicitly targeting
chat:write.public Post to public channels the app has not been explicitly invited to, so a user can share to any public channel without a separate /invite step
links:read Read the Events API link_shared payload for unfurling (Section 20.2.3)
commands Register and receive the /reelay slash command
channels:read, groups:read, im:read, mpim:read Populate the channel/DM picker in the share sheet (list names the user can target) — read-only, no message content access
users:read Resolve the connecting user's Slack identity for attribution on record-and-send messages

No scope grants access to message content history (no channels:history or equivalent) — Reelay never reads existing Slack messages, only posts new ones and receives link-unfurl payloads for URLs Slack itself detects being posted.

20.2.2 Connection Storage #

Per Section 20.1.1, one integration_connections row per workspace, provider = 'slack', externalAccountId = Slack team ID, externalAccountName = Slack workspace name. Slack bot tokens (xoxb-...) issued under this scope set do not expire, so expiresAt is stored null and the connection is excluded from the refresh sweep (Section 20.1.3); health is instead verified via checkHealth() calling auth.test on each Settings-page load (cached 5 minutes).

20.2.3 What It Does #

  • Share to channel/DM: from a video's share sheet, a member picks a Slack channel or DM (from a list populated via channels:read/im:read) and an optional message; Reelay posts via chat.postMessage using Slack Block Kit — a rich card with the video's poster thumbnail, title, duration, and a "Watch" button linking to the share link created for this send (a link visibility share link is auto-created if the video had none, reusing an existing one otherwise).
  • Record-and-send from a slash command: /reelay record posts an ephemeral message with a "Start Recording" button deep-linking into the desktop app (or a browser recorder if the desktop app is not detected via a custom URL scheme handshake); once the resulting recording finishes processing (video.ready, Section 7.13.1), the app posts the same rich card back into the channel/DM the slash command was invoked from, attributed to the Slack user who ran the command.
  • Notifications: workspace members with a Slack connection can opt in, per notification_ preferences (Section 5), to receive a Slack DM (from the Reelay app) when a video they own gets a new comment or crosses view-count milestones (10/100/1000 views) — this is a thin adapter over the same event data that drives in-app notifications and the comment.created/video.viewed webhook events (Section 7.13.1), not a separate notification pipeline.
  • Link unfurling (Events API): Reelay subscribes to the link_shared event for the reelay.link domain and any connected custom domains (Section 7.11.21). When a Reelay URL is pasted into any Slack channel the app can see (per links:read), Slack calls Reelay's Events API endpoint; Reelay resolves the link to its video/share-link and checks the link's visibility and restrictions before deciding what to unfurl. A private/workspace-visibility link, or a link/public-visibility link with a password set, unfurls only a generic "protected video" card with no thumbnail or title — never enough to leak content to an unauthorized Slack audience. A domain-restricted link never unfurls with a full preview in Slack, by design: the link_shared request is placed by Slack's own servers, not by a browser on the host page, so there is no host page origin for Reelay to check against the link's domainAllowlist — the same absence-of-a- reliable-origin problem that limits iframe-embed domain enforcement to loader-script mode only (Section 7.11.16, R7). There is no Slack-team-to-domain mapping in this product: inventing one would be a guess about which website a Slack workspace "belongs to," not a security boundary, so Reelay does not attempt it. A domain-restricted link therefore always unfurls as the generic "protected video" card in Slack, regardless of which Slack workspace or channel posts it — this is a stated, intentional limitation, not a bug to be fixed by better heuristics.

20.2.4 UI Surface #

Settings → Integrations → Slack: connect/disconnect button, connected workspace name, a toggle per notification type (comments, view milestones), and a "default channel" picker used as the share sheet's pre-selected target. The share sheet itself (reachable from any video) gains a "Slack" tab alongside "Copy link" and "Email" once connected.

20.2.5 Error & Disconnection Handling #

Per the common model (20.1.4): a revoked Slack app shows "Reconnect Slack" in place of the Slack tab in the share sheet and disables the slash command (Slack itself returns an error to the user if they invoke /reelay after the app was uninstalled workspace-wide, which Reelay cannot intercept — this is expected and requires no special handling). A chat.postMessage failure due to the app no longer being a member of a private channel surfaces inline in the share sheet as "Reelay isn't in that channel — invite it or choose another," not a generic error.

20.2.6 Vendor Rate Limits #

Slack's Web API applies per-method tier limits (Slack's Tier 3, ~50+ requests/minute for chat.postMessage as of this writing, enforced by Slack, not configurable by Reelay). Reelay's parseRateLimit() implementation for Slack reads the Retry-After header Slack returns on 429 and requeues the send as a BullMQ job with that delay (capped at 5 retries, same backoff ceiling as Section 9's general job policy) rather than surfacing a failure to the user for a transient vendor throttle. Events API deliveries (inbound to Reelay) are not rate-limited by Reelay beyond the generic per-IP protections at the edge, since Slack's own delivery cadence is well within any sensible ceiling.

20.3 Notion #

20.3.1 OAuth App Setup & Scopes #

Notion's integration model is capability-based rather than granular OAuth scopes: a public Notion integration is configured with a fixed capability set at registration time (Notion does not support per-install scope negotiation the way Slack/HubSpot do). Reelay's Notion integration is registered with:

Capability Why it's needed
Read content Locate the target page/database when pushing a video
Insert content Create/append blocks (video embed block, transcript, chapters) on a page
Update content Update a previously pushed block if the video is re-pushed (Section 20.3.3)
No user information capability Reelay does not need Notion user email/profile data, so this capability is deliberately left ungranted, minimizing the OAuth consent surface shown to the connecting user

The connecting user grants access to specific pages/databases via Notion's own page-picker during the OAuth consent screen (Notion, not Reelay, controls this UI) — Reelay only ever sees pages the user explicitly shared with the integration, and cannot enumerate the user's full workspace.

20.3.2 Connection Storage #

One integration_connections row per workspace, provider = 'notion', externalAccountId = Notion workspace ID, externalAccountName = Notion workspace name. Notion access tokens do not expire under the standard OAuth grant (no refresh token is issued), so refreshTokenEncrypted is null and expiresAt is null; the connection is excluded from the refresh sweep, and health is verified by a lightweight users/me-equivalent call on each Settings-page load.

20.3.3 What It Does #

  • Paste-to-embed: Notion's own link-preview mechanism calls the target URL's Open Graph tags when a Reelay link is pasted into a Notion page; Reelay's watch pages (Section 15) already serve correct og:title, og:image (poster), and og:video tags, so a pasted Reelay link renders as a native Notion video embed with no Reelay-side integration code required for this direction — documented here because it is part of the Notion story even though it depends only on Section 15, not on the OAuth connection above.
  • Push video with transcript and chapters: from a video's "Send to Notion" action (available once connected), the user picks a target page (via the Notion API's search endpoint scoped to pages shared with the integration) and Reelay creates a set of blocks via blocks.children.append: an embed block (the share link URL, rendered by Notion the same as the paste-to-embed path above), a heading block "Transcript," paragraph blocks chunked from the transcript (Notion caps a single rich-text block at 2000 characters, so long transcripts are split at sentence boundaries nearest each 2000-char limit), and a bulleted-list block per chapter with its title and a deep-linked timestamp URL. The created block IDs are stored on the video (a notionPageId + notionBlockIds array in a JSON column, owned by Section 5) so a subsequent "Send to Notion" on the same video updates the existing blocks (blocks.update) rather than duplicating content.
  • Re-pushing after an edit (Section 11) that changed the transcript or chapters replaces the transcript/chapter blocks entirely (delete + recreate, since Notion's block API has no bulk replace) rather than diffing — simpler and Notion block operations are cheap enough that this is not a performance concern at the push frequency this feature sees (user-initiated, not automatic).

20.3.4 UI Surface #

Settings → Integrations → Notion: connect/disconnect, connected workspace name. Video detail page gains a "Send to Notion" button (in the same action group as "Send to Slack") that opens a page picker (backed by Notion's search endpoint) and shows "Sent to Notion" with a link to the page once complete, or "Update in Notion" on subsequent pushes to the same video.

20.3.5 Error & Disconnection Handling #

A push that fails because the target page is no longer shared with the integration (user revoked page-level access in Notion without disconnecting the whole integration — this is possible in Notion's model and is NOT the same as connection revocation) surfaces as "Reelay no longer has access to that page in Notion" with a re-picker, distinct from the "Reconnect Notion" state used for full connection revocation (20.1.4).

20.3.6 Vendor Rate Limits #

Notion's API enforces an average of 3 requests/second per integration (burst tolerant), returning 429 with a Retry-After header on excess. parseRateLimit() for Notion reads that header; multi-block pushes (transcript chunking above) are throttled client-side to roughly 3 req/s during the append sequence to avoid ever hitting the vendor limit in the common case, with the 429/backoff path as a fallback for contention from other Reelay workspaces' concurrent Notion pushes sharing infrastructure capacity (not vendor-side per-workspace contention, since Notion's limit is per integration-and-workspace-pair, but Reelay's own worker concurrency for this job type is capped separately to stay well under it in aggregate).

20.4 HubSpot #

20.4.1 OAuth App Setup & Scopes #

Reelay registers a public HubSpot app used via standard OAuth 2.0 (app.hubspot.com/oauth/authorize).

Scope Why it's needed
crm.objects.contacts.read / crm.objects.contacts.write Find the contact to attach a video to; write the synced-email property (20.4.3)
crm.objects.deals.read / crm.objects.deals.write Find and attach a video to a deal timeline
timeline (Engagements API / CRM timeline events scope) Log video views as engagement events on the contact/deal timeline
oauth Base scope required by HubSpot for any OAuth app to obtain a refresh token

20.4.2 Connection Storage #

One integration_connections row per workspace, provider = 'hubspot', externalAccountId = HubSpot Hub ID (portal ID), externalAccountName = HubSpot account name. HubSpot access tokens expire after 30 minutes and are refreshed via the standard OAuth refresh-token grant — this connection IS included in the 15-minute refresh sweep (Section 20.1.3), refreshed proactively before the 30-minute expiry.

20.4.3 What It Does #

  • Attach a video to a contact/deal timeline: from a video's "Send to HubSpot" action, the user searches for a contact or deal (via HubSpot's CRM search API, scoped to the connected portal) and Reelay creates a CRM Timeline Event (HubSpot's Timeline Events API, using a Reelay-registered event template with fields videoTitle, videoUrl, posterUrl, durationMs) associated with that contact or deal record. The event shows a Reelay-branded card directly in HubSpot's contact/ deal timeline UI with a thumbnail and "Watch" link.

  • Log views as engagement events: for any video that has been attached to at least one contact or deal (above), subsequent video.viewed events (Section 7.13.1) for viewers who are identified — either because they watched via a personalized recipient link whose email matches the attached contact, or because they submitted an email capture matching it — create an additional Timeline Event ("Contact watched to 82%") on that contact's timeline. Anonymous, unmatched views are never pushed to HubSpot, consistent with the pseudonymous-by-default analytics posture of Section 16.

  • Sync captured emails to contacts: when a share link's email-capture (Section 7.11.18) collects an email not already a HubSpot contact, Reelay creates a new HubSpot contact (or updates the existing one if the email matches) with the property mapping below. This sync is opt-in per workspace (a toggle in the UI surface below, off by default) since it creates CRM records as a side effect and workspaces should consciously enable it.

    Reelay field HubSpot property Notes
    email email Match key
    derived from share link's video title reelay_last_video_watched (custom property, auto-created on first sync if absent) Free text
    capturedAt reelay_last_captured_at (custom property) ISO timestamp string
    share link's id reelay_share_link_id (custom property) For traceability back to Reelay
    N/A lifecyclestage Never set or modified by Reelay — lifecycle stage is a CRM-owned concept the integration does not presume to manage

20.4.4 UI Surface #

Settings → Integrations → HubSpot: connect/disconnect, connected portal name, an "Auto-sync captured emails to HubSpot contacts" toggle (off by default per above). Video detail page gains a "Send to HubSpot" action opening a contact/deal search-and-attach picker, mirroring the Notion page picker pattern.

20.4.5 Error & Disconnection Handling #

A sync or attach call that fails because the matched contact/deal was deleted in HubSpot between search and attach returns a "That record no longer exists in HubSpot" inline error with a re-search prompt. Per the common model, full token revocation (detected via a 401 from HubSpot or the refresh sweep) shows "Reconnect HubSpot" and disables the auto-sync toggle's effect (queued sync jobs for a revoked connection are dropped, not queued indefinitely, matching 20.1.4).

20.4.6 Vendor Rate Limits #

HubSpot enforces both a burst limit (100 requests/10 seconds) and a daily limit tied to the connected portal's HubSpot subscription tier (typically 250,000/day on Professional+, lower on Starter — Reelay does not control this, it is a property of the customer's own HubSpot plan). parseRateLimit() for HubSpot reads the X-HubSpot-RateLimit-Remaining and X-HubSpot-RateLimit-Secs-Remaining response headers; when remaining capacity drops below 10% of the burst limit, the auto-sync worker voluntarily throttles itself (delays queued sync jobs) before HubSpot ever returns a 429, since a portal's HubSpot capacity is shared with the customer's other tools and Reelay treats it as a scarce shared resource, not a limit to race up to.

20.5 Generic Outbound Webhooks (User-Facing Surface) #

Section 7.13 defines the outbound webhook mechanics (signing, retry, delivery guarantees) — this subsection covers how a workspace admin configures, tests, and monitors webhooks without touching the API directly, i.e. the UI built on top of Section 7.11.23's endpoints.

  • Configuration: Settings → Integrations → Webhooks lists registered endpoints (URL, event subscriptions, enabled state, health indicator derived from the consecutive-failure counter of Section 7.13.4). "Add endpoint" is a form: URL, a multi-select of the event catalogue (Section 7.13.1) grouped by resource (Video events, Sharing events, Engagement events), and an optional description. On save, the same synchronous test-ping behavior described in Section 7.11.23 runs, surfaced in the UI as an immediate "✓ Endpoint responded" or "⚠ Endpoint did not respond — saved anyway, first real event may fail" inline notice.
  • Testing: a "Send test event" button per endpoint calls POST .../test (Section 7.11.23), which delivers a synthetic video.ready payload with placeholder data (videoId: "vid_test...") through the identical signing/delivery code path as a real event, so a passing test is a genuine guarantee the receiver's verification code works, not a special-cased ping.
  • Delivery-log UI: per endpoint, a reverse-chronological table of the last 30 days of deliveries (backed by GET .../deliveries, Section 7.11.23) showing event type, timestamp, HTTP status, duration, and attempt number; clicking a row expands the full request payload and response body (truncated per Section 7.13.4) for debugging. A "Redeliver" button per failed row calls POST .../redeliver. When an endpoint has been auto-disabled (Section 7.13.4), the UI shows a persistent banner on that endpoint's row with the disable reason and a one-click "Re-enable" that calls PATCH with { enabled: true }.
  • Webhook configuration itself requires admin role or above (matching Section 7.11.23's role column); member and viewer roles do not see the Webhooks settings page at all.

20.6 Zapier / Make Posture #

Reelay does not build or maintain a first-party Zapier or Make (Integromat) app in this release. The generic outbound-webhook surface (Section 20.5) combined with Zapier's and Make's native "Webhooks by Zapier" / "Webhooks" trigger modules is the documented, supported path for a workspace to connect Reelay to either platform today: a workspace admin registers a webhook endpoint pointing at the Zapier/Make-provided catch URL, subscribes to the relevant events (Section 7.13.1), and verifies the signature using the vendor's custom-code step with the algorithm in Section 7.13.3 — the developer docs (Section 7.14) include a copy-paste code sample for both platforms' custom-code verification step. A first-party Zapier app (published to the Zapier App Directory with pre-built triggers/actions rather than raw webhook configuration) is an explicit candidate for a future release once outbound webhook usage data (via the delivery log's aggregate metrics) shows enough Zapier-catch-URL registrations to justify the maintenance cost of a directory-listed app; it is not scoped further in this document. No Reelay-authored Zapier or Make app exists to install today, and the product does not claim one in any user-facing copy.

21. Billing, Plans & Usage Enforcement #

21.1 The Plan Matrix #

This table is canonical and exhaustive — every plan-gated behavior described anywhere in this document reduces to a row here.

Limit Free Pro Business
Seats (recording: owner/admin/member) 1 Per-seat, billed Per-seat, billed
Viewer seats Unlimited, free Unlimited, free Unlimited, free
Max recording length 5 minutes Unlimited (soft cap 4 hours) Unlimited (soft cap 4 hours)
Library video cap 25 videos Unlimited Unlimited
Storage quota 2 GB total 250 GB per seat, pooled 1 TB per seat, pooled
Player watermark Yes, forced No No
AI auto-editing (zoom/motion/backgrounds) Basic presets only Full Full
AI chapters / summaries / titles No Yes Yes
Filler-word & silence removal No Yes Yes
Viewer analytics Aggregate only Full, per-viewer Full, per-viewer
Custom branding / brand kit No Personal branding Workspace-enforced brand kit
Password + expiry on links Yes Yes Yes
Domain-restricted viewing No No Yes
Custom domain (CNAME) No No Yes
Retention (Section 19) 90 days after inactive 2 years after inactive Unlimited
Transcode priority Standard queue Standard queue Priority queue
REST API + webhooks No No Yes
Export (MP4/GIF/WebM) 720p, watermark Up to 4K Up to 4K

"Soft cap" on recording length means: the client warns and the server logs a metric at 4 hours, but does not hard-stop the recording — per the local-first capture invariant (Section 9), a recording in progress is never destroyed by a limit check. It is refused at the next recording start if the prior one wasn't finalized cleanly, never mid-capture.

21.2 Stripe Integration #

Products and prices. Two Stripe Products exist: "Reelay Pro" and "Reelay Business." Free has no Stripe object at all — a workspace on Free has subscriptions.stripe_subscription_id IS NULL and plan_tier = 'free' is simply a local flag. Each paid Product has two recurring Prices (monthly, annual), billing_scheme = 'per_unit', usage_type = 'licensed' (quantity-based, not metered) — quantity tracks the workspace's count of billable seats (owner + admin + member roles; viewer never counts, per 21.1).

Per-seat model. subscriptions.quantity is kept in sync with workspace_members count of billable roles by two triggers: inviting/accepting a billable-role member increments and calls Stripe's subscription-item update; removing one decrements. Every quantity change goes through Stripe (never a local-only quantity edit), so Stripe remains the single source of truth for what's billed.

Proration. Stripe's default proration_behavior: 'create_prorations' applies to every quantity change: adding a seat mid-cycle creates an immediate prorated invoice item collected on the next invoice (or immediately, if invoice_now semantics are desired for the specific flow); removing a seat creates a prorated credit applied to the next invoice. No custom proration math is implemented — Stripe's calculation is authoritative.

Trials. Pro and Business both offer a 14-day trial, no payment method required to start. The Stripe Subscription is created with trial_period_days: 14; a customer.subscription.trial_will_end webhook (fired 3 days before trial end) triggers an in-app + email prompt to add a payment method. If no payment method is attached by trial_end, Stripe's payment_behavior is configured to leave the subscription incomplete_expired, and the workspace's local plan_tier falls back to free (handled by the customer.subscription.deleted/updated webhook, 21.3) — never a hard cutoff mid- session, per the overage state machine (21.6).

Checkout flow. New subscriptions use Stripe Checkout (hosted), mode: 'subscription', initiated from the in-app upgrade CTA. Success redirects to a workspace billing confirmation page; cancel redirects back to the plan comparison page with no state change. Checkout Session is created with client_reference_id = workspaceId so the checkout.session.completed webhook can attribute the resulting subscription without relying on customer email matching.

Customer portal. Stripe's hosted Customer Portal handles self-serve plan changes, payment method updates, and invoice history, launched via a portal session scoped to the workspace's Stripe Customer. Access to any billing surface — Checkout, Customer Portal, invoice list — is restricted to the owner role (Section 6), enforced server-side before a portal session is ever created (21.10).

Tax. Stripe Tax is enabled on every Checkout Session and subscription, calculating and applying VAT/sales tax automatically by customer location; tax_id_collection is enabled for business customers to supply a VAT/GST number for reverse-charge handling.

Currency. Subscriptions bill in USD by default; Stripe's local presentment shows converted amounts for major currencies (EUR, GBP, CAD, AUD) at Checkout, but the subscription's billing currency is fixed at creation — changing currency requires cancelling and creating a new subscription, there is no in-place currency migration.

21.2.1 API surface #

Method & path Actor Effect
GET /v1/workspaces/:id/billing owner only Current plan, subscription status, quantity, period dates, payment method summary
POST /v1/workspaces/:id/billing/checkout-session owner only Creates a Stripe Checkout Session for a new/changed subscription; returns the hosted URL to redirect to
POST /v1/workspaces/:id/billing/portal-session owner only Creates a Stripe Customer Portal session; returns the hosted URL
GET /v1/workspaces/:id/invoices owner only Cursor-paginated (Section 7.5) invoice history
GET /v1/workspaces/:id/usage owner/admin Current-period usage for every counter in 21.4, with plan limits and percentage-to-cap
{
  "data": {
    "workspaceId": "ws_7hLpQ2mNv8fTxZ1",
    "planTier": "pro",
    "periodStart": "2026-08-01T00:00:00.000Z",
    "periodEnd": "2026-09-01T00:00:00.000Z",
    "counters": [
      { "type": "storage_bytes", "value": 187904819200, "limit": 268435456000, "pctOfLimit": 70 },
      { "type": "video_count", "value": 412, "limit": null, "pctOfLimit": null },
      { "type": "seats", "value": 9, "limit": 9, "pctOfLimit": 100 },
      { "type": "ai_minutes", "value": 340, "limit": 1000, "pctOfLimit": 34 },
      { "type": "export_count", "value": 12, "limit": null, "pctOfLimit": null },
      { "type": "api_calls", "value": 15042, "limit": null, "pctOfLimit": null }
    ],
    "overageState": "overage_warning"
  },
  "meta": null
}

limit: null denotes an uncapped counter on the workspace's current plan (per 21.1, e.g. Pro's unlimited library and export ceilings); pctOfLimit is likewise null in that case rather than a misleading 0.

Dunning. On a failed payment, Stripe's Smart Retries schedule attempts collection over approximately 14 days; subscriptions.status mirrors Stripe's (activepast_due on first failure). A past_due workspace remains fully functional — this is deliberate: dunning is not a punishment, and a legitimate payment-method hiccup should not disrupt a paying customer's work. Only once Stripe exhausts retries and transitions the subscription to unpaid does the local reconciliation (21.3) move the workspace into the creation-read-only overage state (21.6) — content already shared is, as always, unaffected.

21.3 Webhook Handling #

Signature verification. Every inbound Stripe event is verified via the SDK's stripe.webhooks.constructEvent(payload, signature, endpointSecret) using the raw request body (never a re-serialized/parsed body — signature verification requires the exact bytes Stripe sent). A signature that fails verification is rejected with 400 and never enqueued or processed further.

Idempotency. The handler enqueues a BullMQ job with job.id = billing:stripe-webhook:<event.id>:v1 immediately after signature verification and returns 200 to Stripe within its timeout window — all actual processing happens in the worker, asynchronously. Because BullMQ rejects a duplicate job.id, Stripe's at-least-once delivery guarantee (it retries on anything but a 2xx) can never cause the same event to be processed twice, with no separate dedupe table required.

Handled events:

Stripe event Local effect
checkout.session.completed Create/attach subscriptions row for the workspace (matched via client_reference_id); set plan_tier
customer.subscription.created Upsert subscriptions row: status, quantity, price, current period dates
customer.subscription.updated Sync status/quantity/price/period; if status transitions to unpaid/canceled, trigger downgrade (21.7)
customer.subscription.deleted plan_tier = 'free', subscriptions.status = 'canceled', trigger downgrade (21.7)
customer.subscription.trial_will_end Send in-app + email prompt to add payment method
invoice.paid Insert/update invoices row, status paid, clear any past_due UI state
invoice.payment_failed Update invoices row, status payment_failed; if subscriptions.status is still active, flip to past_due
invoice.finalized Insert invoices row (status open) — this is the point an invoice becomes visible in the billing UI
invoice.upcoming No local write; used only to trigger a "your renewal is coming up" email for annual plans
customer.updated Sync billing contact name/email/tax ID onto the workspace's subscription row
payment_method.attached Clear any "add a payment method" nag state
charge.refunded Update the related invoices row with refund amount/date (21.8)

Reconciliation. A nightly job (billing.reconciliation.stripe-sync) lists every Stripe subscription for every workspace with a non-null stripe_customer_id and diffs status, quantity, and price against the local subscriptions row. Stripe is always the source of truth: any drift is corrected locally (never pushed back to Stripe) and logged to audit_events with the before/after values. If more than 0.5% of checked workspaces show drift in a single run, an ops alert fires — that ratio indicates a systemic webhook-processing bug, not routine timing drift.

21.4 Usage Metering #

Counter Storage Incremented Decremented Recompute source
storage_bytes usage_counters On confirmed upload/render/export completion (Section 9) On video purge (19.3.2 stage 2–3) or export deletion Sum of media_assets.size_bytes + renditions.size_bytes for the workspace
video_count usage_counters On video row creation On video purge (19.3.2 stage 9) or move to another workspace COUNT(*) FROM videos WHERE workspace_id = $1 AND deleted_at IS NULL
seats usage_counters, mirrored from Stripe (21.2) On billable-role member added/invite accepted On billable-role member removed or demoted to viewer COUNT(*) FROM workspace_members WHERE role IN ('owner','admin','member')
ai_minutes usage_counters On completion of an AI auto-edit, transcription, or chapter/summary job, incremented by the source video's durationMs / 60000 Never (consumption is not refundable mid-cycle) Sum of usage_events of type ai_minutes in the current billing period
export_count usage_counters On successful export render completion Resets to 0 at the start of each billing period (Free/Pro export ceilings, if configured, are period-based) COUNT(*) FROM usage_events WHERE type = 'export' AND period = current
api_calls usage_counters On every authenticated public-API request (Section 7) Resets each billing period COUNT(*) FROM usage_events WHERE type = 'api_call' AND period = current

api_calls is metered for observability, rate-limit tuning, and future metered billing — it is not currently a hard cap; Business-plan API access is a boolean feature gate (21.1), not a quantity limit.

The usage_counters and usage_events schemas are owned by Section 5.4: the composite primary key (workspace_id, counter_type, period_start) on usage_counters (with period_start = '1970-01-01' denoting an all-time, non-period-scoped counter), the 6-value counter_type CHECK ('storage_bytes' | 'video_count' | 'seats' | 'ai_minutes' | 'export_count' | 'api_calls'), and usage_events' type/quantity/source_id/occurred_at columns. This section only describes how those columns are driven.

Drift and recompute. Because increments/decrements happen at many call sites (upload completion, purge stages, member management, exports), counters can drift from ground truth over time. A nightly job (billing.usage.reconcile) recomputes every counter from its authoritative source query (rightmost column above) and corrects usage_counters.value if it disagrees, logging the correction to audit_events when the delta exceeds 1% — small, expected rounding-adjacent drift is corrected silently; large drift is a signal worth an operator's attention.

Enforcement checkpoint. Every cap is checked before the action that would consume it starts — never after. Concretely: storage_bytes and video_count are checked when a recording session is initialized (POST /v1/recordings) or an upload is initialized (POST /v1/media-assets), before any bytes are accepted; seats is checked when an invite is sent or accepted; ai_minutes is checked before an AI job is dispatched to the queue; export_count is checked before an export render job is enqueued. This ordering guarantees the local-first capture invariant (Section 9) is never violated by a cap — a user is never left holding a completed recording that the server then refuses to accept.

21.5 Server-Side Enforcement #

Every cap in 21.1 is enforced on the server. Client-side checks exist purely as UX (to preemptively gray out a button and avoid a round trip) and carry zero authority — the server check is authoritative and is never skipped for a client that claims to have already validated.

Cap Checked at Error code HTTP status User-facing message
Recording length Recording finalize (POST /v1/recordings/:id/finalize) recording_length_exceeded 422 "This recording is longer than your plan allows. Upgrade to record without limits."
Library video cap Recording/upload init (POST /v1/recordings, POST /v1/media-assets) video_library_cap_exceeded 403 "You've reached your plan's video limit. Delete a video or upgrade to add more."
Storage quota Recording/upload init storage_quota_exceeded 403 "You're out of storage. Free up space or upgrade for more."
Seats Invite send/accept (POST /v1/workspaces/:id/invites) seat_limit_exceeded 403 "You've used all your seats. Remove a member or upgrade to add more."
AI minutes AI job dispatch (auto-edit, transcription) ai_minutes_exhausted 403 "You've used your AI minutes for this period. Upgrade for more, or wait for next cycle."
Export ceiling Export render enqueue export_limit_exceeded 403 "You've reached your export limit for this period. Upgrade to export more."
Feature gate (custom domain, API keys, domain restriction, brand kit, per-viewer analytics) Feature-specific endpoint feature_not_available_on_plan 403 "This feature isn't available on your current plan. Upgrade to unlock it."
Workspace in creation-read-only overage (21.6) Any creation endpoint workspace_overage_readonly 403 "Your workspace is over its plan limits. Resolve the overage or upgrade to keep creating."

All error responses use the canonical envelope (Section 7.6):

{
  "error": {
    "code": "video_library_cap_exceeded",
    "message": "You've reached your plan's video limit. Delete a video or upgrade to add more.",
    "details": [{ "field": "workspaceId", "issue": "library_cap_reached" }],
    "requestId": "req_9fH3kLmZpQx1"
  }
}

Warnings. Independently of hard enforcement, every counter is checked against 80% and 100% of its cap on every increment. Crossing 80% triggers a single in-app banner + email per counter per billing period (not repeated on every subsequent increment); crossing 100% triggers the hard block above plus a distinct "you're now over your limit" in-app + email notice.

interface EnforcementResult {
  allowed: boolean;
  errorCode?: 'recording_length_exceeded' | 'video_library_cap_exceeded' | 'storage_quota_exceeded'
    | 'seat_limit_exceeded' | 'ai_minutes_exhausted' | 'export_limit_exceeded'
    | 'feature_not_available_on_plan' | 'workspace_overage_readonly';
}

async function checkCap(
  workspaceId: string,
  counterType: 'storage_bytes' | 'video_count' | 'seats' | 'ai_minutes' | 'export_count',
  requested: number,
): Promise<EnforcementResult> {
  const [plan, current] = await Promise.all([
    getEffectivePlan(workspaceId),       // resolves plan_tier + any negotiated overrides
    getCurrentUsage(workspaceId, counterType),
  ]);
  const limit = planLimitFor(plan, counterType); // Infinity for unlimited rows in 21.1
  const projected = current + requested;

  if (projected > limit * 0.8 && current <= limit * 0.8) {
    await queueCapWarningNotice(workspaceId, counterType, 80);
  }
  if (projected > limit) {
    await queueCapWarningNotice(workspaceId, counterType, 100);
    return { allowed: false, errorCode: capErrorCode(counterType) };
  }
  return { allowed: true };
}

21.5.1 Concurrency and the check-then-increment race #

A naive "read the counter, compare to the limit, then increment" sequence is a classic time-of-check-to-time-of-use race: two concurrent upload-init requests can both read a counter sitting one unit under the cap, both pass the check, and both proceed — landing the workspace two units over. This is prevented by making the check and the increment a single atomic database operation rather than two separate steps:

-- Atomic check-and-increment for a licensed (non-pooled-across-period) counter such as video_count.
-- Returns the row only if the increment keeps the counter at or under the limit; returns zero rows
-- (and the caller treats that as a rejection) if it would exceed the limit.
WITH current AS (
  SELECT value FROM usage_counters
  WHERE workspace_id = $1 AND counter_type = 'video_count' AND period_start = '1970-01-01'
  FOR UPDATE
)
UPDATE usage_counters
SET value = value + 1, updated_at = now()
WHERE workspace_id = $1 AND counter_type = 'video_count' AND period_start = '1970-01-01'
  AND (SELECT value FROM current) + 1 <= $2 -- $2 = plan limit, or a caller-supplied MAX_BIGINT sentinel when unlimited
RETURNING value;

The FOR UPDATE row lock serializes concurrent requests against the same (workspace_id, counter_type, period_start) row — the second concurrent request blocks until the first transaction commits or rolls back, then re-evaluates against the now-updated value. This converts the race into a queue of one, which is exactly the semantics a hard cap needs. If the UPDATE returns zero rows, the request is rejected with the counter's error code (21.5's table) inside the same transaction that would otherwise have created the video/upload/recording row — the enforcement check and the resource creation are wrapped in one database transaction so a rejected cap check can never leave a partially-created recording or upload behind.

21.6 The Iron Rule #

Hitting a plan cap never breaks an already-shared link. Caps gate creation — new recordings, new uploads, new renders, and new exports — only. Playback of a link that has already been shared is never interrupted, for any reason this section describes: not a cap breach, not a downgrade, not a lapsed subscription, not library or seat overage. A video that was shared or embedded keeps playing indefinitely regardless of plan state. A demo sitting in a customer's inbox, or embedded in a help center article, keeps working forever, at the quality and watermark state it already had — no watermark is ever applied retroactively to an already-shared video on downgrade, and no watermark already burned into a delivered rendition is ever removed on upgrade without a fresh render (21.7 has the mechanics). The only two things that ever stop a video from playing are explicit user deletion, or the documented, always-noticed retention policy in Section 19 — nothing described in this section stops playback under any circumstance.

The overage state machine. A workspace transitions between three states based on its usage counters (21.4) relative to its plan limits (21.1):

        usage < 80% of every cap
   ┌───────────────────────────────────┐
   │                                     ▼
┌──────────┐   crosses 80% of any cap  ┌──────────────────┐   crosses 100% of any cap   ┌───────────────────────┐
│  active  │ ────────────────────────▶ │ overage_warning    │ ──────────────────────────▶ │ overage_readonly       │
└──────────┘                            └──────────────────┘                              └───────────────────────┘
   ▲                                             │                                                    │
   └─────────────────────── usage drops back under the relevant threshold ──────────────────────────┘
State What still works What is blocked What the user sees
active Everything Nothing Normal product
overage_warning Everything, including all creation Nothing An in-app banner ("You're at 85% of your storage") + one email; no functional change
overage_readonly All playback of existing content (Iron Rule); viewing, commenting, sharing existing links; billing/upgrade flows; account settings; deleting content to free up room New recordings, new uploads, new AI jobs, new exports, new renders — every creation endpoint returns workspace_overage_readonly (21.5) A persistent banner on every dashboard page: "Your workspace is over its plan limit. [Upgrade] or [Free up space]." Creation buttons are visibly disabled with the same message on hover/click

overage_readonly is entered the instant any single counter crosses 100% of its cap (not simultaneously across all counters — going over on storage alone is sufficient) and exited the instant every counter is back under its cap, whether by the user deleting content, removing seats, or upgrading. There is no separate approval step to exit — the state is a pure, continuously re-evaluated function of current usage vs. current plan limits.

21.7 Downgrade Behavior #

A downgrade (Pro → Free, Business → Pro, Business → Free, or a paid plan lapsing to Free per 21.2's dunning outcome) takes effect at the current billing period's end, per Stripe's default cancel_at_period_end semantics — never mid-cycle. From the moment the new, lower plan is active:

  • Videos over the new library/storage cap: nothing is deleted, hidden, or degraded. The workspace evaluates against 21.6's state machine using the new plan's limits — if the workspace is now over cap, it enters overage_readonly (creation blocked; every existing video, at its current quality, with its current watermark state, keeps playing).
  • Seats over the new count: no member is forcibly removed. The workspace enters a 14-day seat grace period: a notice at 7 days and again at 1 day before it ends warns the owner/ admins to remove members or upgrade. If unresolved when the grace period ends, the most-recently added billable members (LIFO — last invited, first affected) are automatically converted to the viewer role (free, unlimited) until the seat count fits the new plan. Converted members keep all their created content (ownership is untouched — only their workspace role changes) and can still view and comment; they can no longer record.
  • Business-only features in use (custom domains, domain restriction, API keys): functionally suspended, not deleted, with a 14-day grace period matching seats. During the grace period everything keeps working. After it: api_keys are revoked (401 on further calls; keys are kept, not deleted, and reactivate automatically on re-upgrade); domain-restriction checks stop being enforced (a security loosening the workspace admin is explicitly warned about, since it removes a protection rather than adding a restriction); custom-domain routing falls back to the default reelay.app short link for every existing share — the link itself keeps resolving and playing, it simply no longer resolves under the vanity domain. This is the Business-feature analogue of the Iron Rule: losing the plan tier degrades convenience, never breaks an already-distributed link.

21.8 Upgrades, Cancellations, Refunds, and Data-Access Window #

Upgrades take effect immediately (not at period end): the Stripe subscription item's price changes with proration_behavior: 'create_prorations' producing an immediate prorated charge, and every newly-unlocked feature (higher caps, no watermark, full analytics, etc.) is available the instant the webhook confirming the change is processed.

Cancellations are cancel_at_period_end: true by default — the workspace keeps full paid-plan functionality through the end of the current billing period, then transitions to Free per 21.7 at the period boundary. There is no immediate-cancellation option in the standard flow; a workspace that wants to stop paying immediately can still cancel and simply stop using paid features, but access is not artificially cut off mid-period since it's already paid for.

Refund posture. No prorated refunds are issued for a mid-cycle downgrade or cancellation — the customer retains access through the period they already paid for (above), which is the refund equivalent. The one exception is a 14-day money-back window from the initial paid subscription start: within that window, support can issue a full refund of the most recent invoice via the Stripe refund API at their discretion, which also immediately cancels the subscription and downgrades the workspace. Refunds are always issued for the full invoice amount — no partial-invoice refunds.

End-of-subscription data-access window. When a subscription ends (cancellation reaching period end, or a downgrade taking effect), the workspace does not lose access to its data — it transitions to Free and is immediately subject to Free's limits (21.7) and Free's retention window (Section 19.1). Nothing already shared stops playing (Iron Rule, 21.6/19.8); videos simply re-enter the normal inactivity/retention lifecycle under the new, shorter Free retention window going forward.

21.9 Free-Tier Anti-Abuse Controls #

  • Email verification before public sharing. Per the auth model (Section 6), a Free-tier account must verify its email address before any share link's visibility can be set to link or publicworkspace/private visibility does not require verification, since it doesn't expose content outside the workspace.
  • Per-IP signup limits. A maximum of 5 new Free-workspace signups per IP address per rolling 24-hour window. The 3rd and every subsequent signup attempt from the same IP within that window requires passing a CAPTCHA challenge before the signup form submits.
  • Disposable-email blocklist. Signup checks the email domain against a maintained disposable/ temporary-email domain list; matches are rejected with a clear message rather than silently admitted and flagged.
  • Abuse-detection posture. Velocity checks run continuously on video creation, sharing, and invite volume per workspace. Anomalous velocity (e.g. dozens of public share links created in minutes) triggers an automatic soft-throttle (tighter rate limits, Section 7.9) rather than an immediate ban — false positives are far more costly than a brief throttle. Workspaces flagged repeatedly, or reported via the abuse-report channel (Section 22), enter a manual review queue. Permanent suspension is a human decision, reserved for confirmed, repeated Terms of Service violations (illegal content, confirmed fraud), never an automated action.

21.10 Invoices, Receipts, Billing Contact, and Owner-Only Access #

Access control. Every billing surface — the plan/upgrade page's checkout action, the Customer Portal link, the invoice list, the billing-contact editor — is restricted to the owner role (Section 6: exactly one owner per workspace, the only role with billing authority). admin can view plan limits and usage (21.1, 21.4) but cannot initiate a plan change, view invoices, or edit the billing contact; a server-side check on every billing endpoint enforces this regardless of what the client renders.

Invoices and receipts. Every invoice.finalized/invoice.paid webhook (21.3) upserts an invoices row locally. GET /v1/workspaces/:id/invoices (cursor-paginated per Section 7.5, owner- only) lists them; each entry links to a short-lived redirect to Stripe's hosted_invoice_url for PDF download rather than proxying the PDF itself. A receipt email is sent automatically by Stripe on every successful payment to the workspace's billing-contact email.

Billing contact. A workspace's billing-contact email defaults to the owner's account email but can be changed to a separate address (stored on the subscriptions row, e.g. an accounts-payable mailbox) that does not need to correspond to any platform user account — it exists purely as the Stripe Customer.email and receives invoices, receipts, and payment-failure notices independently of who is logged into the product.

22. Security, Privacy & Compliance #

22.1 Threat Model #

This section states what is worth protecting, who is likely to attack it, and what is explicitly out of scope for v1. Every control described in the rest of Section 22 traces back to an asset or adversary listed here.

22.1.1 Assets #

Asset Why it matters Primary control
Unlisted/private demo videos containing customer data (screens showing real customer records, internal dashboards, PII) Accidental exposure is the single most damaging failure mode for this product's customers Share-link visibility model (Section 14), signed short-TTL playback URLs
Unredacted original recordings The whole point of redaction is defeated if the source is reachable Restricted bucket, never served, never linked, audit-logged access (22.2)
Posters, thumbnails, and animated previews A poster generated before redaction ran, or served from an unversioned permanent URL, silently leaks the exact content that redaction and link revocation were supposed to protect Generated only from redaction-verified renditions; keys scoped by playback_key_version, purged from storage and CDN on revocation/downgrade (22.2.2)
Transcripts and captions Verbatim text of everything said on camera, often more sensitive than the video itself Same workspace-scoped authorization as the parent video (22.4)
Captured emails and viewer identity data Personal data under GDPR/CCPA Pseudonymization by default, encryption at rest (22.7), retention limits (Section 19)
Viewer analytics (watch behavior, drop-off, engagement) Reveals what a named viewer did, when correlated with recipientToken Access control identical to the video (22.4), consent posture (22.10)
API keys and session tokens Full account/workspace takeover if leaked Hashing at rest, rotation, secret-scanning (22.6)
Workspace membership and role assignments Determines who can see/edit/share what Single enforcement point, IDOR prevention (22.4)
Custom domain and DNS configuration (Business plan) A hijacked custom domain lets an attacker serve content as the customer's brand Domain-verification flow with DNS TXT challenge, reference Section 20
Billing and payment data Financial harm, PCI exposure Never touches Reelay servers directly — Stripe-hosted (Section 21)

22.1.2 Adversaries #

Adversary Capability Primary defenses
Opportunistic link guesser Tries to enumerate or guess share slugs 12-character cryptographically random base58 slugs (Section 5), no sequential IDs, rate limiting on the watch-page route
Viewer trying to defeat redaction or download restrictions Has legitimate playback access, tries to recover blurred regions or bypass disable-download Server-side burned-in redaction (22.2), signed streaming-only URLs, no unredacted rendition ever served
Malicious or compromised workspace member Has a valid session with member or admin role, tries to access data outside their authorization Workspace-scoped queries at the single enforcement point (22.4), audit log on every share/permission mutation (Section 14)
Compromised integration or webhook token A leaked Slack/HubSpot/Notion connection token or webhook signing secret used to pull or inject data Scoped OAuth tokens (Section 20), signed and idempotent webhook verification (Section 7.10), token revocation on disconnect
Automated scraper / credential-stuffing bot Bulk login attempts, bulk share-link enumeration, bulk public-API abuse Rate limiting (22.3, Section 7.9), CAPTCHA escalation, anomaly-based lockout
Malicious uploader Uploads malware, illegal content, or content designed to phish viewers via the product's own domain Upload validation and magic-byte checking (22.5), malware scanning posture (22.9), content-report flow (22.9)

22.1.3 Explicitly Out of Scope for v1 #

The following are acknowledged risks that are deliberately not addressed at launch. Each has a stated reason and a stated future path:

  • End-to-end encryption of video content. Videos are encrypted at rest (22.7) and in transit, but Reelay's own infrastructure (transcoding pipeline, redaction render worker) must be able to read plaintext frames to function. A workspace that requires E2EE for video content is not served by v1. Revisit if enterprise demand materializes.
  • Customer-managed encryption keys (BYOK/HYOK). Object storage and database encryption use platform-managed keys (22.7). Customer-supplied KMS keys are deferred.
  • SOC 2 Type II certification. Section 22.11 states the honest readiness posture: Type I controls are in place at launch, Type II (observed-over-time) audit is a post-launch milestone.
  • HIPAA compliance / BAA availability. The product is not positioned for PHI at launch. No BAA is offered. This must be stated on the pricing/legal pages.
  • FedRAMP / government cloud. Out of scope indefinitely for v1.
  • SSO/SCIM. Deferred per Section 6 and Section 2's scope boundaries. Its absence is a workspace administration gap, not a security gap — the workspace_members table's explicit, non-implicit membership model means SSO can be added later without a data-model migration.
  • Client-side malware sandboxing of uploaded files beyond magic-byte and scanning checks (22.5, 22.9). Deep static/dynamic analysis of uploaded binaries is not applicable because the only accepted uploads are recording chunks and image assets (screenshots, brand kit logos) — never arbitrary file types, so a full malware sandbox is disproportionate to the actual attack surface.
  • Formal third-party penetration test before launch. A penetration test is scheduled for the first full quarter post-launch (22.11); launch proceeds on internal security review plus the checklist in 22.13.

22.1.4 Known Limitation: Domain Restriction Cannot Be Attested by an Iframe #

Domain-restricted share links (Business plan, Section 14) are meant to guarantee that a video only plays when embedded on an allowlisted host domain. That guarantee has a real transport limitation that this document states honestly rather than papering over: an <iframe> cannot attest to the domain of the page that embeds it. The Origin header on a request made from inside an embed iframe always reads the iframe's own origin (embed.reelay.app), never the host page's origin — a malicious host page can embed the iframe just as freely as an allowlisted one, and the server has no transport-level signal to tell them apart. Treating the iframe's own request origin as if it were the host page's origin would be a guarantee the product cannot actually make, so it does not make it.

The product's honest resolution: domain restriction is enforced only in loader-script mode, where the host page's own first-party script participates in a postMessage handshake with a server-issued, timing-bound nonce, so the host page itself — not the iframe — vouches for its own origin (Section 15 owns the handshake implementation; 22.8.2 states the security constraints it must satisfy). On the iframe-fallback path (used when a host page cannot or does not run the loader script — for example, embedding contexts that strip <script> tags), domain restriction is not enforceable, and the product does not pretend otherwise: a domain-restricted link refuses to play in iframe-fallback mode and shows an explanatory message rather than either (a) silently playing without the restriction it promised, or (b) silently claiming a guarantee the transport cannot back. This is stated here as a known, permanent limitation of the iframe embedding model, not a defect to be fixed later — no client-side signal can make an iframe trustworthy about its own embedder.

22.2 Redaction as a Security Property #

This is the most important subsection in this document's entire security posture. Redaction is not a visual effect. It is a data-handling guarantee, and it must hold even against a technically sophisticated viewer with full access to browser developer tools.

22.2.1 The core guarantee #

Blur and redaction regions defined in the timeline editor (Section 11.7) are burned into the pixel data server-side by the render worker, into every delivered rendition and every export format, before that rendition is ever written to a location a viewer's client can reach. Redaction is never implemented as a client-side overlay — no CSS blur filter, no canvas mask, no DOM element drawn on top of a video element. A client-side overlay is trivially defeated: a viewer opens developer tools, removes the overlay element or disables the filter, and the unredacted pixels — which were present in the video stream all along — are fully visible and capturable by any screen-recording tool. The product's redaction guarantee holds only if the pixels the viewer's device ever receives are already redacted. Nothing downstream of the render worker can be trusted to enforce redaction.

22.2.2 Storage separation: two physically separate buckets #

Restricted media does not live behind a prefix inside a single bucket — it lives in a second, physically separate bucket, with its own IAM policy and, deliberately, no CDN configuration at all. This is a load-bearing design decision, not an implementation preference: a single-bucket, restricted-prefix model depends on every bucket-policy statement and every CDN origin-path rule getting the prefix boundary exactly right, forever, across every future change to either. One misconfigured bucket policy, or one misconfigured CDN origin path, in a prefix-based model can expose the restricted content directly through the same bucket the delivery content is already served from. Two physically separate buckets remove that failure mode structurally: there is no bucket-policy statement or CDN origin configuration for the delivery bucket that can be misconfigured into exposing a different bucket's objects, because the restricted content was never placed in that bucket.

  • reelay-media-restricted (env var STORAGE_BUCKET_RESTRICTED) — holds every unredacted source object: video originals and screenshot source frames. No CDN distribution is attached to this bucket, in any environment. IAM-denied by default to every principal except the render worker's service role and the audited internal restricted-media tool (22.2.3) — no other application service, human operator, or deployment credential has read access.
  • reelay-media-delivery (env var STORAGE_BUCKET_DELIVERY) — holds every object a viewer's client is ever allowed to receive: redacted renditions, posters, thumbnails, animated previews, redacted screenshot exports, and video exports (MP4/GIF/WebM). This is the only bucket with a CDN distribution attached.

Object key layout:

s3://reelay-media-restricted/{workspaceId}/{videoId}/original.<ext>
s3://reelay-media-restricted/{workspaceId}/{videoId}/screenshots/{screenshotId}/original.<ext>

s3://reelay-media-delivery/{workspaceId}/{videoId}/renditions/{renditionId}.m3u8
s3://reelay-media-delivery/{workspaceId}/{videoId}/exports/{exportId}.<mp4|gif|webm>
s3://reelay-media-delivery/{workspaceId}/{videoId}/posters/{playbackKeyVersion}/{posterId}.jpg
s3://reelay-media-delivery/{workspaceId}/{videoId}/thumbnails/{playbackKeyVersion}/{thumbnailId}.jpg
s3://reelay-media-delivery/{workspaceId}/{videoId}/previews/{playbackKeyVersion}/{previewId}.mp4
s3://reelay-media-delivery/{workspaceId}/{videoId}/screenshots/{screenshotId}/edited.<ext>

Every media_assets.kind value, and the bucket it lives in:

media_assets.kind Bucket Why
original reelay-media-restricted The unredacted source recording for a video with no redaction_regions rows. Never CDN-fronted, never referenced by a share link or playback token, never a valid target for the signed-URL issuance path (22.7.3).
redaction_unredacted_original reelay-media-restricted The same physical object type and the same restricted-bucket rules as original — the distinct kind value exists so the row itself flags "this source is known-sensitive, has redaction regions defined against it" for audit and tooling. A video's original acquires this kind the moment its first redaction_regions row is created and keeps it even if every region is later removed, since the source was demonstrably sensitive at some point; original and redaction_unredacted_original are mutually exclusive per video. (The earlier, separate 'source_original' value is retired — it and redaction_unredacted_original described the same physical object; redaction_unredacted_original is the one that survives.)
screenshot_original reelay-media-restricted The raw captured screenshot frame (Section 13) before any redaction or beautification. Carries the same exposure risk as a video original — the frame can contain the identical sensitive on-screen content — so it is restricted-bucket by the same reasoning as original, not by a weaker convention for a "smaller" asset.
screenshot_edited reelay-media-delivery The beautified, redaction-applied screenshot export. This is the only screenshot form a share link, embed, or download link may ever reference.

Posters, thumbnails, animated previews and video exports are media_assets rows, carrying the poster, thumbnail and export kinds defined in Section 5.4.4. Renditions are the exception: they are tracked in the separate renditions table (Section 5.4.4), not as media_assets rows. The same two-bucket rule applies to all of them without exception: every one is a reelay-media-delivery object, produced only from a redaction_verified = true rendition (22.2.4) or from a source with no redaction regions at all, and none is ever written to reelay-media-restricted.

The one screenshot-specific consequence of the same rule: a screenshot_original — the raw captured frame, before beautification or redaction — is a restricted-bucket object (Section 5.4.4's media_assets_restricted_consistency constraint enforces this at the database level). Only its screenshot_edited derivative, which has had any redaction burned in, is delivery-bucket content.

Posters, thumbnails, and animated previews are access-controlled like playback, not treated as cosmetic metadata. A poster/thumbnail/preview is a rendering of the video's own content: if one were generated before redaction ran, or served from a permanent, unversioned, guessable URL, it would silently defeat the exact guarantee this section exists to provide — a viewer, a crawler that indexed the URL before a link was revoked, or anyone who saved the image URL could retrieve sensitive frames through the poster path even after the video itself was locked down or the link revoked. Two rules close this, and both are shown in the object-key layout above:

  1. A poster, thumbnail, or animated preview is generated only from a rendition that has already passed the redaction-verification gate in 22.2.4, or from a video with no redaction regions — never from the restricted-bucket source.
  2. Every poster/thumbnail/preview object key is scoped by the share link's playback_key_version (Section 5.4's share_links table). Revoking a share link or downgrading its visibility (Section 14.1.1) increments playback_key_version, which changes every subsequent key derived from it; the old, now-orphaned poster/thumbnail/preview objects are purged from the delivery bucket and from the CDN edge cache as part of that same revocation/downgrade operation, not on a delayed or best-effort schedule. A poster is therefore exactly as revocable as playback itself.

The restricted bucket's IAM policy is scoped to exactly two principals — the render worker's service-account role (routine, for rendering) and the audited internal restricted-media tool (22.2.3) — and denies everyone else, including other application services and deployment/CI credentials. The delivery bucket's IAM policy allows the CDN's origin-access identity to read and allows the render/export/poster-generation workers to write; it denies direct public listing and denies any principal other than the signed-URL issuance path (22.7.3) from producing a client-facing URL against it. There is no network path by which a viewer's playback request can reach the restricted bucket: the CDN's origin is configured to the delivery bucket exclusively, the restricted bucket has no public bucket policy, and the signed-URL signing function's bucket parameter is an enum of delivery only (22.7.3) — structurally incapable of targeting restricted.

22.2.3 Access to the unredacted original is audit-logged #

Every read of an object in the restricted bucket — whether by the render worker (routine, for rendering), by an internal support/admin tool (exceptional, for customer support investigations), or by any ad hoc operator action — writes a row to audit_events with event_type = 'restricted_media.accessed', the actor (a service-account identity for the render worker, or a human operator identity for admin tooling), the object key, the reason code, and the timestamp. Human-operator access requires a reason code selected from a fixed enum (customer_support_ticket, legal_hold, security_investigation, data_export_request) and is surfaced in the workspace's own audit log (Section 14) as a restricted_media.accessed entry visible to workspace owners and admins, so a workspace is never left unaware that an operator touched their unredacted content. There is no human-operator access path that skips this logging — the restricted bucket's IAM policy denies direct console/CLI access to individual operators; access is only possible through the audited internal tool, which is the sole principal (besides the render worker) with bucket read permission.

22.2.4 Verification step: a render cannot be marked shareable without a produced redacted rendition #

The render pipeline (Section 9) enforces a hard gate between "an EDL with redaction regions exists" and "the video is shareable." The gate is implemented as an explicit verification step at the end of the render job, not as an assumption that the job succeeded:

  1. The render worker executes the EDL, including all redaction_regions (static rects or keyframed tracks, Section 11) as burned-in blur/pixelation filters in the FFmpeg filter graph.
  2. On job completion, before the worker marks the rendition status = 'ready', it runs a post-render verification pass: it decodes a deterministic sample of frames from the produced rendition (not the source) at the timestamps covered by each redaction region, computes a perceptual hash / edge-density metric inside each region's bounding box, and asserts that the metric falls below the "high-frequency detail" threshold expected of a blurred region (the same threshold used to validate the redaction filter's own output in CI, so the check is calibrated against a known-good baseline, not an arbitrary heuristic).
  3. If a video has one or more redaction_regions rows with status = 'pending' or status = 'active', the render job's output rendition is tagged redaction_verified = false until every region's post-render check passes; only once all pass is it flipped to redaction_verified = true and the rendition transitions to status = 'ready'.
  4. If any region fails the check, the render job does not fail silently: it transitions to status = 'failed', writes renditions.failure_reason = 'redaction_verification_failed', enqueues an alert to the observability pipeline (Section 24) with severity critical, and — critically — does not produce a servable rendition at all. No partially-redacted rendition is ever written to the delivery bucket.

22.2.5 Fail-closed behavior #

The system defaults to blocking sharing whenever redaction status cannot be positively confirmed. Concretely:

  • A video's videos.share_gate_status is computed, not stored as an independent source of truth: it is blocked whenever the video has any redaction_regions row not yet reflected in a redaction_verified = true rendition covering the video's current EDL version.
  • POST /v1/share-links and PATCH /v1/share-links/{id} (making a link active) both re-check share_gate_status synchronously at request time — server-side, not client-side. If the status is blocked, the API returns 409 Conflict with error code redaction_pending.
  • The existing-share-link exception: per the iron rule in Section 21, hitting a cap never breaks an already-shared link. Redaction status is the one exception to "never breaks playback" in the opposite direction — if a new redaction region is added to a video that already has active share links (the video owner decides after the fact that a region needs blurring), the previously-shared rendition is immediately unpublished (its renditions.servable flag is flipped to false and the CDN cache is purged for that rendition's URLs) until a new redaction_verified = true rendition is produced. This is a deliberate asymmetry: playback continuity is guaranteed against plan/billing state, never against an unresolved redaction gap, because the alternative would mean knowingly serving a video the workspace itself has flagged as containing sensitive content.
  • A video with share_gate_status = blocked cannot be shared, embedded, or made public through any code path, including the public API (Section 7). This is enforced at the single authorization enforcement point (22.4), not per-endpoint, so no new endpoint can accidentally bypass it.

22.2.6 Ownership boundary #

The editor's redaction-drawing UI, region types (static rect vs. keyframed track), and the EDL schema for redaction_regions are owned by Section 11.7. The render worker's filter-graph construction from the EDL is owned by Section 9. This section (22.2) owns the security guarantee those two sections must satisfy: burn-in only, storage separation, audit logging, and the fail-closed verification gate.

22.3 Authentication and Session Security #

Section 6 owns the authentication mechanics (credential scheme, session token format, OAuth flow, MFA). This subsection covers the attack surface around those mechanics.

22.3.1 Credential stuffing defense #

  • Login attempts (POST /v1/auth/login) are rate-limited per IP (10 attempts / 5 minutes) and per account (5 failed attempts / 15 minutes, sliding window, tracked in Redis).
  • On the 5th consecutive failed attempt for an account, the account enters a soft lockout: further login attempts for that account require passing a CAPTCHA challenge in addition to correct credentials, for 15 minutes. This is a throttle, not a hard lockout — a hard account lockout is itself a denial-of-service vector against a targeted user, so genuine credential-holders are never fully blocked, only slowed and challenged.
  • Failed login attempts do not reveal whether the failure was due to an unknown email or a wrong password; the response is uniform: 401 Unauthorized, error code invalid_credentials.
  • Successful login from a new device/IP combination (no prior session from that IP range in the last 30 days) triggers an email notification to the account holder with device, approximate location (derived from IP, no client-side geolocation), and a "this wasn't me" link that revokes all sessions and forces a password reset.
  • Password policy: minimum 10 characters, no composition requirement — no mandatory mix of uppercase, lowercase, digits, or symbols — per NIST SP 800-63B and canonical alongside Section 6.1.2. Every submitted password is additionally checked against the top 100,000 breached-password list (k-anonymity range query against the Have I Been Pwned API, no plaintext password ever leaves the server) at registration and password-change time. Rejected breached passwords return 422 with error code password_compromised.

22.3.2 Rate limiting #

General API rate limiting is documented in Section 7.9. Auth-specific endpoints have tighter limits than the general API default because they are higher-value targets:

Endpoint Limit Scope
POST /v1/auth/login 10 / 5 min per IP
POST /v1/auth/login 5 / 15 min per account (triggers CAPTCHA challenge, not a hard block)
POST /v1/auth/signup 5 / hour per IP
POST /v1/auth/password-reset/request 3 / hour per email address requested
POST /v1/auth/mfa/verify 5 / 5 min per session (exceeding invalidates the in-progress login attempt)
POST /v1/collect (analytics ingestion) 120 / min per viewerToken

22.3.3 Session fixation #

  • A session token is issued only after successful authentication completes (including MFA if enabled) — never before. There is no pre-auth session identifier that becomes valid post-auth.
  • On every privilege-relevant transition — login, MFA completion, password change, OAuth link/unlink — the server issues a new session token and invalidates the prior one. A pre-authentication session cookie, if any exists from an anonymous visit, is never "upgraded" in place.
  • Password change invalidates all other active sessions for the account except the session that performed the change; the account holder can also explicitly "log out everywhere" from account settings, which truncates all rows in the session table for that user_id.

22.3.4 CSRF strategy #

The session cookie is used for browser-based dashboard/editor requests. CSRF defense is layered:

  1. SameSite=Lax on the session cookie (stated in Section 6) blocks cross-site requests from ever attaching the cookie on cross-origin POST/PUT/PATCH/DELETE navigations — this alone stops the majority of CSRF vectors.
  2. Double-submit CSRF token for all state-changing requests (POST, PUT, PATCH, DELETE) made from the web app: the server sets a csrf_token value (opaque, per-session, rotated every 24 hours) as a non-httpOnly cookie readable by the app's JS, and the app must echo it back in an X-CSRF-Token header on every mutating request. The server rejects any mutating request where the header value does not match the cookie value with 403 Forbidden, error code csrf_token_mismatch. This second layer exists because SameSite=Lax alone does not protect against same-site scripting or subdomain-takeover-adjacent scenarios and because some older browser/proxy combinations do not enforce SameSite reliably.
  3. Public API requests authenticated with a Bearer API key are not subject to CSRF checks — API keys are never stored in a cookie and are not automatically attached by a browser, so the CSRF threat model does not apply to them.
  4. GET requests never perform state changes (strict REST semantics enforced in code review and by the negative-authorization test suite, Section 25) and are therefore exempt from CSRF token checks by design, not by omission.

All cookies set by the web app:

Cookie HttpOnly Secure SameSite Notes
Session token Yes Yes Lax 30-day sliding expiry per Section 6
CSRF token No (must be JS-readable) Yes Lax Rotated every 24h
Anonymous viewerToken (analytics) No Yes None* First-party only, never sent to embed host page (Section 15)

* SameSite=None is required only for the viewerToken cookie set on reelay.app-hosted watch pages that may be reached via a top-level cross-site navigation (a link click from an email or Slack); it always carries Secure. The embeddable player itself never sets a cookie on the host page domain (Section 15's Shadow-DOM/no-host-cookie guarantee) — the viewerToken used inside an embedded player is scoped to reelay.app and delivered over the player's own first-party network requests to the collection endpoint, not as a host-page cookie.

No cookie is ever readable cross-origin; the API additionally sets Access-Control-Allow-Credentials: false on any endpoint that does not explicitly require cookie-based auth from a known origin.

22.4 Authorization #

22.4.1 Single enforcement point #

All authorization decisions — "can this actor perform this action on this resource" — are evaluated by exactly one function, authorize(actor, action, resource), canonically defined in Section 6.9. Section 6.9 owns the function's interface — the Actor.type shape, the Action vocabulary, and its behavior on denial — and this section does not restate or vary any part of that interface; it narrates the security properties the rest of Section 22 depends on and states why they hold.

The enforcement guarantee comes from throwing, not returning. authorize() throws a ForbiddenError on denial rather than returning a { allowed: false } value (Section 6.9). This is a deliberate, security-relevant choice, not a stylistic one: a thrown error unwinds the call stack and is caught by the centralized request-error handler, which maps it to 403 Forbidden (or 404 Not Found where existence itself is sensitive — 22.4.2) before any further handler code runs. A route handler cannot forget to check a thrown error's result, because there is no result to check — the call either returns normally (authorized) or the handler's remaining code never executes (denied). A { allowed: false } return value does not have this property: a handler that forgets the if (!result.allowed) check, or that is written against a different contract than the one actually wired in, silently proceeds as if authorization had succeeded. This is exactly why an earlier draft of this section — which defined its own, second authorize() returning { allowed, reason } — was a genuine security regression disguised as a second opinion: a handler written against the return-value contract, given the Section 6.9 implementation at runtime, would receive a thrown exception where it expected a return value, and depending on how that handler's surrounding code is structured, an uncaught or mishandled throw at the wrong layer can itself become a silent no-op of the intended denial. One throwing implementation, invoked identically everywhere, closes that gap entirely — which is why this document has exactly one canonical authorize(), in Section 6.9, and this section only narrates it.

No ad hoc role checks. No route handler in apps/api is permitted to implement its own comparison against a role or scope field; this is enforced by a lint rule that flags any direct comparison against role/scopes fields outside the Section 6.9 authorize() implementation and its unit tests. Every handler calls authorize() before any database mutation or read of workspace-scoped data:

// apps/api route handler — invocation pattern only; the authorize() interface itself
// (Actor.type, the Action vocabulary, throw behavior) is defined once, in Section 6.9.
await authorize(actor, 'video.edit', { workspaceId, videoId });
// Execution reaches here only if authorize() did not throw. No `if (!result.allowed)`
// branch exists anywhere in the codebase, because there is no boolean result to branch on.

IDOR-prevention argument. authorize() being the sole enforcement point is necessary but not sufficient for preventing insecure direct object reference — a workspace-scoped role check alone does not stop an authorized member in workspace A from supplying a resource ID that belongs to workspace B. 22.4.2 states the structural mechanism (workspace-scoped queries, not just a role check) that closes that gap; authorize() is the gate that mechanism sits behind.

Negative-test requirement. Because authorize() is the single point every handler depends on, a regression in authorize() itself, or a handler that fails to call it, is the single highest-leverage class of bug this document can guard against with an automated test. 22.4.3 states the mandatory negative-authorization test matrix every endpoint must carry in CI.

A third Actor.type variant: anonymous share-link visitors. Section 6.9's Actor.type includes a variant for anonymous callers arriving through a public or link-visibility share link: { type: 'share_viewer', viewerToken, videoId }. This is evaluated against its own allow-list logic scoped to the specific videoId the token was minted for (Section 14) — it is never evaluated against ROLE_CAPABILITIES['viewer'], the capability set for the authenticated workspace viewer role. The workspace viewer role and the anonymous share_viewer actor are different populations with different blast radii if conflated: a workspace viewer is a known, authenticated member with a persistent membership row (Section 6) who can see every video the workspace's folder-permission model (6.8.1) grants them across the whole workspace. A share_viewer is an anonymous, transient grant scoped to exactly one video via exactly one token, which may be password-protected, may expire, and may be revoked at any moment (Section 14). If a code path ever evaluated a share_viewer actor against the workspace viewer role's capability set — for example, by treating "has some form of viewer-level access" as a single code path — an anonymous link visitor would inherit the workspace viewer role's ability to browse the rest of the workspace's shared folders, which is exactly the shape of a privilege-escalation bug: possession of one link would leak visibility into content that link was never scoped to. Keeping the two Actor.type variants on structurally separate evaluation paths, per Section 6.9, is what prevents that.

22.4.2 IDOR prevention through workspace-scoped queries #

Insecure direct object reference (a request that supplies a resource ID belonging to a different workspace and receives it anyway) is prevented structurally, not just by a role check:

  • Every Drizzle query that fetches a workspace-owned entity (videos, folders, comments, share_links, brand_kits, etc.) includes workspace_id = :actorWorkspaceId in its WHERE clause as a non-optional query builder parameter — the shared repository layer's fetch functions (packages/db) do not expose a variant that omits it. A route handler cannot accidentally fetch by primary key alone.
  • When a resource ID does not resolve within the actor's workspace — whether because it belongs to another workspace or does not exist — the API returns 404 Not Found with error code video_not_found (or the resource-appropriate _not_found code), never 403 Forbidden. This denies an attacker the ability to distinguish "exists but I can't see it" from "doesn't exist," which would otherwise let them enumerate valid IDs across workspaces.
  • Public-facing resources (a video reachable through a public visibility share link) are the one exception: authorization there is based on possessing a valid, unexpired, non-revoked share token presented in the request (Section 14), not on workspace membership, and is still evaluated through the same authorize() function using the share_viewer actor type ({ type: 'share_viewer', viewerToken, videoId }, per Section 6.9 and 22.4.1) — never the workspace viewer role's code path, for the reason stated in 22.4.1.
  • Nested resources (a comment on a video, a redaction region on an EDL) inherit their authorization from the parent video's workspace, re-validated on every request rather than cached — a comment's video_id is joined back to videos.workspace_id on every read/write, so moving a video between folders or workspaces (not currently a supported operation, but guarded regardless) cannot strand a stale authorization decision.

22.4.3 Negative authorization testing requirement #

Every API endpoint must have at least one automated test asserting that an actor lacking the required role or workspace membership receives a 403/404 denial, not merely that an authorized actor succeeds. This is a hard merge requirement, not a guideline: CI fails the build if an endpoint file is added or modified without a corresponding negative-authorization test in the same pull request (enforced by the endpoint-coverage check described in Section 25). At minimum, the negative test matrix for every endpoint covers: (1) an authenticated actor from a different workspace, (2) an authenticated actor from the same workspace with an insufficient role, (3) an unauthenticated request where authentication is required, and (4) — for viewer-facing endpoints — an expired or revoked share token.

22.5 Input Validation and Output Encoding #

22.5.1 The shared Zod boundary #

Every API route validates its request body, query parameters, and path parameters against a Zod schema defined once in packages/shared and imported by both apps/api (server-side enforcement, authoritative) and apps/web/apps/desktop (client-side, UX-only pre-validation). No handler reads req.body or req.query directly; the Fastify route definition wraps every handler with a validate(schema) pre-handler that parses and replaces the request payload with the parsed, type-narrowed result, rejecting with 422 Unprocessable Entity and the error envelope's details array populated per-field (Section 7.6) on any schema violation. This guarantees the server never trusts a client-side check as its only line of defense, satisfying the general principle stated across this spec that client-side validation is UX only.

22.5.2 XSS prevention in comments and custom CTA HTML #

Comments (Section 17) and custom CTA blocks (Section 17) are the two places where a workspace member supplies content that is later rendered to other viewers' browsers, and are therefore the product's primary stored-XSS surface.

  • Comments are stored and rendered as plain text with a constrained Markdown subset (bold, italic, links, inline code, line breaks) — never raw HTML. The subset is parsed server-side with markdown-it configured with html: false (raw HTML passthrough disabled entirely) and a link renderer override that force-adds rel="noopener noreferrer nofollow ugc" and validates the link scheme is http: or https: (rejecting javascript:, data:, vbscript:). The parsed output is rendered client-side through React's default JSX text/attribute escaping — dangerouslySetInnerHTML is never used for comment content.
  • Custom CTA HTML (a Business-plan feature letting a workspace embed a custom call-to-action block, e.g. a Calendly widget snippet) is the one place raw HTML is genuinely required. It is sanitized server-side on save with DOMPurify (running server-side via jsdom) using an explicit allowlist rather than DOMPurify's permissive default:
    const CTA_HTML_ALLOWED_TAGS = [
      'a', 'p', 'span', 'div', 'strong', 'em', 'b', 'i', 'br', 'ul', 'ol', 'li',
      'button', 'img', 'iframe', // iframe allowed ONLY for an allowlisted embed-host set, see below
    ];
    const CTA_HTML_ALLOWED_ATTR = [
      'href', 'src', 'alt', 'class', 'style', 'target', 'rel', 'data-*',
    ];
    const CTA_IFRAME_SRC_ALLOWLIST = [
      /^https:\/\/calendly\.com\//, /^https:\/\/[a-z0-9-]+\.typeform\.com\//,
      /^https:\/\/meetings\.hubspot\.com\//,
    ];
    <script>, <style>, <link>, <object>, <embed>, <form>, all on* event-handler attributes, and style values containing url(/expression(/javascript: are stripped unconditionally. Any <iframe src> not matching CTA_IFRAME_SRC_ALLOWLIST has the iframe element removed entirely (not just the src attribute) rather than left inert, to avoid leaving a confusing empty frame. Sanitization runs at save time (PUT /v1/videos/{id}/ctas/{ctaId}) so the stored value is already clean; it is sanitized a second time at render time in the player (Section 15) as defense in depth, since the CTA HTML is one of the few places the zero-framework embed player renders content it did not generate itself.

22.5.3 SQL injection prevention through the ORM #

All database access goes through Drizzle ORM's parameterized query builder (Section 3); no route handler, job handler, or script constructs SQL via string concatenation or template literals with interpolated user input. The one sanctioned exception is the analytics rollup jobs (Section 16), which use Drizzle's tagged-template sql helper for aggregate queries against video_view_events — even there, every interpolated value is passed through the tagged template's parameter binding (sql\... WHERE workspace_id = ${workspaceId}`), never raw string interpolation. This is enforced by an ESLint rule (no-restricted-syntaxtargeting rawpgquery calls and non-tagged-template string building) that fails CI on direct driver access outsidepackages/db`'s connection-pool module.

22.5.4 SSRF prevention on user-supplied URLs #

Three features accept a user-supplied URL that the server itself will fetch: webhook endpoint registration (Section 20), custom-domain CNAME verification (Section 20), and image import for brand-kit logos/backgrounds (Section 18). Each is a potential SSRF vector against internal infrastructure (cloud metadata endpoints, internal service ports, the database's own network) and is defended identically:

  1. Scheme allowlist: only https: is accepted (plain http: rejected) except for local-development environments, where http: to localhost/127.0.0.1 is permitted behind an environment flag that must be false in production.
  2. DNS resolution: the URL's hostname is resolved via DNS to its candidate IP address(es).
  3. Address normalization, before the denylist check runs: each resolved address is normalized using a vetted IP-address parsing library (not a hand-written regex) before it is compared against the denylist in step 4. Normalization unwraps IPv4-mapped IPv6 addresses (::ffff:a.b.c.da.b.c.d) and canonicalizes non-dotted-quad literal encodings of an IPv4 address — decimal (2130706433), octal (017700000001), hexadecimal (0x7f000001), and partial/mixed forms — to standard dotted-quad notation. This step exists because a denylist that only pattern-matches standard dotted-quad text can be bypassed by encoding the exact same address (for example, 169.254.169.254, the cloud metadata address) in one of these alternate, equally valid textual forms; normalizing to one canonical representation before the comparison closes that gap rather than trying to enumerate every alternate encoding in the denylist pattern itself.
  4. IP denylist check before connect: every normalized, resolved IP address is checked against a denylist of private/reserved ranges before the outbound request is made:
    127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16 (link-local,
    including 169.254.169.254 cloud metadata), 100.64.0.0/10 (carrier-grade NAT), ::1/128,
    fc00::/7, fe80::/10
    A resolved address in any of these ranges causes the request to be rejected before any socket is opened, with error code url_not_allowed.
  5. DNS-rebinding defense: the denylist check happens against the IP actually used for the TCP connection, not just the IP returned by an initial resolve() call that could differ by the time the HTTP client connects (the classic time-of-check/time-of-use gap). The outbound HTTP client is configured with a custom DNS lookup function that pins the resolved (and normalized) IP for the connection, so the IP checked is the IP dialed — this closes the window an attacker would use to pass validation against a benign IP on the first lookup and then rebind the DNS record to an internal IP for the actual connection.
  6. No redirect following for webhook and image-import fetches by default; if a fetch returns a 3xx, the redirect target is itself re-validated through steps 1–5 before being followed, and a maximum of 3 redirect hops is enforced.
  7. Response size and timeout caps: webhook responses read are capped at 64 KB; image imports are capped at 25 MB and a 10-second fetch timeout, matching the upload validation limits in 22.5.5.
  8. Custom-domain CNAME verification additionally requires a DNS TXT record challenge (a random token the customer places in their own DNS) before the CNAME is activated, so domain ownership is proven independent of the HTTP fetch path entirely.

22.5.5 File-upload validation including magic-byte checking #

Two upload surfaces accept binary files from a client: recording chunk upload (Section 9) and image asset upload (screenshots re-upload, brand-kit logo, background images). Both are validated identically before being accepted into permanent storage:

  1. Extension and declared MIME type are never trusted alone. The uploaded bytes are inspected server-side for a magic-byte / file-signature match against the expected format:

    Accepted type Expected magic bytes (hex, first bytes)
    video/mp4 66 74 79 70 at offset 4 (ftyp box)
    video/webm 1A 45 DF A3 (EBML header)
    image/png 89 50 4E 47 0D 0A 1A 0A
    image/jpeg FF D8 FF
    image/webp 52 49 46 46 ... 57 45 42 50 (RIFF....WEBP)
    image/svg+xml rejected outright — SVG can embed script and is never accepted as a brand-kit asset

    A mismatch between the declared Content-Type, the file extension, and the detected magic bytes is rejected with 422, error code file_type_mismatch, before the object is written to permanent storage (validation runs against the first uploaded chunk/part, not after full assembly, so a rejected file never completes a multipart upload).

  2. Size limits: recording uploads are capped by plan (Section 21's duration/storage caps translate to an effective size cap enforced during multipart assembly); image assets are capped at 25 MB raw, with brand-kit logos additionally capped at 4096×4096 px after decode.

  3. Re-encoding, not passthrough, for image assets. Uploaded images are decoded and re-encoded through the server-side image pipeline (strips EXIF metadata, strips any embedded color profile exploits, normalizes to the pipeline's own encoder) rather than stored byte-for-byte — this neutralizes polyglot-file attacks (a file that is simultaneously a valid image and a valid script/archive by format ambiguity) because only the decoded pixel data survives.

  4. Video uploads are never re-encoded on ingest (re-encoding a recording losslessly at upload time is wasteful and the transcode pipeline already re-encodes downstream); instead, ffprobe (Section 9) is run against every uploaded video as a structural validation pass — a file that fails to probe as a valid container is rejected and never reaches the transcode queue, which closes the same "polyglot file" risk for video without a redundant re-encode.

  5. Antivirus/malware scanning of the assembled upload is covered in 22.9.

22.6 Secrets Management #

  • Where secrets live: all runtime secrets (database credentials, Redis credentials, Mux API tokens, Stripe secret key, OAuth client secrets, SMTP/email-provider credentials, the session-JWT signing key, the CSRF-token signing key, the webhook-signing keys issued to customers) are stored in the deployment platform's secret manager (Section 26) and injected into the runtime as environment variables at process start. No secret is ever committed to the repository, embedded in a Docker image layer, or stored in the database in plaintext.
  • Rotation policy: the session-JWT signing key and CSRF-token signing key rotate every 90 days on a schedule, with dual-key acceptance (old + new) for a 24-hour overlap window so in-flight sessions are not invalidated mid-rotation. Database and Redis credentials rotate every 180 days or immediately upon suspected compromise. Third-party API credentials (Mux, Stripe, OAuth client secrets) rotate on the vendor's own schedule or upon suspected compromise, never on a fixed internal cadence the vendor doesn't support. Rotation events are themselves audit-logged (audit_events, event_type = 'secret.rotated', no secret value in the log).
  • What is never logged: request/response logging middleware (Section 24) redacts the Authorization header, Cookie header, any field named or matching /password|token|secret|apiKey|ssn|cardNumber/i in request bodies, and the full value of any sk_live_... API key (only the stored display prefix, per Section 6, is ever logged). Structured log output passes through a redaction transform before being shipped to the log aggregator (Section 24); this is tested by a CI check that feeds known-sensitive payloads through the logger and asserts the redaction markers appear in place of the values.
  • API-key hashing scheme: public API keys (sk_live_..., Business plan only) are hashed at rest with SHA-256 before storage, as established in Section 6; only the first 8 characters after the sk_live_ prefix are retained in plaintext for display purposes (e.g. sk_live_a1b2c3d4********). Verification on each API request hashes the presented key and looks up the hash, which is an O(1) indexed lookup rather than a per-key comparison loop — this is why API keys use SHA-256 (fast, suitable for a high-entropy random token looked up by exact hash) rather than Argon2id (deliberately slow, appropriate for low-entropy user passwords per Section 6, but would make every API request pay an intentional latency cost).
  • Secret scanning: CI runs a pre-merge secret-scanning check (pattern-matching for known secret formats: AWS keys, Stripe keys, generic high-entropy strings assigned to variables named like secrets) against every diff; a match blocks the merge until remediated or explicitly allowlisted by a security-designated reviewer for a documented false positive.

22.7 Encryption #

22.7.1 TLS everywhere #

  • All traffic — marketing site, dashboard, API, watch pages, embed player asset delivery, webhook deliveries to customer endpoints, internal service-to-service calls that cross a network boundary — is TLS-encrypted. Minimum TLS version 1.2; TLS 1.3 is preferred and negotiated by default where the client supports it. TLS 1.0 and 1.1 are disabled at the load balancer/CDN edge.
  • HSTS is enabled on all reelay.app responses: Strict-Transport-Security: max-age=63072000; includeSubDomains; preload, and the apex domain is submitted to the HSTS preload list.
  • Customer custom domains (Business plan, Section 20) are provisioned with an auto-renewing TLS certificate (ACME/Let's Encrypt or the CDN provider's managed-certificate equivalent) as part of the domain-verification flow; a custom domain is never served over plain HTTP even transiently — the verification challenge itself uses DNS TXT (22.5.4), not an HTTP-01 challenge that would require serving unencrypted content first.

22.7.2 Encryption at rest #

  • Object storage (originals, renditions, exports, screenshots, brand-kit assets): server-side encryption at rest using the storage provider's AES-256 encryption, enabled unconditionally at the bucket level for both the restricted and delivery buckets described in 22.2.2.
  • PostgreSQL 17: encryption at rest via the managed database provider's volume-level AES-256 encryption. In addition to volume encryption, a small set of especially sensitive columns use application-level encryption before they ever reach the database: mfa_credentials.totp_secret and oauth_accounts.refresh_token are encrypted with AES-256-GCM using a key from the secret manager (22.6), so a database backup or read-replica compromise alone does not expose these values in plaintext even though volume encryption is already in place — this is deliberate defense in depth for the two column classes whose plaintext exposure would allow direct account takeover.
  • Redis: encryption at rest is enabled on the managed Redis provider's persistence snapshots; Redis in this architecture holds session cache, rate-limit counters, and queue payloads, none of which are the long-term system of record (Postgres is, per Section 3), so Redis persistence itself is best-effort, not a durability guarantee — but what is persisted is still encrypted.
  • Backups: database and object-storage backups inherit the same at-rest encryption as the primary store; backup retention and restoration procedures are owned by Section 24.

22.7.3 The signed-URL model #

Viewer-facing media access never uses long-lived, guessable, or permanently valid URLs:

  • Playback: the watch page and embed player request a Mux signed playback URL/token from the API (GET /v1/videos/{id}/playback-token, itself gated by authorize() per 22.4 against the share link's visibility rules from Section 14). The issued token has a 6-hour TTL and is scoped to the specific Mux playback ID; the player re-requests a fresh token before expiry during long viewing sessions rather than letting playback fail mid-video.
  • Downloads (where disable-download is not set on the share link, Section 14): a presigned object-storage URL is issued with a 15-minute TTL, scoped to the specific rendition/export object key, generated only after authorize() confirms the requesting actor may download this specific video.
  • Screenshot/export delivery: same presigned-URL pattern, 15-minute TTL.
  • Signed URLs are never logged in full (22.6) and are never persisted anywhere beyond the issuing request/response — a share link or embed code never contains a signed URL directly, only a stable video/share reference that triggers a fresh signed-URL issuance on each playback session.
  • No signed-URL generation path exists for objects under the restricted bucket (22.2.2) — the signing function's bucket parameter is an enum of delivery only, structurally incapable of targeting restricted.

22.8 Content Security #

22.8.1 Content Security Policy for the web app #

apps/web (marketing, dashboard, editor, watch page) serves a CSP via response header (not a <meta> tag, so it cannot be stripped by injected HTML and applies to the full response including non-HTML resources):

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'wasm-unsafe-eval' https://js.stripe.com;
  style-src 'self' 'unsafe-inline';
  img-src 'self' data: https://image.mux.com https://*.reelay-cdn.app;
  media-src 'self' https://stream.mux.com blob:;
  connect-src 'self' https://api.reelay.app https://*.mux.com wss://api.reelay.app;
  frame-src https://js.stripe.com https://calendly.com https://*.typeform.com https://meetings.hubspot.com;
  frame-ancestors 'self';
  object-src 'none';
  base-uri 'self';
  form-action 'self';
  upgrade-insecure-requests;

Notes on specific directives: 'unsafe-inline' on style-src is required by Tailwind's runtime class injection and CSS-in-JS used by packages/ui's component library; this is an accepted, scoped exception — it does not extend to script-src, where no 'unsafe-inline' or 'unsafe-eval' is permitted (the one narrow exception, 'wasm-unsafe-eval', is required for the client-side video processing WASM module used in the editor's local preview scrubbing, Section 11, and does not permit arbitrary inline script execution). frame-src is scoped to the exact third-party embed origins allowlisted for custom CTAs (22.5.2) plus Stripe's payment element. The CSP is validated in CI by a test that renders each app route and asserts zero CSP violation reports are emitted (using a report-only shadow policy in the test harness that mirrors the enforced policy).

A separate, stricter CSP applies to the watch page specifically, since it is the page most likely to be reached by an untrusted/unauthenticated visitor via a shared link: it omits 'wasm-unsafe-eval' entirely (the editor's WASM module is never loaded there) and its frame-src includes only the CTA allowlist, not Stripe.

22.8.2 Constraints the embeddable player satisfies on arbitrary host pages #

The embeddable player (owned by Section 15) is loaded on host pages Reelay does not control and cannot apply a CSP to. Section 22 imposes the security constraints that Section 15's implementation must satisfy given that reality:

  • The player script itself sets no cookies on the host page's own domain (stated in Section 15's player budget as a performance requirement; restated here as a security requirement — a host-domain cookie set by embedded third-party JS is a supply-chain risk the host site owner has not consented to).
  • All player styling is contained within a closed Shadow DOM root, so the player cannot be restyled or have its DOM manipulated by host-page CSS/JS in a way that could be used for clickjacking-adjacent UI redress against the player's own controls (e.g., making the player's "report" button invisible while remapping clicks).
  • The player does not eval, does not inject <script> tags, and does not fetch or execute remotely-supplied JavaScript beyond its own versioned bundle — this is enforced by the player's own build process (no dynamic import() of non-bundled URLs) and verified by the size-limit/dependency CI job referenced in Section 15.
  • The player's network requests (playback, analytics collection) go only to reelay.app and *.mux.com origins — it never proxies arbitrary host-page-supplied URLs, closing off the embed itself as an SSRF or open-redirect vector on behalf of a malicious host page.
  • Because the host page is untrusted infrastructure from Reelay's perspective, the player treats postMessage communication with the host page (used for responsive resizing and optional host-page API control per Section 15) with strict origin and message-shape validation — every incoming postMessage is checked against an expected message schema before being acted on, and the player never executes a string received via postMessage as code.
  • Domain restriction is a loader-script-mode-only guarantee. As stated in the threat model (22.1.4), an iframe cannot attest to its own embedder's origin, so the player never treats an iframe's own request Origin header as evidence of the host page's domain. Where a share link configures a domain allowlist (Section 14), the player enforces it only via the loader-script handshake (a server-issued, timing-bound nonce exchanged over postMessage with the host page's own script, validated per the strict origin/schema check above); in iframe-fallback mode the player refuses to play a domain-restricted video and shows the explanatory message specified in 22.1.4, rather than allowing playback without the restriction it was configured to enforce.

22.8.3 Frame-ancestors policy and clickjacking defense #

  • The dashboard and editor (authenticated surfaces) set frame-ancestors 'self' (as shown in the CSP above) and additionally X-Frame-Options: SAMEORIGIN for legacy user-agent compatibility — neither the dashboard nor the editor is ever intended to be iframed by any origin, including a customer's own site, so this is unconditional.
  • The watch page's frame-ancestors policy is conditional on the share link's configuration: a share link with no domain allowlist (Section 14) sets frame-ancestors * on the watch page's direct URL (it is designed to be embeddable/linkable broadly) but the embeddable player is the sanctioned embedding mechanism — direct-iframing of the full watch page by a third party is discouraged through documentation but not technically blocked, since the same authorization rules (Section 14) apply regardless of framing context. A share link with a domain allowlist configured sets frame-ancestors to exactly that allowlist, both on direct navigation and on any framing attempt, so a domain-restricted video cannot be framed from outside its allowlist even if the raw watch-page URL leaks.
  • Interactive elements that perform a sensitive action (deleting a video, changing share-link visibility, revoking API keys) additionally use a X-Frame-Options: DENY-equivalent posture on their containing dashboard routes and require the CSRF token described in 22.3.4 for the mutating request itself, so even a successful clickjacking frame could not complete a state-changing action without also forging a valid CSRF token, which framing alone does not provide.

22.9 Abuse Prevention #

  • Malware scanning posture for uploads: every assembled upload (completed multipart recording, completed image asset) is scanned by an antivirus engine (ClamAV, run as a step in the ingest job queue, Section 9) before the object transitions out of a quarantine storage prefix into its normal restricted/delivery path. A positive detection sets media_assets.scan_status = 'infected', the object is deleted from the quarantine prefix (not moved), the owning video is force-transitioned to a blocked state that cannot be shared or played, and the workspace owner/admin is notified by email. This scan is necessarily probabilistic against novel threats — it is a baseline hygiene control given that the accepted upload surface (video containers, standard image formats) is already narrow per 22.5.5, not the primary defense.
  • Content-report flow for public videos: any viewer of a public-visibility video (Section 14), authenticated or not, can submit a report via a persistent "Report" control in the player and watch page (also satisfying the abuse-reporting expectation that comes with hosting public content on the product's own domain). A report creates a content_reports row (video_id, reporter_viewer_token or reporter_user_id, reason enum: spam, harassment, illegal_content, impersonation, other, free-text details capped at 1000 characters) and is queued for review by the platform trust-and-safety process. Three or more independent reports on the same video within 24 hours auto-transitions the video's public visibility to workspace-only (not deleted) pending manual review, with notification to the workspace owner explaining the action and the appeal path.
  • Phishing defense: because Reelay hosts user-generated content on its own domain (*.reelay.app watch pages, reelay.app-hosted embeds), the product is a potential vector for phishing content that borrows the domain's reputation. Defenses: (1) the content-report flow above; (2) automated scanning of newly-public videos' generated thumbnails/titles/AI-generated summaries (Section 12) against known phishing-brand keyword heuristics (impersonation of major brand names combined with credential-request language in captions/transcripts), flagging matches for manual review rather than auto-blocking, to bound false positives; (3) rate limits on how quickly a newly created, unverified-email account can set videos to public visibility (Section 22.10's email-verification-before-public-sharing requirement is the primary control here); (4) outbound links in custom CTAs are scoped to the allowlist in 22.5.2, so the phishing surface through CTA content is structurally limited to a small set of vetted embed hosts, not arbitrary link injection.
  • Takedown process: a confirmed violation (via the report flow, automated flagging, or a legal request such as a DMCA notice) results in the video being set to status = 'taken_down': all renditions become unservable immediately (mirroring the mechanism in 22.2.5), all active share links for the video are marked revoked, and the workspace owner receives a notification with the reason category and, where legally required (e.g., DMCA), a copy of the underlying notice and the documented counter-notice process. Takedown state is distinct from deletion (Section 19) — content and metadata are retained for the legal hold / appeal window (30 days) before the normal retention policy resumes governing the video's lifecycle.

22.10 Privacy and Compliance #

22.10.1 GDPR lawful basis per processing purpose #

Processing purpose Data involved Lawful basis (GDPR Art. 6)
Account creation and authentication Email, hashed password, name Contract (necessary to provide the service)
Recording, storing, and serving video content the customer creates Video/audio content, transcripts Contract
Billing Payment method (via Stripe), invoice history Contract / legal obligation
Anonymous/pseudonymous viewer analytics (default) Rotating viewerToken, watch events, no name/email Legitimate interest (product analytics, not identifying)
Named per-viewer analytics (opt-in, link-owner enabled) Viewer email/name if submitted, tied to watch behavior Consent (viewer-facing notice required, see 22.10.7)
Transactional email (verification, password reset, share notifications) Email address Contract / legitimate interest
Marketing email Email address Consent (explicit opt-in, separate from transactional)
Security monitoring and abuse prevention (22.9, 22.3) IP address, device/session metadata Legitimate interest
Malware/content scanning Uploaded file content Legitimate interest / legal obligation (content hosting duty of care)

22.10.2 Data-processing inventory #

Category Examples Retention Cross-border transfer
Account data users, oauth_accounts, mfa_credentials Life of account + 30 days post-deletion (Section 19) Processed in primary region; sub-processors listed in 22.10.3
Workspace/content data videos, recordings, media_assets, transcripts, comments Per plan retention (Section 19, Section 21) Object storage + Mux, see 22.10.3
Analytics data video_view_events, video_view_daily, video_engagement_curve 25 months rolling (aligned with standard web-analytics norms; stated explicitly since Section 19 does not separately cover analytics-table retention) Primary region only
Billing data subscriptions, invoices, usage_counters Life of account + 7 years (tax/accounting legal requirement, supersedes the general 30-day post-deletion purge for this category specifically) Stripe (sub-processor)
Support/communication email_log, support tickets (external helpdesk tool) 24 months External helpdesk sub-processor

22.10.3 Sub-processor list #

Sub-processor Purpose Data categories
Cloud infrastructure provider (compute, PostgreSQL, Redis hosting) Application hosting, system of record All categories
S3-compatible object storage provider Media asset storage Video/image content
Mux Video transcoding and adaptive-bitrate streaming Video content, playback metadata
CDN provider Content delivery Video/image content (cached), no persistent storage of viewer PII
Stripe Payment processing, billing Payment method, billing address, invoice history
Transactional email provider Verification, password reset, share/notification email Email address, email content
Transcription vendor (Section 12) Speech-to-text for captions/transcripts Audio content
Error tracking / observability provider (Section 24) Application error monitoring Request metadata, scrubbed of PII per 22.6's redaction rules

An up-to-date version of this table is published at a customer-facing subprocessors page, and workspace owners/admins on the Business plan receive email notification at least 30 days before a new sub-processor is added, with the ability to object per the DPA (22.10.4).

22.10.4 Data residency posture and DPA availability #

At launch, all customer data is processed and stored in a single primary region (US). Region selection (e.g., an EU-resident data option) is not offered at launch; this is stated honestly as a current limitation, not a permanent architectural constraint — the schema's workspace_id-scoped data model does not preclude a future per-workspace region assignment. A standard Data Processing Addendum (DPA), incorporating Standard Contractual Clauses for any sub-processor transfer outside the customer's jurisdiction, is available to any workspace on request and is auto-attached to the Terms of Service for Business-plan workspaces at signup.

22.10.5 CCPA/CPRA rights #

California residents (and any workspace choosing to extend the same rights globally, which is the product's default posture rather than geofencing rights by request origin) can exercise, via a self-service "Privacy Requests" page plus an email fallback:

Right Mechanism
Right to know / access Self-service data export (Section 19's export mechanism) covering account data, workspace content the user owns, and analytics tied to their identity
Right to delete Account deletion flow (Section 19); workspace content deletion is workspace-owner-initiated separately, since an individual user's deletion request cannot unilaterally delete workspace-shared content owned collectively
Right to correct Account settings self-service for account data; support request for data the user cannot self-edit
Right to opt out of sale/sharing Reelay does not sell personal information as defined under CCPA/CPRA; this is stated as a factual posture, not merely a toggle — no data broker relationships exist
Right to limit use of sensitive personal information N/A in practice — the product does not collect CCPA-defined "sensitive personal information" categories (SSN, precise geolocation, biometric identifiers) as a matter of product design; video content itself is not a CCPA-enumerated sensitive category

Requests are logged (deletion_requests table doubles for CCPA deletion requests; a parallel privacy_requests table covers access/correction) with a 45-day response SLA per CCPA's statutory timeline, extendable once by 45 additional days with notice for complex requests.

22.10.6 Do Not Track and Global Privacy Control #

The analytics collection endpoint (POST /v1/collect, Section 16) checks both the legacy DNT: 1 request header and the Sec-GPC: 1 header on every request. When either is present:

  • The viewerToken is not persisted beyond the single page-load session (no 30-day rotating cookie is set; a session-only, non-persistent identifier is used for the duration of that single playback only, sufficient for the drop-off curve computation within that session but not for cross-session tracking).
  • Named per-viewer identification (22.10.1's consent-basis processing) is disabled for that viewer regardless of the link owner's settings — GPC/DNT overrides link-level configuration, since it is a user-expressed signal that takes precedence.
  • Aggregate, non-identifying view-count and engagement-curve rollups still receive the event (this is the legitimate-interest-basis aggregate analytics from 22.10.1, which GPC/DNT does not disable, consistent with GPC's specification targeting "sale/sharing" and cross-context tracking rather than first-party aggregate counting).
Cookie Type Set on Consent required?
Session token (22.3.5) Strictly necessary Dashboard/editor login No (strictly necessary exemption under ePrivacy/GDPR)
CSRF token (22.3.5) Strictly necessary Dashboard/editor login No
viewerToken (Section 16) Analytics/functional Watch page, first visit Regionally qualified — see below; never gated behind a blocking consent banner on the watch page regardless of region
Marketing site analytics (if a workspace enables a third-party pixel via Section 20 integrations) Third-party/marketing Marketing pages only, never the watch page or player Yes — explicit consent banner gates loading, following the standard "no third-party script fires before consent" pattern

Regional consent posture for the viewerToken is stated as qualified guidance, not a flat global claim, because the strength of the "legitimate interest, no prior consent needed" argument genuinely differs by jurisdiction:

  • US (CCPA/CPRA) and most non-EU/UK jurisdictions: the pseudonymous, first-party, non-sold viewerToken is treated as not requiring an opt-in consent gate; CCPA's opt-out-of-sale model applies (22.10.5), and Reelay does not sell personal information, so no consent banner is required on the watch page.
  • EU/UK (ePrivacy Directive / UK PECR): ePrivacy's "strictly necessary" cookie exemption is narrower than GDPR's legitimate-interest lawful basis — regulators in several EU member states and the UK ICO have taken the position that first-party analytics cookies, even pseudonymous and non-advertising ones, fall outside the strictly-necessary exemption and are subject to ePrivacy's consent requirement, independent of whether GDPR Article 6 legitimate interest would separately justify the underlying processing. Reelay's posture for EU/UK-detected viewers (by IP-derived region, consistent with 22.10.6's approach) is therefore more conservative than the US posture: the viewerToken is still set (a blocking cookie banner on the watch page is avoided, for the reasons stated below), but for EU/UK-detected viewers it is treated as a session-only, non-persistent identifier unless the workspace has separately obtained the viewer's consent through the named per-viewer opt-in path (22.10.1) — the same reduced-persistence behavior already specified for DNT/GPC signals in 22.10.6, applied here proactively by region rather than only reactively by signal.
  • This regional qualification is reviewed against current regulatory guidance at least annually (mirroring Section 22.11's readiness-posture cadence), since first-party-analytics consent guidance in the EU/UK is an area of ongoing regulatory interpretation, not settled law with a single fixed answer.

The web app implements a cookie consent banner on the marketing site with Accept/Reject/Manage options; rejecting blocks any consent-gated category from loading. The watch page and embed player never present a blocking cookie consent banner to a viewer, in any region — the player's own no-host-cookie guarantee (22.8.2) plus the reduced-persistence handling above for EU/UK-detected viewers is the product's answer to the region-specific consent question, avoiding the significant UX harm a blocking cookie banner would cause on a page meant to be embeddable and frictionless.

Non-blocking disclosure requirement. Independent of the consent-banner question, the watch page carries a persistent, non-blocking disclosure of viewer tracking: a small, always-visible "Viewing activity on this page may be recorded — Privacy" text/link in the watch page's footer (and, for the embeddable player, in the player's overflow/settings menu, since the player budget constraints in Section 15 do not allow for persistent on-canvas chrome dedicated to this) linking to a plain-language privacy explanation. This is a disclosure, not a consent gate — it does not block or delay playback and requires no viewer action — and it is shown regardless of region, so the default cross-session viewerToken behavior (30-day rotation, Section 16) is never something a viewer could only discover by reading the full privacy policy. This satisfies the transparency half of "consent or transparency" for jurisdictions where the viewerToken's pseudonymous default does not itself require a consent gate (the US bullet above), and complements the EU/UK-specific handling for jurisdictions where it does.

A screen recording frequently captures more than the recorder's own content: a video call participant's face/voice, another person's name visible in a chat window, a colleague's voice in a narrated walkthrough. Reelay's stated position: the recording user (the workspace member who initiates a capture) is the data controller for the content of their own recordings and is responsible for obtaining any consent required by their jurisdiction and organizational policy before recording third parties. Reelay acts as a processor of that content, not the controller.

In-product notice: the desktop and browser recorder both display a persistent, non-dismissable-until- acknowledged notice on first use per device ("You're responsible for getting consent from anyone included in your recordings, including their voice, face, or on-screen content. Learn more") linking to a plain-language guidance page. This acknowledgment is logged (users.recording_consent_ack_at) and re-surfaced if the product's guidance materially changes. This in-product notice is a UX/legal mitigation, not a substitute for the redaction tooling in Section 11.7 — the product additionally markets redaction specifically as the remedy for third-party content that should not have been captured or should not be distributed.

22.10.9 Retention alignment with Section 19 #

All the retention figures stated above (analytics 25 months, billing 7 years, support 24 months) are additive to, and do not override, the workspace/plan-driven video-content retention policy owned by Section 19 (90 days inactive on Free, 2 years on Pro, unlimited on Business) and Section 21's downgrade/overage handling. Where a conflict could arise — for example, a workspace on Free plan with deleted_at set on a video whose analytics rows are still within the 25-month analytics retention window — the video-content deletion in Section 19 cascades to delete the associated video_view_events/video_view_daily/video_engagement_curve rows for that specific video immediately upon hard-delete, rather than waiting out the general analytics retention window; the 25-month figure governs analytics for videos that still exist, not orphaned analytics for deleted ones.

22.11 SOC 2 Readiness Posture #

Stated honestly, distinguishing what is operationally true at launch from what is deferred:

In place at launch:

  • Documented access control policy and the single-enforcement-point authorization model (22.4).
  • Encryption at rest and in transit (22.7).
  • Audit logging for share-link/permission changes (Section 14) and restricted-media access (22.2.3).
  • Secrets management with rotation policy (22.6).
  • Vulnerability/dependency scanning in CI (22.12).
  • A written incident-response runbook with defined severity levels (22.12).
  • Background checks and confidentiality agreements for employees with production data access (organizational control, not a code artifact, but a prerequisite for any SOC 2 posture).
  • Vendor/sub-processor risk assessment for the sub-processors listed in 22.10.3.

Deferred, with a stated path:

  • SOC 2 Type I report: targeted for completion in the first two quarters post-launch, once the above controls have a documented evidence trail. Type I attests controls are suitably designed at a point in time and is achievable relatively quickly once the controls above are operating.
  • SOC 2 Type II report: requires the Type I controls to be observed operating effectively over a 6–12 month window; targeted for roughly 12–18 months post-launch. This cannot be accelerated because it is fundamentally a period-of-observation requirement, not an implementation gap.
  • Third-party penetration test: scheduled for the first full quarter post-launch (stated in 22.1.3); launch itself proceeds on internal review plus the checklist in 22.13.
  • Formal business continuity / disaster recovery test (tabletop and live failover): Section 24 owns the operational DR posture; a formal, documented DR test cadence begins post-launch once production traffic patterns are established enough to make a test meaningful.
  • Employee security awareness training program with tracked completion: informal onboarding coverage exists at launch; a tracked, recurring formal program is a post-launch operational milestone.

This posture should be represented to prospective enterprise customers exactly as stated here — as a credible, time-bound roadmap, not as an existing certification. No claim of SOC 2 compliance or certification is made anywhere in customer-facing materials until the corresponding report is actually issued by a third-party auditor.

22.12 Vulnerability Management #

  • Dependency scanning: every pull request and every nightly scheduled run executes automated dependency vulnerability scanning (npm audit plus a dedicated SCA tool such as Dependabot/Snyk) across all workspaces in the monorepo. A critical or high severity finding with an available fix blocks merge; a finding with no available fix is triaged within 3 business days and either mitigated (e.g., a WAF-level compensating control) or the affected dependency is replaced.

  • Container/base-image scanning: deployment images (Section 26) are scanned for OS-level CVEs on every build; the base image is rebuilt and redeployed on a weekly cadence independent of application changes, so a newly disclosed base-image CVE is remediated within a week even absent an application code change.

  • Static analysis: CI runs a static application security testing (SAST) pass (CodeQL or equivalent) on every pull request touching apps/api or packages/shared, covering the injection, SSRF, and auth-bypass pattern classes described earlier in this section.

  • Disclosure policy: a public security disclosure policy is published at a stable /.well-known/security.txt path and a corresponding human-readable page, stating: how to report (the security contact below), expected acknowledgment time (2 business days), expected time to triage (5 business days), a safe-harbor statement for good-faith security research conducted within the policy's scope (no share-link password/expiry data of other customers is to be accessed; testing against one's own account/workspace only), and — once the product has meaningful scale — a bug-bounty program is a stated future step, not a launch requirement.

  • Security contact: security@reelay.app, monitored continuously with the acknowledgment SLA above; a PGP key is published on the security page for encrypted report submission.

  • Incident-response runbook outline:

    Severity Definition Example Notification timeline
    SEV-1 (Critical) Active exploitation, confirmed unauthorized access to customer data, or the redaction fail-closed gate (22.2.5) confirmed bypassed A workspace's unredacted originals found reachable via a crafted URL Internal escalation within 15 minutes of detection; affected customer notification within 72 hours per GDPR Art. 33/34, sooner where feasible; status-page update within 1 hour of confirmed impact
    SEV-2 (High) Vulnerability confirmed exploitable but no evidence of exploitation, or a significant availability incident A dependency CVE with a working public exploit affecting a production service Internal escalation within 1 hour; patched or mitigated within 24 hours; customer notification if any data exposure risk existed, within 72 hours of confirmation
    SEV-3 (Medium) Confirmed vulnerability with no direct exploitation path, or a degraded (non-outage) reliability issue An internal tool exposing more detail than necessary to an authenticated admin Triaged within 5 business days; fixed on next regular release cycle
    SEV-4 (Low) Best-practice gap, defense-in-depth improvement, no direct risk A missing security header on a non-sensitive static asset route Tracked as a backlog item, no fixed SLA

    The runbook's phases for any SEV-1/SEV-2 event: Detect (alerting per Section 24) → Triage (confirm scope and severity) → Contain (revoke credentials, disable the affected code path, invoke the fail-closed behavior of 22.2.5 where applicable) → Eradicate (patch the root cause) → Recover (restore normal service, verify with the checklist in 22.13) → Notify (affected customers and, where legally required, regulators, per the timelines above) → Post-mortem (written, blameless, with concrete follow-up actions tracked to completion).

22.13 Security Checklist Before Launch #

The executor must verify each item below individually before the product is made available to paying customers:

  • Redaction burn-in is verified end-to-end: a video with an active redaction region cannot be shared until its rendition is redaction_verified = true (22.2.4), and the verification cannot be bypassed by any API path (22.2.5).
  • No API route or storage path exists that can return an object from the restricted media bucket to a viewer-facing request (22.2.2).
  • Poster/thumbnail/animated-preview keys are playback_key_version-scoped and are purged from the delivery bucket and the CDN edge cache on link revocation/downgrade, not on a delayed schedule (22.2.2).
  • Every restricted-media access, human or service, produces an audit_events row (22.2.3).
  • Every endpoint in the API has at least one negative-authorization test in CI, and CI fails the build on an endpoint added without one (22.4.3, Section 25).
  • authorize() is the only code path that checks role/scopes; the lint rule blocking ad hoc role checks is enabled and passing (22.4.1).
  • IDOR responses for cross-workspace resource access return 404, not 403, for the resources listed in 22.4.2.
  • The share_viewer actor type is never evaluated against ROLE_CAPABILITIES['viewer']; verified by a test that an anonymous share-link visitor cannot access any resource outside the single video their token was minted for (22.4.1).
  • Every mutating dashboard/editor request enforces the CSRF token check; the check is covered by an automated test that a request with a missing/mismatched token is rejected (22.3.4).
  • Session, CSRF, and viewerToken cookies carry the exact attributes specified in 22.3.5.
  • Comment rendering never uses dangerouslySetInnerHTML; custom CTA HTML passes through server-side DOMPurify with the exact allowlist in 22.5.2 and is re-sanitized in the player.
  • SSRF allowlist/denylist checks (22.5.4) are applied to all three URL-fetching features (webhooks, custom-domain verification, image import), including the DNS-rebinding-safe pinned lookup and the address-normalization step confirmed to run before the denylist comparison.
  • Magic-byte validation rejects a mismatched file before it reaches permanent storage, for both video and image upload paths (22.5.5).
  • No secret value appears in application logs; the redaction-transform CI test (22.6) passes.
  • API keys are stored as SHA-256 hashes only; no plaintext key is retrievable after creation (22.6, Section 6).
  • TLS 1.0/1.1 are disabled at the edge; HSTS is present on all reelay.app responses (22.7.1).
  • mfa_credentials.totp_secret and oauth_accounts.refresh_token are confirmed encrypted at the application level, not relying on volume encryption alone (22.7.2).
  • No signed-URL issuance function accepts the restricted bucket as a target (22.7.3).
  • The CSP header (22.8.1) is present on every apps/web route and the CI CSP-violation test passes with zero violations.
  • The embeddable player sets zero cookies on any host-page domain, verified by an automated cross-origin embed test (22.8.2, Section 15).
  • A domain-restricted share link refuses to play in iframe-fallback mode with the explanatory message specified in 22.1.4, rather than silently playing without the restriction (22.1.4, 22.8.2).
  • frame-ancestors is correctly conditional on share-link domain-allowlist configuration (22.8.3).
  • Malware scanning runs on every completed upload before it leaves the quarantine prefix (22.9).
  • The content-report flow and the 3-report auto-visibility-downgrade threshold are functional end-to-end on a public video (22.9).
  • The sub-processor list published to customers matches the sub-processors actually in use (22.10.3).
  • DNT/GPC headers measurably change viewerToken persistence and disable named per-viewer identification, verified by an automated test (22.10.6).
  • EU/UK-detected watch-page sessions use the reduced-persistence viewerToken behavior specified in 22.10.7, and the watch page's non-blocking viewer-tracking disclosure is present and never blocks or delays playback (22.10.7).
  • The recording-consent notice is shown and its acknowledgment logged on first use per device, for both the desktop app and the browser recorder (22.10.8).
  • security.txt and the human-readable disclosure policy page are published and reachable (22.12).
  • The incident-response runbook contacts and escalation paths (22.12) are current and tested with a tabletop exercise before launch.
  • Dependency and container scanning are wired into CI and nightly schedules, with merge-blocking behavior confirmed on a deliberately vulnerable test dependency (22.12).

23. Accessibility — WCAG 2.2 AA #

This section owns the accessibility standard for the entire product. Every other section that describes a user interface — the marketing site, dashboard, timeline editor (Section 11), watch page, embeddable player (Section 15), and comment/engagement surfaces (Section 17) — conforms to the requirements stated here and cross-references this section rather than restating them.

23.1 Conformance Target #

The conformance target is WCAG 2.2 Level AA, applied across five distinct surfaces: the marketing site, the dashboard, the timeline editor, the watch page, and the embeddable player. "Conformance" is defined operationally, not aspirationally, as all of the following being true simultaneously:

  1. Every applicable WCAG 2.2 Level A and Level AA success criterion (enumerated in 23.2) is met on every page/state of the five surfaces above, with the narrow, explicitly stated exceptions inside 23.2 where a criterion does not apply to a given surface (e.g., contrast requirements do not apply to disabled/inactive UI states, per the WCAG normative exception).
  2. The automated conformance gate in CI (23.11) reports zero serious/critical axe-core violations on every route, on every pull request, before merge.
  3. Every release candidate passes the manual audit checklist in 23.11 before shipping.
  4. Every one of the five surfaces is included in the tested screen-reader/browser matrix in 23.7 at least once per minor release.
  5. Any accessibility regression discovered post-launch is triaged with the same severity model as a functional bug — not deprioritized as cosmetic — and is tracked to resolution through the process in 23.11.

Conformance is a continuously-verified property of the shipped product, not a one-time audit result. A change that introduces a new interactive component, a new page, or a new player state is not mergeable until it satisfies this section, exactly as a change that introduces a new API endpoint is not mergeable without the negative-authorization test required by Section 22.4.3.

23.2 Criterion-by-Criterion Conformance Table #

The following table covers the WCAG 2.2 Level A and AA success criteria that materially apply to this product, given its surfaces (a canvas-based timeline editor, a video player, forms, and content pages). Criteria that are trivially satisfied by using semantic HTML and standard component patterns throughout (e.g., 1.3.1 Info and Relationships, satisfied by packages/ui component library's semantic markup) are included for completeness but described concisely; criteria with product-specific complexity (the 2.2 additions, the editor's drag interactions, the player's live-region behavior) are given fuller treatment.

Criterion Level Requirement How this product satisfies it
1.1.1 Non-text Content A All non-text content has a text alternative Every icon-only button in packages/ui requires an aria-label prop at the type level (TypeScript makes it a required prop, not optional, for icon-button variants); video posters have alt text derived from the video title; decorative images use alt=""
1.2.1 Audio-only and Video-only (Prerecorded) A Alternative for time-based media Every video has a transcript (23.4, Section 12) satisfying this for both audio and video-only content
1.2.2 Captions (Prerecorded) A Captions for prerecorded video with audio Captions on by default (23.3); auto-generated per Section 12, editable by the workspace
1.2.3 / 1.2.5 Audio Description A / AA Audio description or media alternative The transcript (23.4) serves as the media alternative satisfying 1.2.3; full synchronized audio description tracks are not produced automatically, but the transcript view's structured text — including on-screen action summarized in AI-generated chapter descriptions (Section 12) — is the documented equivalent, since the product's content (screen recordings, not narrative video) is predominantly self-describing through its own narration
1.3.1 Info and Relationships A Structure conveyed programmatically Semantic HTML throughout packages/ui; headings form a proper outline per page; form fields associate labels via <label for>/aria-labelledby (23.10)
1.3.4 Orientation AA Content not restricted to one orientation Dashboard, editor, and watch page are responsive and functional in both portrait and landscape on mobile viewports; no orientation lock is applied
1.3.5 Identify Input Purpose AA Input purpose programmatically determinable Form fields use appropriate autocomplete attributes (email, name, new-password, etc.)
1.4.1 Use of Color A Color is not the only visual means of conveying information Status indication is never color-only (23.8) — e.g., recording status uses an icon plus text label, not a colored dot alone
1.4.3 Contrast (Minimum) AA 4.5:1 normal text, 3:1 large text Enforced design-token contrast ratios (23.8); brand-kit custom accent color validated at save time (23.8)
1.4.4 Resize Text AA Text resizable to 200% without loss of content/function No fixed-pixel text containers; layout uses relative units (rem) throughout packages/ui; tested at 200% browser zoom in the manual audit (23.11)
1.4.5 Images of Text AA Real text, not images of text No images of text are used anywhere in the product UI; brand-kit logos are user content, exempt under the WCAG "essential" exception (logos are inherently images)
1.4.10 Reflow AA No horizontal scroll/loss of content at 320px width equivalent Dashboard and watch page reflow to single-column below 480px; the editor's canvas timeline is the one documented exception — it requires a minimum 768px viewport width, with a clear in-product message below that width directing the user to a wider viewport or the keyboard-driven list view (23.6), which itself remains usable at 320px
1.4.11 Non-text Contrast AA 3:1 for UI components and graphical objects Focus indicators, form field borders, and player controls meet 3:1 against adjacent colors (23.5, 23.8)
1.4.12 Text Spacing AA No loss of content when text spacing is overridden Verified in the manual audit with a text-spacing bookmarklet/extension; no fixed-height text containers that would clip overridden spacing
1.4.13 Content on Hover or Focus AA Hoverable/focusable content that appears is dismissible, hoverable, persistent Tooltips (e.g., timeline scrubber preview) are dismissible with Escape, remain visible while hovered, and do not obscure the triggering control
2.1.1 Keyboard A All functionality available via keyboard Full keyboard operability, including the editor's drag operations via the list-view alternative (23.6)
2.1.2 No Keyboard Trap A Focus can always move away Verified per-component; modal dialogs use focus-trap-within-modal only, always with an Escape/close path (23.5)
2.1.4 Character Key Shortcuts A Single-character shortcuts can be turned off, remapped, or are active only on focus All editor single-key shortcuts (e.g., Space for play/pause, J/K/L for scrub) are active only when the timeline/player region has focus, never globally on the page (23.5)
2.2.1 Timing Adjustable A Time limits can be extended/disabled The 6-hour playback token TTL (Section 22.7.3) refreshes transparently and is never user-facing as a timeout; session timeout (30-day sliding, Section 6) is never an in-task time limit; no other timed interaction exists in the product
2.2.2 Pause, Stop, Hide A Moving/auto-updating content can be paused Auto-playing video previews in the dashboard library grid are paused by default and require hover/focus intent to animate; the live engagement-curve chart's auto-refresh can be paused via a visible control
2.3.1 Three Flashes A No content flashes more than 3 times/second No flashing content exists in the product by design; verified for any future motion-graphic template additions to the auto-editing background presets (Section 10) as part of the manual audit
2.3.3 Animation from Interactions AAA (adopted as a product commitment) Motion triggered by interaction can be disabled prefers-reduced-motion handling (23.9) extends this AAA criterion as a deliberate product commitment given the auto-editing engine's zoom/pan motion is central to the product
2.4.1 Bypass Blocks A Skip repeated content Skip links on dashboard/editor/watch page (23.5)
2.4.2 Page Titled A Descriptive page titles Every route sets a descriptive <title> (e.g., "Q3 Demo Walkthrough — Reelay")
2.4.3 Focus Order A Focus order preserves meaning/operability Explicit focus-order specification for player and editor (23.5)
2.4.4 Link Purpose (In Context) A Link purpose determinable from text/context No bare "click here" links; comment/CTA links carry descriptive text or aria-label
2.4.6 Headings and Labels AA Descriptive headings and labels Enforced in component library defaults and content review
2.4.7 Focus Visible AA Keyboard focus indicator is visible Visible focus indicators with contrast requirements (23.5)
2.4.11 Focus Not Obscured (Minimum) AA (2.2 addition) Focused component not entirely hidden by author-created content Sticky headers/toolbars in the dashboard and editor are accounted for in scroll-margin calculations so a focused element scrolls clear of any sticky overlay; the editor's floating property panel never overlaps the currently focused timeline element (23.5)
2.4.13 Focus Appearance AA (2.2 addition) Focus indicator has a minimum area/contrast Focus indicator is a 2px solid outline with a 2px offset, contrast ratio ≥ 3:1 against both the unfocused and focused element background, meeting the criterion's minimum area equivalent (perimeter-based, at least as large as a 2px-thick outline around the element) (23.5)
2.5.1 Pointer Gestures A Multipoint/path-based gestures have a single-pointer alternative The editor's timeline drag operations (trim, move clip, drag redaction region) all have a non-drag keyboard equivalent (23.6)
2.5.2 Pointer Cancellation A Down-event does not trigger the action All click/drag interactions trigger on up-event (pointerup) with the ability to move outside the target and cancel before release
2.5.3 Label in Name A Visible label text is contained in the accessible name Enforced by lint rule comparing visible button/link text to aria-label overrides in packages/ui
2.5.4 Motion Actuation A Functionality triggered by device motion has a UI alternative Not applicable — the product has no motion-actuated (device-tilt/shake) functionality
2.5.7 Dragging Movements AA (2.2 addition) Drag functionality has a single-pointer, non-dragging alternative Every drag interaction in the editor has a keyboard/button equivalent via the list view (23.6); the brand-kit color picker's drag handle likewise has arrow-key adjustment
2.5.8 Target Size (Minimum) AA (2.2 addition) Targets at least 24×24 CSS px (with exceptions) All interactive targets in packages/ui are a minimum 24×24px, and primary touch-oriented controls (player play/pause, mobile dashboard nav) are 44×44px; the timeline's fine-grained trim handles are the documented exception under the "essential" exemption (precise sub-pixel trimming is the function), with the keyboard alternative in 23.6 as the accessible path for users who cannot use the small handle
2.6.1 (n/a — not a 2.2 criterion, omitted)
3.1.1 Language of Page A Page lang attribute set <html lang="en"> at launch (product is English-only at launch, stated as a scope boundary in Section 2)
3.2.1 On Focus A No context change on focus alone Verified per-component; no onFocus-triggered navigation anywhere
3.2.2 On Input A No unexpected context change on input Form submission always requires an explicit submit action, never auto-submits on field change
3.2.6 Consistent Help A (2.2 addition) Help mechanism appears in the same relative order across pages The help/support link (chat widget launcher) is positioned identically across dashboard, editor, and settings pages, in the same location in the page's navigation order
3.3.1 Error Identification A Errors identified in text Field-level error messages (23.10)
3.3.2 Labels or Instructions A Labels/instructions for user input Label association (23.10)
3.3.3 Error Suggestion AA Suggested correction where known Error messages include the correction where determinable (23.10)
3.3.4 Error Prevention (Legal, Financial, Data) AA Confirmation/reversal for consequential submissions Destructive actions (video delete, workspace delete, plan downgrade with data loss implications) require a confirmation step; billing changes show a review-before-confirm summary
3.3.7 Redundant Entry A (2.2 addition) Information already entered is not re-requested Multi-step flows (e.g., recording upload retry, checkout) carry forward previously entered values automatically; the browser's autocomplete attributes (1.3.5) further reduce re-entry burden
3.3.8 Accessible Authentication (Minimum) AA (2.2 addition) No cognitive function test required for authentication, unless an alternative exists Password login requires only entry (with paste and password-manager autofill explicitly permitted, never blocked); MFA (Section 6) accepts TOTP autofill on supporting platforms; Google OAuth (Section 6) is itself a fully accessible-authentication-compliant alternative requiring no memorization
4.1.2 Name, Role, Value A UI components expose name/role/value programmatically Enforced via packages/ui component contracts and the axe-core CI gate (23.11)
4.1.3 Status Messages AA Status messages programmatically determinable without focus ARIA live regions for save confirmations, upload progress, and the editor's timeline-change announcements (23.6, 23.7)

23.3 Captions On by Default #

Captions are enabled by default in the embeddable player, the watch page, and the editor's preview, for every video that has completed caption generation. This is stated here as the product's accessibility commitment: a viewer never has to discover and enable a hidden captions toggle to get captions — the default viewing experience is already captioned, and a viewer must take an affirmative action to turn captions off (a preference that persists per-viewer via local storage for subsequent videos on the same device, not just per-session). Section 15 owns the player's caption rendering implementation (track selection, styling, the toggle control itself); Section 12 owns caption generation, editing, and the VTT pipeline. Where a video has not yet completed caption generation (newly uploaded, still processing), the player displays a "Captions processing" state rather than silently having no caption option, so the absence is never ambiguous between "not available" and "still loading."

23.4 The Transcript View as an Accessibility Feature #

The transcript view (rendered alongside the player on the watch page and in the editor) is a first- class accessibility feature, not a supplementary convenience:

  • Keyboard navigable: the transcript is a sequence of focusable segment elements (one per transcript_segments row, Section 12), reachable via standard Tab/Shift+Tab traversal in document order, with Home/End jumping to the first/last segment and ArrowUp/ArrowDown moving between segments without requiring the segment to be a native <a> or <button> element to do so unnaturally — segments are rendered as a role="list" of role="listitem" buttons, each a genuine <button> element so default keyboard activation (Enter/Space) works without custom key handling.
  • Click-to-seek: activating a segment (click, Enter, or Space) seeks the player to that segment's start timestamp and begins playback if paused; the currently-playing segment is highlighted visually and exposed to assistive technology via aria-current="true" on the active segment button, updated as playback advances, so a screen-reader user tracking the transcript during playback has a programmatically available "you are here" marker without needing to watch the visual highlight.
  • Screen-reader-friendly structure: the transcript container is <section aria-label="Video transcript">; each segment includes the speaker label (where speaker diarization is available, Section 12) as visually-associated text (not only a color or icon), and timestamps are exposed as visible text (not only as a title attribute, which is not reliably announced) in a consistent mm:ss format read naturally by screen readers.
  • The transcript view is independently usable with the player paused, closed, or not yet loaded — a viewer who wants to read a video's content without watching or listening to it can do so entirely through the transcript, satisfying 1.2.1's audio/video-only alternative requirement in practice, not just on paper.

23.5 Keyboard Operability #

23.5.1 Player focus order #

Within the embeddable player and the watch page's larger player instance, the focus order is fixed and identical across both surfaces:

  1. Play/Pause toggle
  2. Seek bar (a native-semantics role="slider" with aria-valuemin/aria-valuemax/aria-valuenow in seconds, aria-valuetext in mm:ss; ArrowLeft/ArrowRight seek ±5s, ArrowUp/ArrowDown seek ±10s, Home/End jump to start/end)
  3. Current time / duration display (not focusable, aria-hidden since the seek bar's aria-valuetext already conveys the same information to assistive technology, avoiding duplicate announcement)
  4. Volume control (button + slider, same slider semantics as the seek bar, ArrowUp/ArrowDown adjust ±5%, M toggles mute when the volume control has focus)
  5. Playback speed control
  6. Captions toggle
  7. Transcript toggle (shows/hides the transcript panel described in 23.4)
  8. Picture-in-picture toggle (where supported by the browser)
  9. Fullscreen toggle
  10. Report control (23.9's abuse-reporting entry point)

Global player shortcuts (active when the player region has focus, not the whole page, per criterion 2.1.4): Space/K play/pause, ArrowLeft/ArrowRight seek ±5s, J/L seek ±10s, ArrowUp/ ArrowDown volume, M mute, F fullscreen, C captions toggle, number keys 09 seek to that decile of the video.

23.5.2 Editor focus order #

The editor's overall focus order moves left-to-right, top-to-bottom through logical regions: (1) top toolbar (undo/redo, export, share) → (2) preview player (same internal order as 23.5.1) → (3) side property panel (context-sensitive to current selection) → (4) timeline region (see 23.6 for the timeline's internal keyboard model) → (5) bottom transport controls. Each region is a distinct landmark (role="region" with aria-label) so screen-reader users can navigate directly between regions via their assistive technology's landmark navigation, not only via linear Tab traversal.

23.5.3 Visible focus indicators #

Every focusable element displays a visible focus indicator meeting the 2.4.13 specification in 23.2: 2px solid outline, 2px offset from the element's border, with a contrast ratio of at least 3:1 against the adjacent background in both the light and dark theme. The focus indicator is never suppressed with outline: none without a replacement that meets this specification — this is enforced by a lint rule scanning for unguarded outline: none/outline: 0 in packages/ui and apps/web stylesheets.

23.5.4 No keyboard traps #

Modal dialogs (share settings, delete confirmation, upload progress) trap focus within the dialog only while open — Tab/Shift+Tab cycle through the dialog's own focusable elements and wrap, never escaping to the page behind it — but Escape always closes the dialog and returns focus to the element that opened it. The one sanctioned exception, tested explicitly, is a destructive confirmation dialog (e.g., permanent video deletion) which still honors Escape to cancel; no dialog in the product is ever non-dismissable via keyboard.

Every page with persistent navigation chrome (dashboard, editor, watch page) begins with a visually- hidden-until-focused "Skip to main content" link as the first focusable element, jumping focus to the page's primary landmark (<main>). The editor additionally provides a "Skip to timeline" link immediately after, given the timeline is the most-used region and otherwise sits behind the toolbar and preview player in focus order.

23.5.6 Shortcut-conflict policy with assistive technology #

Single-character shortcuts (23.2's 2.1.4 entry) are scoped to fire only when a specific region has focus (the player or the timeline), never as global document-level key listeners, which is the primary mechanism that avoids conflicts with screen-reader browse-mode key commands (many of which are single letters, e.g., NVDA's H for heading navigation). When a screen reader's own browse mode is active (detected indirectly by the presence of focus events arriving from virtual-cursor navigation rather than physical Tab presses, using the standard heuristic of listening for a focusin event not preceded by a keydown), the timeline's single-key shortcuts additionally require holding Alt as a modifier, documented in the in-product keyboard shortcut reference, so a screen-reader user's browse-mode navigation is never intercepted by the editor's single-letter shortcuts.

23.6 The Editor: A Keyboard-Driven Accessible Alternative to the Canvas Timeline #

The timeline editor's canvas-rendered timeline (Section 11) is the hardest accessibility surface in the product: it is a direct-manipulation, drag-based visual interface, and a canvas element has no inherent accessibility tree. This subsection specifies the accessible alternative in full, since Section 11 owns the visual/interaction design but this section owns the requirement that it be fully operable without a pointer.

23.6.1 The list view #

Every EDL (Section 11's EditDecisionList) has an equivalent list view representation: an ordered, keyboard-navigable list of every EDL operation, exposed as a genuine DOM list (role="list") that a screen reader can traverse natively, alongside the canvas timeline (not replacing it — both views stay in sync against the same underlying EDL, and either can be used to perform the same edit). Toggling to list view is a persistent, remembered preference, and it is also automatically presented as the primary view when the editor detects a screen reader is active (via the same virtual-cursor heuristic described in 23.5.6) or when the viewport is below the 768px minimum stated in 23.2's 1.4.10 entry.

Each EDL operation is rendered as a list item exposing:

role="listitem"
├── operation type (Cut / Trim / Zoom / Redaction / Caption edit / Silence removal / Filler-word removal)
├── source time range (start–end, mm:ss.mmm, editable via a text input with validated numeric entry)
├── a set of action buttons appropriate to the operation type:
│   ├── "Move earlier" / "Move later" (shifts the operation's position in sequence order)
│   ├── "Extend start" / "Trim start" / "Extend end" / "Trim end" (in fixed 100ms or 1s increments,
│   │    selectable via a step-size control, as the keyboard equivalent of dragging a trim handle)
│   ├── "Revert this edit" (per Section 11's per-operation revertibility guarantee)
│   └── type-specific controls (e.g., a redaction operation exposes "Edit region bounds" opening a
│        numeric x/y/width/height form, the keyboard equivalent of dragging a redaction rectangle)

This satisfies criterion 2.5.7 (Dragging Movements) completely: every drag interaction available on the canvas — moving a clip, trimming an edge, repositioning a redaction rectangle, adjusting a zoom segment's timing — has a corresponding non-drag control in the list view that achieves the identical result with the identical precision (numeric entry is strictly more precise than a mouse drag, not a degraded substitute).

23.6.2 ARIA live announcements for timeline changes #

A visually-hidden aria-live="polite" region (a single persistent element, not created/destroyed per-announcement, to avoid announcement loss in some screen-reader/browser combinations) announces every state-changing timeline action, whether performed via canvas drag or list-view control:

  • "Clip trimmed. New duration 2 minutes 14 seconds."
  • "Redaction region moved to start at 0:42."
  • "Zoom segment removed."
  • "Edit reverted. Timeline restored to previous state."

Announcements are debounced during continuous drag operations (a live-drag in the canvas view does not announce on every pixel of movement, which would be unusable) and instead announce once on drag completion (pointerup) with the final resulting value — the equivalent list-view numeric-entry interaction announces immediately on commit (Enter or blur), since there is no continuous intermediate state to debounce.

Destructive or high-impact changes (a cut that removes more than 5 seconds, any redaction region deletion) use aria-live="assertive" instead of polite, interrupting other announcements, since these are changes a user needs to be aware of immediately rather than eventually.

23.6.3 Ownership boundary #

The canvas timeline's visual rendering, drag-gesture implementation, and the EDL schema itself are owned by Section 11. This section (23.6) owns the requirement that the list view exists, stays in sync with the same EDL, and provides full parity — Section 11's implementation must satisfy this requirement, not redefine it.

23.7 Screen Reader Support #

23.7.1 Tested combinations #

The following screen-reader/browser/OS combinations are the officially tested support matrix, run against all five product surfaces (23.1) before every minor release:

Screen reader Browser OS
NVDA (latest stable) Firefox (latest stable) Windows 11
JAWS (latest stable) Chrome (latest stable) Windows 11
VoiceOver Safari (latest stable) macOS (latest stable)
VoiceOver Safari iOS (latest stable)

A combination not in this table (e.g., TalkBack on Android Chrome) is expected to work given the product's adherence to standard ARIA patterns and semantic HTML, but is not part of the formal test gate — this is stated honestly rather than implying untested coverage.

23.7.2 ARIA patterns used #

The product uses only well-established WAI-ARIA Authoring Practices patterns, never a bespoke ARIA construction: dialog (modals), listbox/option (dropdown selects, e.g., plan selector), tablist/ tab/tabpanel (settings pages, editor property panel sections), slider (player seek/volume, 23.5.1), switch (boolean toggles, e.g., captions on/off), combobox (folder picker with search). Custom components in packages/ui implementing these patterns are unit-tested against the expected keyboard interaction model defined by each pattern's Authoring Practices specification, not just visually reviewed.

23.7.3 Live-region strategy #

Beyond the timeline's live region (23.6.2), the product uses a small, deliberately limited set of live regions to avoid the well-known screen-reader problem of over-announcement:

  • A single global aria-live="polite" toast-notification region for save confirmations, upload completion, and non-critical errors.
  • A single global aria-live="assertive" region reserved for critical errors that block further action (e.g., "Recording failed, no data was lost" per the local-first upload guarantee in Section 9).
  • The timeline's own live region (23.6.2), scoped to the editor page only.
  • The upload-progress indicator uses role="progressbar" with aria-valuenow updated on a throttled interval (every 2 seconds, not on every byte) rather than a live region, since continuous progress is better conveyed via the progressbar role's value semantics than repeated announcements.

No other component creates ad hoc live regions; this is enforced by code review against the ARIA pattern list in 23.7.2.

23.8 Colour and Contrast #

23.8.1 Minimum ratios #

Content type Minimum ratio
Normal text (<18pt / <14pt bold) 4.5:1
Large text (≥18pt / ≥14pt bold) 3:1
UI components and graphical objects (borders, icons conveying meaning, focus indicators) 3:1
Logotype and purely decorative content No minimum (WCAG exception)
Disabled/inactive UI state No minimum (WCAG exception, applied narrowly only to elements with aria-disabled/disabled)

These ratios are encoded as design tokens in packages/ui's theme definition and verified by an automated contrast-check test that renders every design-token color pair used in the codebase and asserts the computed ratio meets the applicable minimum — this runs in CI, not only at design time, so a future token change that regresses contrast fails the build rather than shipping unnoticed.

23.8.2 Brand-kit custom accent color validation #

The Pro/Business brand kit (Section 18) lets a workspace choose a custom accent color used for CTA buttons, the player's progress bar, and highlighted UI elements on the watch page. Because this color is applied against a fixed set of known backgrounds (the watch page's background color, white button text, or dark button text depending on the computed contrast), it must not be allowed to silently produce inaccessible combinations:

Algorithm, run synchronously in the brand-kit save handler (PUT /v1/workspaces/{id}/brand-kit) before persisting:

  1. Parse the submitted color to sRGB.
  2. Compute the relative luminance and contrast ratio (per the WCAG 2.x formula, not the newer APCA model, since AA conformance is specified against the WCAG 2.x formula) against both white (#FFFFFF) and black (#000000) text.
  3. Determine the best-contrast text color: if the ratio against black text ≥ 4.5:1, black text is selected for elements using this accent as a background; else if the ratio against white text ≥ 4.5:1, white text is selected; if neither reaches 4.5:1 (the accent color is a mid-tone that fails against both), the save request is rejected — 422, error code accent_color_insufficient_contrast — with a response details array containing the two computed ratios and a suggested adjusted color (the same hue/saturation, with lightness adjusted via HSL binary search to the nearest value that clears 4.5:1 against the better of the two text colors).
  4. Independently, the accent color is checked against the watch page's fixed background (#FFFFFF light theme / #0A0A0A dark theme) for the 3:1 non-text-contrast minimum (23.8.1), since the accent is also used for the player progress-bar fill, a graphical object; failing this check alone (while passing step 3) does not block the save but surfaces a non-blocking warning in the brand-kit editor UI, since the progress-bar use is supplementary to the primary CTA-button use case step 3 gates on.
  5. The selected best-contrast text color (from step 3) is stored alongside the accent color (brand_kits.accent_color, brand_kits.accent_text_color) so every consumer of the brand kit (watch page, player, CTA rendering) uses the pre-computed, validated pairing rather than recomputing contrast client-side, guaranteeing consistency and guaranteeing the client cannot skip the check.

A user who picks a failing color sees the rejection and suggested alternative inline in the brand-kit color picker before save, with a one-click "use suggested color" action — the picker never silently auto-corrects without the user's explicit action, since brand color choice is intentional.

23.8.3 Non-colour-dependent status indication #

Every status indicator in the product pairs color with a redundant non-color signal: recording status uses an icon shape change plus a text label ("Recording" / "Paused") in addition to the red/gray color change; processing states (transcoding, rendering, redaction pending) use distinct icons (spinner / checkmark / warning triangle) plus text, never color alone; the transcript's active-segment highlight (23.4) pairs a background color change with the aria-current attribute and a bold font-weight change, not color alone; form field validation state pairs a red border with an explicit error icon and text message (23.10), never a red border alone.

23.9 Motion #

prefers-reduced-motion: reduce is honored in three distinct subsystems, each with a stated, concrete reduced-motion behavior:

  • Player: standard UI transitions (control fade-in/out, dialog open/close) switch from an animated transition to an instant state change. This does not affect the video content itself, which the player does not alter based on this media query — a reduced-motion viewer still sees the video's own baked-in motion (per 23.9's auto-editing entry below, if applicable), since suppressing the actual video content would misrepresent what was shared with them; the player only reduces its own chrome's motion.
  • Editor: canvas timeline scrubbing, zoom/pan preview animations in the property panel, and drag-and-drop visual feedback (ghost-element following the cursor) all switch to instant/non- animated equivalents. The editor's own UI chrome is fully within the product's control, unlike rendered video content, so it fully suppresses non-essential motion.
  • Auto-editing engine's zoom and pan behavior (Section 10) — the most significant case: the auto-editing engine's zoom/pan camera motion is baked into the rendered video pixels at render time (Section 10's critically-damped-spring camera path), which means a reduced-motion viewer's browser setting cannot retroactively alter already-rendered video the way it can alter live UI chrome. The product's stated resolution: the render request (Section 10) accepts a reducedMotionVariant: boolean parameter, and when a video is rendered with an active auto-edit preset, the render worker additionally produces a second rendition using a reduced-motion preset variant — the same EditDecisionList's cut/timing decisions are preserved (what to show and when), but the camera-path parameters are substituted with a near-static equivalent: zoom transitions become instant hard cuts to the target zoom level rather than a spring-eased pan (satisfying 2.3.3's animation-from-interaction spirit even though this is baked content, not an interaction), and zoom magnitude is capped at 1.15x rather than the standard engine's up to 2.5x, minimizing the vestibular- trigger effect of large-scale continuous panning. The watch page and player detect prefers-reduced-motion: reduce at load time and automatically request the reduced-motion rendition where one exists, falling back to the standard rendition (never blocking playback) if the reduced-motion variant has not yet been rendered, with the standard rendition's render enqueued to also complete before the equivalent reduced-motion job — reduced-motion viewers are never made to wait longer for a video to become available than standard viewers, and the standard rendition is never withheld from a reduced-motion viewer as a way to force the wait, it is simply preferred once ready.

23.10 Forms and Errors #

  • Label association: every form input has a programmatically associated label via <label for> matching the input's id, or aria-labelledby where the visible label text is not a direct sibling (e.g., a label spanning a composite control). Placeholder text is never used as the sole label — it is permitted only as supplementary formatting guidance (e.g., a date format hint) in addition to a persistent visible label, since placeholder text disappears on input and is not reliably announced identically across screen readers.
  • Error identification: on validation failure (client-side pre-check or server-side rejection via the shared Zod boundary, Section 22.5.1), the invalid field is marked aria-invalid="true" and associated with an error message element via aria-describedby; the error message is also inserted into the page's aria-live="polite" region (23.7.3) so a screen-reader user who has moved focus away from the field before submission is still informed, and focus moves to the first invalid field on a failed form submission.
  • Error suggestion: where the failure reason permits a concrete suggestion, the message states it — e.g., "Password must be at least 10 characters" rather than "Invalid password"; "This email is already registered — try signing in instead" with a link, rather than a bare rejection; the API's error envelope details[].issue field (Section 7.6) is a stable code the client maps to this exact human-readable, suggestion-bearing text, so server-side and client-side validation produce identically worded messages for the same underlying issue.
  • Required-field marking: required fields are marked with a visible asterisk plus the word "required" conveyed programmatically via aria-required="true" (not the asterisk alone, which is a visual-only convention); optional fields in a form that is majority-required are marked "(optional)" instead, whichever labeling minimizes the total number of markers per the form's own required/optional balance.

23.11 Accessibility Testing #

23.11.1 Automated testing #

axe-core runs in two layers: (1) a component-level test for every component in packages/ui, asserting zero violations in isolation across its documented prop-variant states (default, hover, focus, disabled, error), run as part of the existing component test suite (Section 25); (2) a route-level integration test (Playwright + @axe-core/playwright) that loads every distinct route/ page-state across the five surfaces (23.1) and asserts zero violations. Failure threshold: any critical or serious impact violation fails the CI build outright. moderate impact violations are logged as a build warning and tracked as backlog items with no fixed SLA (mirroring the SEV-4 posture in Section 22.12) but do not block merge; minor impact violations are logged only. This threshold is deliberately not "zero violations of any impact" because axe-core's minor-impact category includes a meaningful rate of false positives on complex custom components (e.g., the canvas timeline's non-standard region) that would create alert fatigue and erode the signal of the gate if treated as blocking.

23.11.2 Manual audit checklist #

Beyond automated coverage (which cannot verify semantic correctness, only structural presence of ARIA attributes), every release candidate undergoes a manual audit against this checklist:

  • Full keyboard traversal of each of the five surfaces with the mouse physically disconnected — every function reachable, no trap, focus order matches 23.5.
  • One full pass of the tested screen-reader matrix (23.7.1) per surface, verifying announcements make sense in context, not just that an accessible name exists.
  • 200% browser zoom on dashboard, editor, and watch page — no lost content or function (1.4.4).
  • prefers-reduced-motion: reduce enabled at the OS level — verify all three subsystems in 23.9 behave as specified.
  • Color contrast spot-check with a physical color-blindness simulation (protanopia, deuteranopia, tritanopia) across the status-indication surfaces in 23.8.3.
  • The editor's list view (23.6) performs every operation type available in the canvas view, with matching resulting EDL state, verified by comparing the exported EDL JSON after an equivalent canvas-driven and list-view-driven edit sequence.
  • Forms: submit each form with every required field empty, verify error focus and message wording (23.10).
  • Brand-kit accent color picker: submit a color known to fail contrast, verify rejection message and suggested-color mechanism (23.8.2).

23.11.3 Frequency of re-audit #

The automated gate (23.11.1) runs on every pull request touching any of the five surfaces, with no exception. The manual audit checklist (23.11.2) runs on every release candidate prior to a minor or major version release (not on every patch release, where the automated gate is the sole gate, unless the patch touches accessibility-relevant markup, in which case the specific affected checklist items are re-run). A full independent third-party accessibility audit (an outside firm, not internal engineering) is conducted once in the first two quarters post-launch and every 12 months thereafter, mirroring the honesty of the SOC 2 posture in Section 22.11 — this is a launch-adjacent commitment, not a pre-launch blocker, since the automated gate plus the manual checklist above are the pre-launch bar.

23.12 The Accessibility Statement Page and Feedback Channel #

A public, permanently linked (site-wide footer) accessibility statement page states: the conformance target (WCAG 2.2 AA, this section), the date of the most recent audit (internal or third-party, whichever is more recent), known exceptions stated honestly and specifically (at launch: the canvas timeline requiring a 768px minimum viewport with the list-view fallback below that threshold, per 23.2's 1.4.10 entry, and the absence of synchronized audio-description tracks in favor of the transcript-based equivalent, per 23.2's 1.2.3/1.2.5 entry), and a dedicated accessibility feedback channel (accessibility@reelay.app, monitored with the same 2-business-day acknowledgment SLA as the security contact in Section 22.12) for reporting a barrier not covered by this statement. A reported barrier that constitutes a genuine WCAG 2.2 AA conformance failure is triaged with the same severity model as a functional defect (23.1's operational conformance definition), not routed to a lower-priority general-feedback backlog.

24. Observability, Reliability & Operations #

24.1 Structured logging #

Every process (apps/web server runtime, apps/api, apps/worker, apps/desktop main process) emits newline-delimited JSON to stdout. Nothing is ever written directly to a file by application code; the hosting platform's log collector ships stdout to the log store. No console.log of unstructured strings is permitted outside local development — the shared packages/shared logger (createLogger(service), a thin wrapper over pino) is the only allowed entry point in apps/api and apps/worker.

24.1.1 Log record schema #

Every log line is a single JSON object. Fields marked "always" are present on every record regardless of service; fields marked "HTTP" or "job" are present only in that context.

Field Type Presence Description
timestamp string (ISO 8601 UTC, Z) always Emission time, millisecond precision.
level string enum always One of trace, debug, info, warn, error, fatal.
message string always Human-readable summary. No interpolated secrets.
service string enum always api, worker, web, desktop.
env string enum always local, preview, staging, production.
version string always Deployed commit SHA (short, 12 char).
requestId string always where applicable req_<base58>, see 24.1.2. Present on every HTTP-originated or job-originated log line.
traceId string (32 hex) always where a span is active OpenTelemetry trace id, see 24.3.
spanId string (16 hex) always where a span is active OpenTelemetry span id.
workspaceId string | null when known Public-prefixed id (ws_...), never the raw UUID in logs shipped to a third-party sink.
userId string | null when known Public-prefixed id (usr_...). Never an email.
route string HTTP The Fastify route pattern, e.g. /v1/videos/:videoId, never the resolved path (avoids high cardinality).
method string HTTP HTTP method.
statusCode number HTTP Response status.
durationMs number HTTP, job Wall-clock duration of the request or job attempt.
queue string job BullMQ queue name, dot.case per Section 4.
jobName string job Job name, dot.case.
jobId string job The BullMQ job id (<entity>:<operation>:<version>, Section 9).
attempt number job 1-indexed attempt number.
err object | omitted on failure { code, message, stack }. stack is included in local, preview, staging only; omitted in production logs (it can leak file-system layout) and instead attached to the paired trace span as an exception event.
ctx object optional Free-form structured extra fields specific to the log site (e.g. { videoId, renditionId }). Never contains any field from the never-log list below.

Example API request-completion log line:

{"timestamp":"2026-08-19T14:02:11.402Z","level":"info","message":"request completed","service":"api","env":"production","version":"a1b2c3d4e5f6","requestId":"req_9K3mQ7pXcT2vN8wZ","traceId":"4bf92f3577b34da6a3ce929d0e0e4736","spanId":"00f067aa0ba902b7","workspaceId":"ws_7GpQmZ3","userId":"usr_2XkTn9","route":"/v1/videos/:videoId","method":"GET","statusCode":200,"durationMs":42}

Example worker job-failure log line:

{"timestamp":"2026-08-19T14:03:05.118Z","level":"error","message":"render job failed","service":"worker","env":"production","version":"a1b2c3d4e5f6","requestId":"req_9K3mQ7pXcT2vN8wZ","traceId":"4bf92f3577b34da6a3ce929d0e0e4736","spanId":"1a2b3c4d5e6f7a8b","queue":"video.render.compose","jobName":"video.render.compose","jobId":"vid_5RqLpT9:render:3","attempt":2,"durationMs":18420,"err":{"code":"ffmpeg_nonzero_exit","message":"ffmpeg exited with code 1"},"ctx":{"videoId":"vid_5RqLpT9","renditionId":"rnd_2NpQx4"}}

24.1.2 Correlation and request IDs #

  • A request id is generated at the edge: a Fastify onRequest hook checks for an inbound X-Request-Id header (public API clients doing idempotent retries may supply one); if absent, it generates req_<12-char base58>. The id is set on the response header X-Request-Id and stored in AsyncLocalStorage for the duration of the request, so every log line and every DB/vendor call made while handling that request carries it automatically without explicit threading.
  • When a request enqueues a BullMQ job, the job's data payload always includes requestId (the originating request) and causationId (the id of the job or request that directly triggered this one — for a job enqueued by another job, causationId is that job's jobId; for a job enqueued by an HTTP request, causationId equals requestId). This produces a traceable chain from an inbound HTTP call through however many queue hops a piece of work takes (e.g. video.ingestvideo.transcode.request → webhook-triggered video.render.composevideo.thumbnail.generate).
  • The worker process, on picking up a job, seeds a new AsyncLocalStorage context from job.data.requestId so every log line emitted while processing that job — including lines from library code such as the Drizzle query logger — carries the original request id even though the HTTP request that started the chain completed seconds or minutes earlier.
  • The webhook receiver (Mux, transcription vendor, Stripe) generates a fresh requestId for the inbound webhook call but sets causationId to the vendor's own delivery/event id, so a webhook-triggered chain is still traceable even though it has no upstream Reelay request.

24.1.3 Log levels — when each is used #

Level Use Production default
trace Per-sample/per-frame detail (e.g. individual cursor telemetry samples, individual EDL solver iterations). Never enabled outside a developer's local machine. disabled
debug Diagnostic detail useful when investigating a specific incident (cache hit/miss, vendor request/response shape with secrets stripped, query plan hints). disabled by default; toggled per-service via the LOG_LEVEL env var (Section 26.2) for the duration of an investigation, never left on
info Normal operational events: request completed, job completed, state transition (video became ready, share link created, subscription changed plan). This is the audit trail for "what happened." enabled, sampled per 24.1.4
warn Recoverable anomaly: a job retried, a rate limit was hit, a webhook signature check failed once, a vendor call was slow but succeeded, a deprecated API field was used. enabled, 100%
error An operation failed and did not recover: a job exhausted retries, an unhandled exception was caught by the top-level error boundary, a payment failed. Always paired with an err object. enabled, 100%
fatal The process is about to exit or is in an unrecoverable state (failed startup config validation, uncaught exception with no recovery path). Triggers an immediate page (24.5). enabled, 100%

24.1.4 Sampling #

  • info-level HTTP request-completion logs are sampled at 100% for all mutating methods (POST, PATCH, PUT, DELETE) and for any GET that returns a 4xx/5xx. Successful GET requests to high-volume, low-risk routes (/v1/videos, /v1/videos/:videoId/analytics/heartbeat, the public /v1/collect analytics endpoint) are sampled at 10% in production to control log volume, with the sample decision made deterministically from the low bits of the request id so a given request is either fully logged or not — no split logging of one logical operation.
  • debug/trace logs, when enabled for an investigation, are sampled at 1% in production unless scoped to a specific workspaceId filter, in which case 100% for that workspace only.
  • warn, error, fatal are never sampled — always 100%.
  • Health-check (/healthz, /readyz) request logs are suppressed entirely unless the check fails.

24.1.5 Never-log list #

The following are never written to any log line, span attribute, or error message, in any environment, under any log level. This is enforced by a lint rule (no-restricted-syntax custom rule flags any log call whose argument tree contains a key matching /password|token|secret|apiKey|sessionToken|cookie|authorization/i) and by a Zod-based log-sanitizer middleware that recursively redacts matching keys to "[REDACTED]" as a defense-in-depth backstop even if the lint rule is bypassed:

  • Passwords, password hashes, and password reset tokens.
  • Session tokens, JWTs, API key values (the sk_live_... secret; the stored prefix for display is fine).
  • OAuth access/refresh tokens, MFA secrets/backup codes.
  • Full transcript or caption text (a transcriptId/segmentId reference is fine; the text is not).
  • Viewer email addresses and any other viewer PII captured via email-gated links (log the viewerToken or emailCaptureId instead).
  • Signed playback URLs and signed upload URLs in full (log the asset id and TTL; never the signature).
  • Full request/response bodies of vendor webhook payloads (log the event type and id; store the verified payload in the DB, not in logs).
  • Redaction region source pixels or any reference that could be used to reconstruct the unredacted original.
  • Raw cursor telemetry coordinate streams (high volume and reconstructible into a behavioral fingerprint; log aggregate counts only, e.g. "312 interest events processed").

24.2 Metrics #

Metrics are emitted via OpenTelemetry Metrics SDK to an OTLP collector and stored in a Prometheus-compatible time-series backend. Every metric name is snake_case, prefixed with the emitting service where the metric is service-specific (api_, worker_) and unprefixed where it is a cross-cutting domain metric.

24.2.1 RED metrics — API (per Section 7 request handling) #

Metric Type Labels Why
api_requests_total counter route, method, status_class (2xx/4xx/5xx) Request rate; the "R" in RED. Drives the availability SLO in 24.4.
api_request_duration_ms histogram (buckets: 5,10,25,50,100,250,500,1000,2500,5000,10000) route, method Latency distribution; the "D" in RED. Drives the latency SLO and Section 27.2 budgets.
api_request_errors_total counter route, error_code (the stable snake_case code from the error envelope, Section 7.6) Error rate broken down by the specific failure, not just status class; the "E" in RED. Lets on-call jump straight to "which error code is spiking" instead of "5xx is up."

24.2.2 USE metrics — resources #

Metric Type Labels Why
db_pool_connections_in_use gauge app (api/worker) Utilization of the Postgres connection pool. Feeds 24.9 scaling triggers and 27.7 connection budgets.
db_pool_wait_ms histogram app Saturation signal — time a request waited for a pool connection. Non-zero sustained wait means the pool is undersized before queries even get slow.
redis_connected_clients gauge Utilization of the shared Redis instance (queues, rate limits, session cache all share it).
redis_memory_used_bytes gauge Saturation signal against the provisioned Redis memory ceiling.
worker_process_cpu_percent gauge queue Utilization of each worker pool; ffmpeg-heavy render/transcode workers are CPU-bound and this is the primary autoscaling input (24.9).
worker_scratch_disk_free_bytes gauge worker_id Saturation of local scratch disk used for ffmpeg working files. A worker that runs out of scratch disk fails every render job it picks up.
worker_job_errors_total counter queue, error_code Error rate per worker pool, independent of job-level metrics below.

24.2.3 Domain metrics #

These are the metrics that determine whether the product actually works, independent of infrastructure health. Each is computed from raw events (job start/complete timestamps, webhook receipt timestamps, client-reported analytics beacons per Section 16) rather than inferred from infrastructure counters.

Metric Type Labels Why it exists
media_upload_to_playable_seconds histogram (buckets: 15,30,60,120,300,600,1200,1800,3600) source_duration_bucket (lt_2m,2m_10m,10m_1h,gt_1h) The single most important product-quality number: how long a user waits between "I finished recording" and "I can share a working link." Directly drives the 24.4 time-to-ready SLO.
transcode_queue_depth gauge queue, priority (standard/priority — Business plan priority queue per Section 21) Backlog size. Feeds the queue-backlog alert (24.5) and the runbook in 24.6.3.
transcode_queue_oldest_job_age_seconds gauge queue, priority Age of the oldest waiting job — a better staleness signal than depth alone, since depth can be high but fresh (a burst) or low but stale (one stuck consumer).
render_jobs_total counter status (succeeded/failed/dead_lettered), preset_version Numerator/denominator for render success rate. Split by preset_version so a regression introduced by a new auto-edit preset version (Section 10) is visible immediately against the previous version's baseline.
transcription_latency_seconds histogram (buckets: 10,30,60,120,300,600,1200) provider Time from audio-extract-complete to transcript-available. Vendor-attributed so a degrading transcription vendor is visible before it trips the outage runbook (24.6.2).
player_start_time_to_first_frame_ms histogram (buckets: 100,250,500,1000,2000,4000,8000) network_type (from navigator.connection.effectiveType where available, else unknown), player_version Client-reported via the analytics beacon (Section 16). The primary viewer-experience metric; drives 27.5 and cross-references the Section 15.2 player budget.
player_rebuffer_ratio histogram (0.0–1.0, buckets: 0,0.01,0.02,0.05,0.1,0.2,0.5,1.0) player_version Fraction of a playback session spent rebuffering. Client-reported. Drives the playback-start SLO and CDN health assessment.
upload_part_failure_rate counter (as ratio of upload_parts_total{status="failed"} / upload_parts_total) failure_reason (network,server_5xx,checksum_mismatch) Multipart upload part failures are expected in isolation (retried transparently per Section 9) but a rising rate indicates a CDN edge or S3-compatible storage regional issue before it becomes a full upload-success-rate SLO breach.
webhook_delivery_success_rate counter (as ratio of webhook_deliveries_total{status="delivered"} / webhook_deliveries_total) endpoint_id Outbound webhook health (Section 20) — a customer's endpoint failing silently erodes trust in the integration; this is also the input to automatic endpoint disabling after sustained failure.

24.3 Tracing #

OpenTelemetry (Node SDK, auto-instrumentation for Fastify, pg/Drizzle, ioredis, undici/fetch, BullMQ) exports OTLP traces to the same collector as metrics. Sampling is head-based at 100% for any request or job containing an error/fatal log line (tail-sampling override) and 10% head-sampled otherwise in production; 100% in local, preview, and staging.

24.3.1 What is instrumented #

  • Every inbound HTTP request to apps/api gets a root span.
  • Every outbound call to a vendor (Mux, the transcription vendor, Stripe, the object storage endpoint, the OAuth provider) gets a child span named for the vendor and operation.
  • Every Drizzle query gets a child span with the parameterized SQL (values redacted) and row count.
  • Every BullMQ job execution gets a root span (jobs are asynchronous, so they do not share a parent with the HTTP request that enqueued them — the link is via traceId propagation in job.data, not span parenting: the enqueuing span records the child job's future traceId as a span link).
  • The render worker's ffmpeg invocation gets a span covering process spawn to exit, with ctx attributes for input rendition count, output rendition, and wall-clock duration — this is the single most useful span for diagnosing the stuck-render runbook (24.6.5).
  • The auto-edit solver run gets a span with attributes for telemetry sample count, interest-event count, and zoom-segment count produced.

24.3.2 Span naming #

Format: <service>.<resource>.<action>, all snake_case segments, e.g.:

  • api.video.create, api.share_link.create, api.auth.session.refresh
  • worker.transcode.request, worker.render.compose, worker.transcription.request
  • db.query.videos.select, db.query.share_links.insert
  • vendor.mux.asset.create, vendor.stripe.subscription.update

24.3.3 Trace-to-log correlation #

Every log line emitted while a span is active includes that span's traceId and spanId (24.1.1). The observability platform's log viewer and trace viewer are cross-linked bidirectionally: a trace view shows a "view logs" affordance scoped to traceId, and a log line's traceId is a clickable deep link to the trace waterfall. This is the primary tool for the runbooks in 24.6 — "here is the exact chain of HTTP request → jobs → vendor calls that produced this failure," not just a stack trace from one process.

24.4 SLOs, error budgets, and burn-rate alerting #

SLOs are computed over a rolling 30-day window unless stated otherwise. Error budget policy: while budget remains, feature velocity is unconstrained; when a budget is fully consumed mid-window, new production deploys to the affected service are paused (except fixes for the SLO breach itself) until the trailing window recovers headroom or leadership explicitly accepts the risk in writing.

SLI SLO target Window Error budget Measured by
API availability 99.9% of requests return non-5xx 30 days 43.2 minutes-equivalent of failed-request budget/month api_requests_total{status_class="5xx"} / api_requests_total
API latency (read endpoints) p95 < 400 ms 30 days 5% of requests may exceed 400 ms api_request_duration_ms histogram, route label class read
API latency (write endpoints) p95 < 800 ms 30 days 5% of requests may exceed 800 ms api_request_duration_ms histogram, route label class write
Playback start success rate 99.5% of playback sessions reach first frame within 5 s of intent 30 days 0.5% of sessions Client beacon: play_intent event followed by first_frame event within 5000 ms
Upload success rate 99.9% of started multipart uploads complete assembly without an unrecoverable failure 30 days 0.1% of uploads uploads_total{status="failed_permanent"} / uploads_total
Time-to-ready, 10-minute recording p95 < 6 minutes from upload-complete to first playable rendition available 30 days 5% of qualifying uploads media_upload_to_playable_seconds{source_duration_bucket="2m_10m"}

24.4.1 Burn-rate alerting #

Each SLO above uses the Google SRE workbook multi-window, multi-burn-rate method with two alert tiers per SLO, so a fast, severe budget burn pages immediately while a slow, sustained burn opens a ticket without waking anyone:

Tier Long window Short window Burn rate threshold Budget consumed if sustained Action
Fast burn (page) 1 hour 5 minutes 14.4x 2% of monthly budget in 1 hour SEV2 page (24.5) — both windows must breach simultaneously to fire, preventing single-spike false pages
Slow burn (ticket) 6 hours 30 minutes 6x 10% of monthly budget in 6 hours Auto-filed ticket, reviewed next business day, no page

Burn rate is computed as (1 - success_ratio_over_window) / (1 - SLO_target). For example, API availability at 99.9% target: a fast-burn alert fires when the 1-hour error ratio exceeds 14.4 * 0.001 = 1.44% AND the 5-minute error ratio also exceeds that threshold (the short window confirms the condition is current, not a 1-hour-old spike that has already resolved).

24.5 Alerting #

24.5.1 Severity levels #

Severity Definition Response Page?
SEV1 Full outage of a core flow (recording, sharing, or playback is down for all workspaces) or a security incident in progress (Section 22). Immediate, all-hands until mitigated. Yes, primary + secondary + eng lead
SEV2 Major degradation (elevated error rate on a core flow, an SLO fast-burn alert, a fully down non-core flow such as integrations). Immediate, primary on-call. Yes, primary
SEV3 Minor degradation, single-workspace or single-feature impact, SLO slow-burn. Next business day. No — ticket only
SEV4 Cosmetic, no user impact, or a known-benign transient. Backlog. No

24.5.2 Alert catalogue #

Alert Trigger Severity Source
API fast burn 24.4.1 fast-burn tier, any API SLO SEV2 Metrics
API 5xx spike api_requests_total{status_class="5xx"} rate > 5% for 5 consecutive minutes SEV2 Metrics
Queue backlog transcode_queue_oldest_job_age_seconds > 900 for 10 consecutive minutes SEV2 Metrics, runbook 24.6.3
DLQ growth any *.dlq queue depth increases by > 10 in 15 minutes SEV2 Metrics, runbook 24.6.4
Streaming vendor errors Mux API error rate > 10% over 5 minutes, or Mux webhook delivery gap > 10 minutes SEV1 Metrics + vendor status page poll, runbook 24.6.1
Transcription vendor errors transcription job failure rate > 20% over 15 minutes SEV2 Metrics, runbook 24.6.2
Database replication lag read replica lag > 30 s SEV2 Metrics
Database failover primary unreachable, standby promotion triggered SEV1 RDS event, runbook 24.6.10
Storage quota exhaustion any workspace object storage usage > 100% of plan quota and writes are being rejected SEV3 (SEV2 if it is a systemic bucket-level quota, not a per-workspace plan cap) Metrics, runbook 24.6.7
Redis memory pressure redis_memory_used_bytes > 90% of provisioned SEV2 Metrics
Webhook delivery degradation webhook_delivery_success_rate < 90% for any endpoint over 1 hour SEV3 Metrics
TLS certificate expiry any custom domain (Section 21 Business plan feature) certificate expires in < 14 days SEV3 Cert monitor
Deploy health check failure new revision fails /readyz for > 3 minutes post-deploy SEV2 Deploy pipeline, triggers auto-rollback (26.4.4)
Leaked API key secret-scanning hit on a public code host, or anomalous key usage pattern (24.6.8) SEV1 GitHub secret scanning + anomaly detection job, runbook 24.6.8
Video incorrectly public automated audit job (22.x) detects a video with visibility=public and no corresponding intentional-publish event within the audit log SEV1 Nightly audit job, runbook 24.6.9

24.5.3 Routing and on-call posture #

Reelay runs a small engineering team: a single weekly on-call rotation with one primary and one secondary, both drawn from the backend/infrastructure engineers (the desktop and frontend specialists are not in the rotation but are reachable for SEV1 escalation specific to their area). Routing:

  • SEV1/SEV2 → PagerDuty-style page to primary; unacknowledged after 5 minutes escalates to secondary; unacknowledged after a further 10 minutes escalates to the engineering lead.
  • SEV3 → filed as a ticket in the team's issue tracker, triaged in the next daily standup.
  • SEV4 → filed as a ticket, no triage SLA.
  • A SEV1 additionally notifies the status page (24.10.3) and, if customer-visible for more than 15 minutes, triggers the SEV1 comms template.

24.5.4 Anti-noise rules #

  • Every alert requires a sustained breach — the shortest allowed evaluation window is 5 minutes; no alert fires on a single data point.
  • Alerts fire on symptoms (error rate, latency, backlog age) rather than causes (CPU%, individual pod restarts) wherever a symptom-level alert exists, so on-call is paged for user impact, not noise. Cause-level signals (CPU, memory, disk) are visible on dashboards and drive autoscaling (24.9) but do not page independently unless they are a direct precursor to imminent failure (e.g. scratch disk < 5% free does page, since it causes immediate render failures).
  • Alerts are deduplicated and grouped by a fingerprint of (alert_name, affected_service) — a queue backlog alert that would otherwise fire once per queue fires as one grouped notification listing all affected queues.
  • A 15-minute mute window is automatically applied to deploy-health-adjacent alerts (latency, error rate) for the service being deployed, starting at deploy start and explicitly annotated on the timeline — this prevents an expected brief blip during a rolling deploy from paging, while the auto-rollback health check (26.4.4) still runs unmuted since it is a synchronous deploy gate, not an alert.
  • Alerts auto-resolve when the underlying condition clears for 2 consecutive evaluation windows, and the resolution is posted to the same channel as the original page so on-call has closure without needing to poll.
  • Flapping suppression: if an alert fires and resolves 3+ times within 30 minutes, it is escalated to SEV one level higher and stays open until manually acknowledged, on the theory that flapping itself is the incident.

24.6 Runbooks #

Each runbook assumes access to: the observability platform (logs/metrics/traces per 24.1–24.3), psql against a read replica, redis-cli against the Redis instance, the BullMQ operator surface (24.8), the cloud provider CLI, and the deploy pipeline's rollback control (26.4.4).

24.6.1 Streaming-vendor (Mux) outage #

Symptoms: vendor.mux.* spans show elevated errors/timeouts; media_upload_to_playable_seconds climbs; new asset creation fails; the "streaming vendor errors" alert (24.5.2) fires.

Diagnosis: (1) Check Mux's status page. (2) SELECT count(*), error_code FROM jobs_audit WHERE queue = 'video.transcode.request' AND created_at > now() - interval '15 minutes' AND status = 'failed' GROUP BY error_code ORDER BY 1 DESC; — confirm failures are vendor-side (mux_5xx, mux_timeout). (3) Check vendor.mux.asset.create/vendor.mux.upload.create span error rate for the last 30 minutes.

Mitigation: (1) On a confirmed outage, flip MUX_CIRCUIT_BREAKER_OPEN (26.7) so jobs fail fast to a "processing delayed" video state instead of retrying into the outage. (2) Do not cancel in-flight jobs — standard backoff (Section 9) retries once the vendor recovers. (3) Confirm scope: already-transcoded renditions are unaffected (served from CDN-fronted storage, independent of Mux); only new processing is impacted. (4) Post a status page update (24.10.3) if the outage exceeds 15 minutes.

Follow-up: flip the breaker back off once recovered, verify the queue drains (24.6.3), confirm render_jobs_total{status="failed"} returns to baseline, and file a postmortem (24.10.4) if any SLO was breached.

24.6.2 Transcription-vendor outage #

Symptoms: transcription_latency_seconds climbs or job failures spike; captions/chapters/AI metadata (Section 12) are missing on otherwise-ready videos.

Diagnosis: (1) Check the vendor's status page. (2) SELECT count(*) FROM jobs_audit WHERE queue = 'transcription.request' AND status = 'failed' AND created_at > now() - interval '30 minutes';. (3) Confirm the video itself is playable — transcription is a parallel branch (Section 9) and never blocks readiness, so this is never a SEV1.

Mitigation: (1) Jobs retry per the standard 5-attempt policy; no action needed for a short outage. (2) Past 1 hour, flip TRANSCRIPTION_CIRCUIT_BREAKER_OPEN (26.7) so jobs fail fast to the DLQ instead of holding worker slots. (3) Notify affected workspaces in-app that captions/AI features are delayed.

Follow-up: once recovered, replay the fast-failed DLQ jobs (24.8) in batches of 100 to avoid re-triggering vendor rate limits.

24.6.3 Queue backlog #

Symptoms: transcode_queue_oldest_job_age_seconds alert fires; media_upload_to_playable_seconds p95 exceeds SLO.

Diagnosis: (1) Identify the affected queue(s) from the alert payload. (2) redis-cli -h <redis-host> LLEN bull:video.transcode.request:wait to confirm depth directly. (3) Check worker_process_cpu_percent{queue=...} — near 100% is a capacity problem (24.9); idle workers with a growing queue is a stuck-consumer or crash-loop. (4) Check worker logs for repeated restarts around the onset time.

Mitigation: (1) Capacity problem: scale the worker pool beyond its autoscaling ceiling as an immediate relief valve (aws ecs update-service --service worker-transcode --desired-count <n> or equivalent), then investigate why autoscaling (24.9) did not react. (2) Stuck-consumer: restart the worker deployment — BullMQ's lock (30 s, renewed by the active worker) expires and the job returns to wait. (3) If one job is the actual blocker, see 24.6.5.

Follow-up: cross-reference 24.6.1/24.6.2 if vendor-caused; otherwise feed the observed peak into the next capacity review (24.9).

24.6.4 DLQ growth #

Symptoms: the "DLQ growth" alert fires — a *.dlq queue accumulates jobs faster than baseline.

Diagnosis: (1) pnpm --filter worker dlq:list --queue=video.render.compose.dlq --limit=50 (24.8). (2) Group by err.code: one systemic bug, or diverse organic failures. (3) Compare growth rate to the historical baseline — organic growth (malformed uploads) is expected; growth beyond it means something new broke.

Mitigation: (1) Systemic: identify the introducing deploy via the preset_version/version label and roll back (26.4.4) if timing correlates. (2) Organic: no rollback — confirm affected videos surface a clear "processing failed" state rather than hanging. (3) Never bulk-replay before fixing root cause — it just re-fills the DLQ and burns another 5-attempt budget per job.

Follow-up: once fixed and verified against a sample replay, bulk-replay the remainder in batches of 100 with a 30 s pause to avoid re-triggering the backlog alert.

24.6.5 A stuck render #

Symptoms: a video is stuck "processing"; its render job has been active far longer than the p99 render duration.

Diagnosis: (1) pnpm --filter worker dlq:list --active --queue=video.render.compose or query jobs_audit for the jobId (<videoId>:render:<version>) to find the holding worker and lock age. (2) Pull the traceId and inspect the ffmpeg span (24.3.1) — still running, or exited without the worker observing it? (3) ps aux | grep ffmpeg on the worker host — a hung process is commonly a malformed/truncated source causing a read that never completes.

Mitigation: (1) Expired lock but job still active: use the DLQ surface's "requeue stuck active job" action (a wrapper over BullMQ's Job.moveToWaiting). (2) Hung ffmpeg: kill the process — the stalled-job detector (checked every 30 s) marks it stalled and it retries on another worker. (3) If the same job stalls on every attempt, let it exhaust its 5 attempts into the DLQ rather than forcing success.

Follow-up: if stalls cluster on a codec/resolution combination, file an ffmpeg hardening item (explicit read/decode timeout).

24.6.6 A failed deletion-verification #

Symptoms: the retention/deletion job (Section 19, verify-then-confirm, never fire-and-forget) reports a purge that did not actually remove the object(s) on verification.

Diagnosis: (1) SELECT * FROM deletion_requests WHERE status = 'verification_failed' ORDER BY created_at DESC LIMIT 20;. (2) For each, check whether the verification HEAD call found the object still present, or hit an unrelated error (permissions, throttling) misclassified as a verification failure. (3) Confirm the delete call itself actually reached object storage (check the vendor call span).

Mitigation: (1) Object genuinely still present: a correctness bug — SEV2 minimum, since it contradicts the Section 19 invariant; re-issue the delete via admin tooling and re-verify. (2) False negative (e.g. throttled HEAD): re-run verification only. (3) Never mark a row completed without a passing verification.

Follow-up: any confirmed "told the user it was deleted and it wasn't" case gets a postmortem (24.10.4) regardless of severity — this is a trust-critical invariant.

24.6.7 Storage-quota exhaustion #

Symptoms: a workspace hits its plan quota (Section 21) and new uploads are rejected with storage_quota_exceeded; distinguish an expected single-workspace cap (SEV3) from unexpected bucket-level pressure (SEV2).

Diagnosis: (1) SELECT workspace_id, sum(size_bytes) FROM media_assets WHERE workspace_id = $1 AND deleted_at IS NULL GROUP BY 1; cross-checked against usage_counters. (2) Confirm the 80%/100% warning emails (Section 21) were sent — query email_log. (3) Bucket-level: check total usage against the provisioned ceiling in the provider console.

Mitigation: (1) Single workspace: expected behavior — creation is blocked, playback unaffected (Iron Rule, Section 21); user is prompted to upgrade or delete. No engineering action beyond confirming warning emails fired. (2) Bucket-level: object storage is usage-based with no hard ceiling, so this is an unexpected-growth-rate investigation (e.g. a looping render job re-writing renditions), not a capacity wall.

Follow-up: feed the observed growth rate into the cost model (26.9) and capacity plan (24.9).

24.6.8 A leaked API key #

Symptoms: secret scanning flags an sk_live_... pattern in a public repository, or anomaly detection flags unusual key usage (IP/geography/volume).

Diagnosis: (1) Match the leaked prefix against api_keys.prefix (the full key is never stored, only a SHA-256 hash + prefix, Section 16). (2) SELECT * FROM audit_events WHERE actor_type = 'api_key' AND actor_id = $1 ORDER BY created_at DESC LIMIT 100; to assess blast radius before revocation.

Mitigation: (1) Revoke immediately: UPDATE api_keys SET revoked_at = now() WHERE id = $1; — the auth middleware checks revoked_at IS NULL on every request (max 30 s Redis cache, Section 6), so this takes effect essentially immediately. (2) Notify the workspace owner/admins by email with instructions to issue a new key. (3) If the audit trail shows use beyond the key's documented scope (Section 6), escalate to the Section 22 security incident process, which supersedes this runbook.

Follow-up: confirm the leak source. If it originated in Reelay's own systems (not the customer's), this is SEV1 regardless of the steps above being completed.

24.6.9 A video incorrectly made public #

Symptoms: the nightly audit job (diffing share_links.visibility = 'public' rows against share_audit_events for a matching intentional-actor event) flags an unexplained public link, or a customer reports one.

Diagnosis: (1) SELECT * FROM share_audit_events WHERE share_link_id = $1 ORDER BY created_at ASC; to reconstruct the visibility history. (2) Determine whether it was (a) a forgotten intentional action, (b) a UI/API bug, or (c) unauthorized account access.

Mitigation: (1) Immediately set visibility = 'private' via admin tooling regardless of root-cause status — containment first. (2) Signed playback URLs expire in 6 hours (Section 14), but do not rely on TTL as the mitigation — revoke now. (3) Notify the workspace owner of the exposure window and what was exposed.

Follow-up: always a postmortem (24.10.4) — this is the product's most sensitive trust boundary (Section 14). If root cause was (b), it is SEV1 and blocks further deploys of the affected path until fixed and covered by a negative-authorization regression test (25.9).

24.6.10 Database failover #

Symptoms: the primary becomes unreachable and the managed service promotes a standby; the failover alert fires from the provider's failover event, not an internal metric.

Diagnosis: (1) Confirm failover completion in the provider console (typically under 2 minutes). (2) api_request_errors_total{error_code="database_unavailable"} — confirm the API is surfacing 503s cleanly, not hanging. (3) Check pooler (26.8) reconnection; restart it if requests still fail after the provider confirms completion.

Mitigation: (1) During the gap, the API returns 503/service_unavailable with Retry-After; workers pause consumption rather than crash-looping (jobs stay safely queued in Redis). (2) If reconnection does not occur within 3 minutes of provider-confirmed completion, restart the API and worker fleets. (3) Confirm health: SELECT pg_is_in_recovery(); returns false on the new primary.

Follow-up: always a postmortem (24.10.4) — reviewed for whether it was provider-side (informational) or preceded by a detectable degradation Reelay should have caught earlier.

24.6.11 A bad player release affecting live embeds #

Symptoms: a new packages/player release (Section 15.14) is live; player_start_time_to_first_frame_ms or the client-side error beacon spikes; embeds across the web show broken/blank players.

Diagnosis: (1) Confirm timing correlates with the release timestamp (deploy events are annotated per 24.10.4's timeline practice). (2) Check the error beacon's signature and browser/version distribution to scope the blast radius.

Mitigation: (1) The embed loader resolves the player core version via a CDN-served manifest, not a hardcoded URL — revert the manifest to the last-known-good version immediately. (2) The manifest's cache TTL is deliberately short (60 s, Section 15.14) for exactly this reason; purge the CDN cache for the manifest path if 60 s is not fast enough. (3) Do not hotfix forward under pressure — revert first, fix forward through the normal release process once stable.

Follow-up: postmortem required (24.10.4) — this has the widest blast radius of any failure mode since it affects third-party pages, and the fix must add whatever regression coverage (25.8) would have caught it before release.

24.7 Health checks, readiness, liveness, and dependency health #

Endpoint Purpose Checks Used by
GET /healthz Liveness Process is running and the event loop is not blocked (a lightweight event-loop-lag check, no dependency calls). Returns 200 unless the process itself is unhealthy. Orchestrator's liveness probe — a failing liveness check kills and restarts the container.
GET /readyz Readiness Postgres reachable (SELECT 1) and migrations at the expected version (compares drizzle_migrations table's latest applied id against the version baked into the deployed image); Redis reachable (PING); does NOT check third-party vendors (a Mux outage should not remove the API from the load balancer — it should degrade specific features, per 24.6.1). Returns 503 if any check fails. Orchestrator's readiness probe — a failing readiness check removes the instance from the load balancer without killing it, so it can recover without a disruptive restart. Also gates the auto-rollback check in 26.4.4.
GET /internal/health/dependencies Dependency health (internal/admin only, not public) Reports current circuit-breaker state (24.6.1, 24.6.2) for each vendor, last successful call timestamp per vendor, and current queue depths. The internal ops dashboard and the runbooks in 24.6.

Liveness and readiness are deliberately separate: a process stuck in a GC pause or infinite loop should be restarted (liveness failure); a process that is fine but temporarily cannot reach Postgres during a failover (24.6.10) should be taken out of rotation, not killed, since killing it does not help and adds restart churn.

24.8 The DLQ operator surface #

Every BullMQ queue <name> has a corresponding dead-letter queue <name>.dlq (Section 9) that receives a job's final payload and full error history after its 5th failed attempt. The operator surface is a CLI (pnpm --filter worker dlq:*) backed by the same BullMQ connection, plus a read-only view in the internal admin dashboard for non-CLI users. All operator actions are themselves recorded in jobs_audit with the acting user, so DLQ manipulation is itself audited.

# List recent dead-lettered jobs for a queue, most recent first.
pnpm --filter worker dlq:list --queue=video.render.compose.dlq --limit=50

# Inspect one job's full payload and error history. PII fields (per the never-log list, 24.1.5)
# are redacted in the default view; --unsafe-show-pii requires a second-factor confirmation and
# itself writes an audit_events row.
pnpm --filter worker dlq:inspect --job-id=vid_5RqLpT9:render:3

# Edit a job's payload before replay — used when the fix is "this job's input was malformed in a
# specific, correctable way" (e.g. a stale preset_version reference). Requires --reason, stored in
# jobs_audit. The edited payload is re-validated against the job's Zod schema before it is accepted;
# an invalid edit is rejected, not silently stored.
pnpm --filter worker dlq:edit --job-id=vid_5RqLpT9:render:3 --set preset.version=4 --reason "stale preset ref, see INC-142"

# Replay: moves the job back to its origin queue with attempt count reset to 0. Single job:
pnpm --filter worker dlq:replay --job-id=vid_5RqLpT9:render:3

# Bulk replay: requires --confirm and is rate-limited to 100 jobs per invocation with a mandatory
# 30s pause between batches (enforced by the script, not just documented) to avoid re-triggering
# the queue-backlog alert (24.5.2) or a vendor rate limit from a thundering-herd replay.
pnpm --filter worker dlq:replay --queue=video.render.compose.dlq --error-code=ffmpeg_nonzero_exit --confirm

# Purge: permanently discards jobs that will never be replayed (confirmed-unrecoverable input,
# e.g. a permanently corrupt source file). Requires --reason and a workspace-notification flag
# indicating whether the affected user was told their content failed to process.
pnpm --filter worker dlq:purge --job-id=vid_9TmXq2:transcode:1 --reason "source file corrupt beyond recovery" --notified=true

Safety guards: dlq:replay on a queue whose error is still systemic (24.6.4) is a foot-gun the CLI cannot prevent programmatically, so replay output always prints a reminder to confirm root cause is fixed before bulk-replaying. dlq:edit is scoped to a documented allow-list of fields per job type (defined alongside each job's Zod schema) — arbitrary payload mutation is not permitted, only the specific fields each job type declares as operator-editable.

24.9 Capacity planning and scaling triggers #

Component Signal Scale-out trigger Scale-in trigger Notes
apps/api api_pod_cpu_percent > 70% average over 5 min < 30% average over 15 min Horizontal autoscaling, min 2 / max 20 instances at launch scale (27.1).
apps/worker — transcode-orchestration queue transcode_queue_depth, worker_process_cpu_percent queue depth > 50 OR CPU > 75% over 5 min queue depth = 0 AND CPU < 25% over 15 min Orchestration is lightweight (calls Mux, does not run ffmpeg itself) — scales on queue depth primarily.
apps/worker — render/compose queue worker_process_cpu_percent, transcode_queue_oldest_job_age_seconds CPU > 80% over 5 min OR oldest job age > 300 s CPU < 30% over 20 min CPU-bound (ffmpeg). Scale-in is deliberately slower than scale-out to avoid thrashing during bursty render load.
apps/worker — transcription queue queue depth queue depth > 100 queue depth = 0 over 15 min Mostly I/O-bound waiting on the vendor; low resource footprint per worker, scale conservatively.
PostgreSQL db_pool_connections_in_use, disk usage connection utilization > 80% sustained → add a read replica or raise pool ceiling; disk > 75% → provision more storage — (databases do not scale in) Read replica addition is a capacity-planning decision made ahead of the trigger, not a reactive autoscale (Section 26.8).
Redis redis_memory_used_bytes > 80% of provisioned memory Redis holds queues, rate limits, and session cache — a memory-pressure eviction here silently drops queued jobs, so this trigger has no scale-in counterpart and is treated as an immediate capacity-increase action, not autoscaling.
Object storage usage growth rate vs. cost model (26.9) no hard scaling action — usage-based, effectively unlimited Tracked for cost forecasting, not availability.
CDN cache hit ratio, origin request rate origin request rate exceeding the provisioned origin fleet's headroom CDN itself scales automatically at the provider; this signal instead feeds back into origin (object storage + API) capacity planning.

Capacity reviews happen monthly at launch scale and weekly once workspace count exceeds 500, using the 30-day peak (not average) of each signal above as the planning input, with a 40% headroom target over observed peak for every component that has a scale-in counterpart.

24.10 Incident management #

24.10.1 Severity definitions #

Reuses the severities defined in 24.5.1 (SEV1–SEV4) as the single incident-severity scale across alerting and incident management — there is no separate numbering.

24.10.2 Incident process #

  1. Declare: the first responder (whoever acknowledges the page, or whoever notices a customer-reported issue that meets a SEV1/SEV2 bar) declares an incident, which creates an incident channel and an incident record with a timestamped timeline.
  2. Mitigate: follow the relevant runbook (24.6) if one exists; otherwise investigate using 24.1–24.3. All actions taken are logged to the incident timeline in real time, not reconstructed afterward.
  3. Communicate: SEV1/SEV2 incidents post to the status page (24.10.3) using the templates below within 15 minutes of declaration.
  4. Resolve: declared resolved when the triggering condition has cleared and stayed clear for at least 2 alert-evaluation windows (consistent with the auto-resolve rule in 24.5.4).
  5. Postmortem: required for every SEV1, every SEV2 that breached an SLO error budget, and any incident explicitly flagged in its runbook as postmortem-required (24.6.6, 24.6.9, 24.6.10, 24.6.11) — see 24.10.4.

24.10.3 Status page and comms templates #

The public status page (a subdomain, e.g. status.reelay.app) has independently-tracked components: API, Recording & Upload, Video Processing, Playback, Dashboard, Integrations & Webhooks. Only SEV1/SEV2 incidents with customer-visible impact are posted; SEV3/SEV4 are not, to keep the page a reliable signal.

Initial post (within 15 minutes of declaration):

We are investigating an issue affecting [component]. Some users may experience [plain-language symptom, e.g. "delays in video processing"]. We will post an update within 30 minutes.

Update (every 30 minutes until resolved, even if the update is "still investigating"):

Update: we have identified [plain-language cause, only once confirmed — never speculative] and are [working on a fix / monitoring the fix]. Next update by [time].

Resolved:

This incident has been resolved as of [time]. [One sentence on root cause if known and safe to share publicly; otherwise "Root cause is under investigation and a summary will follow."]

24.10.4 Postmortem practice #

Postmortems are blameless: the document explains what happened and why the systems and processes allowed it to happen, never who made a mistake. Required within 5 business days of resolution for any incident meeting the criteria in 24.10.2 step 5.

Template sections (fixed structure, stored as a document in the team's knowledge base — not part of this specification's own artifact set):

  1. Summary — one paragraph, what happened, customer impact, duration.
  2. Timeline — timestamped, built from the incident channel log and the observability platform's deploy/alert annotations (24.5.4), not reconstructed from memory.
  3. Root cause — the technical cause, traced to a specific commit, config change, or vendor event where possible.
  4. What went well — detection speed, runbook effectiveness, communication.
  5. What went poorly — detection gaps, missing runbook, alert noise or silence, anything that slowed mitigation.
  6. Action items — each with an owner and a due date, filed as tracked tickets, reviewed for completion in the following month's capacity/reliability review (24.9). An action item without an owner and due date is not a valid postmortem action item.

25. Testing Strategy & Quality Assurance #

25.1 The testing pyramid #

Layer Target share of total tests Target coverage Tooling
Unit ~70% 85% line coverage on packages/shared, packages/db query builders, the auto-edit solver (Section 10), and EDL operations (Section 11); 75% on apps/api route handlers' business logic (excluding thin HTTP glue) Vitest 4.x (25.2)
Integration ~20% Every API route has at least one integration test exercising it against a real Postgres and Redis; every BullMQ job handler has at least one test exercising a full enqueue-to-completion cycle Vitest 4.x + testcontainers (25.3)
End-to-end ~10% Every critical user journey (25.4) has one E2E test; E2E is not measured by line coverage Playwright 1.62.x (25.4)

What coverage percentage does and does not prove. A line-coverage number proves that a line executed during a test run at least once; it proves nothing about whether the assertions on that execution were meaningful, whether the input space around that line was adequately explored, or whether the test would actually fail if the logic were wrong. Coverage is treated as a floor that catches "this code path has zero tests," never as a target to optimize past a diminishing-returns point, and never as a substitute for the specific testing disciplines in 25.5–25.10 that target the product's actual risk surface (the auto-edit solver's numerical stability, redaction's security property, authorization's completeness) which line coverage cannot verify by construction — a line can execute inside an assertion that is trivially true. The merge gate (25.13) enforces coverage floors per package but treats a coverage drop on a PR as a stronger signal than an absolute number, since it flags untested new code directly.

25.2 Unit testing with Vitest 4.x #

What is unit tested: pure functions and modules with mockable boundaries — Zod schema validation logic, the auto-edit solver's individual stages (interest-event detection, zoom-timeline construction, spring integration, One-Euro filtering — Section 10), EDL operation application/reversion (Section 11), pricing and usage-limit calculation (Section 21), permission-check logic (Section 6), the render worker's FFmpeg argument-building functions (not FFmpeg execution itself — that is integration-tested), and React component logic in packages/ui via @testing-library/react for behavior, not visual output.

Mocking policy:

  • Network boundaries (vendor SDKs: Mux, transcription vendor, Stripe, the OAuth provider, object storage) are always mocked at the unit layer — a unit test never makes a real network call, including to localhost test doubles. Use hand-written fakes implementing the same TypeScript interface the vendor-swappable abstraction defines (Section 3's vendor-swappable Mux interface is the seam), not deep mocking of the vendor's own SDK client, so the fake's contract is explicit and typed.
  • Database access is NOT mocked in code that will also be integration-tested — if a function's only logic is "call Drizzle then return," it does not get a unit test at all; it is covered by the integration layer (25.3). Unit tests are reserved for logic with real branching/computation, not thin data-access wrappers.
  • Time is never read directly from Date.now()/new Date() inside testable business logic; see the deterministic-time rule below.
  • Math.random() and any randomness (share slug generation, jitter in backoff) is injected via a seedable RNG interface in packages/shared, never called directly, so tests can assert exact output for a given seed.

Deterministic-time rule: any function whose behavior depends on the current time (expiry checks, retry backoff scheduling, createdAt stamping, the 30-day viewer token rotation, the 6-hour signed-URL TTL) accepts a now: () => Date parameter (defaulting to the real Date.now in production call sites via packages/shared's systemClock) rather than calling Date.now() internally. Tests inject a fixed clock. Where a third-party library reads the system clock directly and cannot accept an injected clock, tests use vi.useFakeTimers({ now: <fixed timestamp> }) and always call vi.useRealTimers() in an afterEach to prevent leakage into subsequent tests — leakage is caught in CI by a lint rule requiring every useFakeTimers call site to have a matching afterEach in the same file.

// packages/shared/src/clock.ts
export interface Clock { now(): Date }
export const systemClock: Clock = { now: () => new Date() }

// example: share link expiry check, packages/shared/src/share-links.ts
export function isExpired(link: { expiresAt: Date | null }, clock: Clock = systemClock): boolean {
  return link.expiresAt !== null && clock.now() >= link.expiresAt
}

// test
import { describe, it, expect } from 'vitest'
import { isExpired } from './share-links'

describe('isExpired', () => {
  it('is true exactly at the expiry instant', () => {
    const fixed = new Date('2026-01-01T00:00:00.000Z')
    const clock = { now: () => fixed }
    expect(isExpired({ expiresAt: fixed }, clock)).toBe(true)
    expect(isExpired({ expiresAt: new Date(fixed.getTime() + 1) }, clock)).toBe(false)
  })
})

25.3 Integration testing #

Integration tests exercise real Postgres 17 and real Redis 8.x — never an in-memory or SQLite substitute, because Drizzle query behavior, constraint enforcement, and partition routing (video_view_events, Section 16) must be verified against the actual engine. Both run via testcontainers (the Node.js port), spun up once per test-file worker and torn down after, using the same Docker images pinned in the local docker-compose file (26.3) so behavior is identical between local development and CI.

Transaction-rollback fixture pattern. Each test wraps its work in a Postgres transaction (BEGIN before the test, ROLLBACK after) via a test-scoped Drizzle client, so tests never leave residue and never need manual cleanup logic, and can run with full parallelism against a single shared testcontainer instance (rather than paying container-startup cost per test file):

// apps/api/test/setup/db.ts
import { drizzle } from 'drizzle-orm/node-postgres'
import { Pool } from 'pg'
import * as schema from '@reelay/db/schema'

let pool: Pool

export async function withTransactionalDb<T>(fn: (db: ReturnType<typeof drizzle>) => Promise<T>): Promise<T> {
  const client = await pool.connect()
  try {
    await client.query('BEGIN')
    const db = drizzle(client, { schema })
    const result = await fn(db)
    return result
  } finally {
    await client.query('ROLLBACK')
    client.release()
  }
}

A handler that itself opens a nested transaction (e.g. the deletion-verification flow, Section 19) uses a SAVEPOINT internally; the fixture's outer ROLLBACK still discards everything regardless of how many savepoints the code under test created.

Queue testing. BullMQ tests run against the same Redis testcontainer with real Queue/Worker instances (no BullMQ mocking) but with removeOnComplete/removeOnFail disabled so the test can assert on job state after processing, and with backoff delays overridden to near-zero via a test-only queue config so a test asserting retry behavior does not wait through the real 500ms–30s backoff (Section 9). A full enqueue-to-completion integration test looks like:

// apps/worker/test/integration/video-transcode.test.ts
it('processes a transcode request end to end against a fake Mux', async () => {
  const queue = new Queue('video.transcode.request', { connection: redisTestConnection })
  const worker = createTranscodeWorker({ connection: redisTestConnection, muxClient: fakeMuxClient })
  await queue.add('video.transcode.request', { videoId: 'vid_test1', requestId: 'req_test1' },
    { jobId: 'vid_test1:transcode:1' })
  const result = await waitForJobCompletion(queue, 'vid_test1:transcode:1', { timeoutMs: 5000 })
  expect(result.status).toBe('completed')
  await worker.close()
})

Vendor mocking at the HTTP boundary. Integration tests mock vendors one layer lower than unit tests: not by faking the TypeScript interface, but by intercepting the actual outbound HTTP call (via msw's Node server) and returning a canned vendor response body captured from a real sandbox call. This verifies the vendor SDK's request construction and response parsing, which the unit-layer interface fake does not, while still avoiding real network calls and vendor rate limits/costs in CI. Recorded fixtures live in apps/worker/test/fixtures/vendor-responses/ and are refreshed manually against each vendor's sandbox whenever that vendor's contract changes, not automatically re-recorded on every run.

25.4 E2E testing with Playwright 1.62.x #

Critical user journeys covered end to end, each as a dedicated Playwright test spec:

  1. Sign up, verify email, create a workspace, record a short screen capture in-browser, confirm it appears processing then ready in the library.
  2. Create a share link with a password and expiry, open it in an unauthenticated context, confirm password gate and successful playback.
  3. Edit a video's EDL (trim a silence gap, verify the trimmed segment is skipped on playback, restore it, verify the original timeline plays back exactly as before — cross-references 25.6).
  4. Add a redaction region to a video, confirm the delivered rendition has the region blurred and the video cannot be shared while redaction is pending (cross-references 25.7).
  5. Invite a member to a workspace, accept the invite, confirm role-appropriate access (25.9 cross-reference).
  6. Hit a plan limit (Free plan library cap) and confirm creation is blocked while existing shared links still play. The assertion oracle is explicit, not just "no error thrown": the test (a) issues a create-video call after the workspace crosses the cap and asserts 403 with error code plan_limit_exceeded; (b) for a video that was already shared before the cap was hit, fetches a fresh signed playback token through the existing share link's playback-token endpoint (the Section 14.8.1 access chain — never a client-generated viewer token) and asserts 200 with a token whose player reaches the first_frame event, in the fake-media Chromium project below, within the budget in 27.5. This proves the Iron Rule (Section 21) end to end — that playback keeps working, not merely that the creation endpoint returns the right error code while playback goes unexercised.
  7. Embed a video on a third-party test page via the embed script and confirm it lazy-loads and plays (Section 15).
  8. Submit a comment and a CTA email capture as a viewer, confirm both appear in the dashboard's engagement view (Section 17).
  9. Cancel a subscription, confirm downgrade behavior matches the Iron Rule (existing content unaffected, new creation gated).
  10. Desktop app: record with the Electron app (run via Playwright's Electron support), confirm OS audio capture toggle and local-first write-to-disk resilience across a simulated app restart mid-upload.

Fake device streams in CI. Browser-based recording tests (journeys 1, 3, 4, 7) run against Chromium launched with synthetic media so CI does not depend on a real camera/microphone/screen and produces byte-identical input across runs:

// apps/web/playwright.config.ts (excerpt)
export default defineConfig({
  projects: [
    {
      name: 'chromium-fake-media',
      use: {
        browserName: 'chromium',
        launchOptions: {
          args: [
            '--use-fake-device-for-media-stream',
            '--use-fake-ui-for-media-stream',
            '--use-file-for-fake-video-capture=./test/fixtures/media/synthetic-720p-30s.y4m',
            '--use-file-for-fake-audio-capture=./test/fixtures/media/synthetic-tone-30s.wav',
            '--auto-select-desktop-capture-source=Entire screen',
          ],
        },
      },
    },
  ],
})

--use-fake-device-for-media-stream and --use-fake-ui-for-media-stream together satisfy getUserMedia without a real device and without a permission prompt; --use-file-for-fake-video-capture feeds a checked-in Y4M raw video file as the synthetic camera/screen source so the recorded output is deterministic and content-verifiable (the test can assert on known frames in the fixture, e.g. a specific color bar appearing at a specific timestamp, to confirm the capture pipeline did not drop or reorder frames); --auto-select-desktop-capture-source bypasses the OS-level screen-picker dialog that getDisplayMedia would otherwise show, which Playwright cannot interact with since it is outside the browser's DOM.

25.5 Testing the auto-edit engine #

The auto-edit engine (Section 10) is the product's differentiator and gets a dedicated testing strategy beyond the general unit/integration/E2E split, because its correctness is defined by numerical and temporal properties that example-based tests alone cannot adequately cover.

25.5.1 Golden fixture telemetry files with expected EDL outputs #

A checked-in library of cursor telemetry fixture files (apps/worker/test/fixtures/telemetry/*.json, one file per interesting scenario: rapid clicking, a drag-select, a typing burst, a scroll-heavy walkthrough, a window-focus change, an idle period, telemetry recorded at the degraded 60 Hz browser rate) is run through the full auto-edit solver, and the resulting EditDecisionList output (zoom timeline specifically) is compared field-by-field against a checked-in expected output file. A mismatch fails the test with a diff of exactly which zoom segment's start/end/level/easing changed, making a regression immediately legible instead of a generic "output changed" failure.

// apps/worker/test/golden/auto-edit-solver.test.ts
import { describe, it, expect } from 'vitest'
import { solveZoomTimeline } from '../../src/auto-edit/solver'
import fixtures from '../fixtures/telemetry/manifest.json'

describe.each(fixtures)('auto-edit solver golden fixture: $name', ({ name, telemetryFile, expectedEdlFile, presetVersion }) => {
  it('produces the exact expected EDL', async () => {
    const telemetry = await loadFixture(telemetryFile)
    const expected = await loadFixture(expectedEdlFile)
    const result = solveZoomTimeline(telemetry, { presetVersion })
    expect(result).toEqual(expected)
  })
})

Golden fixtures are updated only via an explicit, reviewed pnpm auto-edit:regen-golden command that regenerates expected outputs from current solver code — never silently accepted by re-running tests, which would defeat the purpose. A PR that changes golden fixture outputs must explain why in the PR description and is reviewed with extra scrutiny given the solver's determinism requirement (Section 10).

25.5.2 Deterministic snapshot testing of the solver #

Beyond the curated golden fixtures, every solver run in CI asserts the determinism requirement directly: running the same fixture through the solver twice must produce byte-identical JSON output (not just structurally equal — identical serialization, since the EDL is stored and diffed as JSON, Section 10).

it('is byte-identical across repeated runs on identical input', () => {
  const telemetry = loadFixtureSync('typing-burst.json')
  const run1 = JSON.stringify(solveZoomTimeline(telemetry, { presetVersion: 3 }))
  const run2 = JSON.stringify(solveZoomTimeline(telemetry, { presetVersion: 3 }))
  expect(run1).toBe(run2)
})

25.5.3 Property-based tests for the invariants #

Using fast-check, the solver's stated invariants (Section 10) are tested against thousands of randomly generated (but seeded, for reproducibility) telemetry inputs per run, rather than only the curated golden fixtures — property tests find edge cases a human would not think to write by hand.

Invariant Property assertion
Zoom never exceeds bounds For every zoom segment in the output, 1.0 <= level <= 2.5 (Section 10's max), and default-preset segments equal exactly 1.6 unless an override event justifies otherwise.
Hold durations respected Every zoom segment's duration (end - start) is >= 1200 ms (Section 10's min hold).
Camera never leaves the source rect For every frame in the derived camera path, the crop rectangle is fully contained within [0,0,sourceWidth,sourceHeight] — never partially or fully outside.
Output is stable under identical input Running the solver twice on the same generated input (not just the fixed golden fixtures) yields identical output — the property-test-generated version of 25.5.2.
Minimum gap between zooms No two zoom segments' start times are closer than 800 ms (Section 10) after coalescing.
Motion settle time For every zoom/pan segment, the simulated critically damped spring path (Section 10, ω₀ = 9.0 rad/s, ζ = 1.0) settles to within 2% of the target framing within 5.8 / ω₀ ≈ 644 ms of the triggering interest event — computed by simulating the integrator step-by-step and asserting the position-error norm crosses below the 2% band by that wall-clock offset and never re-crosses above it afterward (no re-opening once settled).
Peak jerk bounded For every generated camera-path segment, the third finite difference of position (jerk) at the render frame rate never exceeds the bound implied by Section 10's critically damped, frame-rate-independent integration — a spike here means the "frame-rate-independent" integration is not actually frame-rate-independent (e.g. a fixed per-frame delta bug), which is exactly the class of bug a purely visual QA pass would not catch.
Cursor-jitter RMS reduction For every arbitrary telemetry stream with injected high-frequency dither (simulating sensor/sampling noise), the RMS of frame-to-frame cursor-position delta after One-Euro filtering (Section 10's β/min-cutoff parameters) is at least 25% lower than the RMS of the same delta computed on the raw, unfiltered input — proving the filter measurably reduces jitter rather than merely relabeling the signal.
Framing-safety invariant (Section 10.1) For every frame in the derived camera path, either the tracked cursor position lies within the inner 60% safe rect or the camera is actively moving to bring it back within the hysteresis band's reaction window — never both "cursor outside the safe rect" and "camera stationary" for longer than that window. A second property on the same generated paths asserts the hysteresis band actually prevents oscillation: for a monotonically drifting cursor input, the camera's movement direction reverses at most once within any hysteresis-band window.
import fc from 'fast-check'
import { solveZoomTimeline } from '../../src/auto-edit/solver'
import { arbitraryTelemetryStream } from '../fixtures/arbitraries'

it('never produces a zoom level outside [1.0, 2.5]', () => {
  fc.assert(
    fc.property(arbitraryTelemetryStream(), (telemetry) => {
      const result = solveZoomTimeline(telemetry, { presetVersion: 3 })
      return result.zoomSegments.every((s) => s.level >= 1.0 && s.level <= 2.5)
    }),
    { seed: 20260819, numRuns: 2000 },
  )
})

arbitraryTelemetryStream() generates plausible-but-random telemetry (random click/drag/scroll/typing event sequences at both the 120 Hz desktop and 60 Hz browser sampling rates, Section 10) rather than fully unconstrained random data, so the property tests explore the realistic input space, not a space dominated by inputs the solver would reasonably reject or ignore.

The framing-safety invariant is simulated end to end (solved zoom timeline → derived per-frame camera path → per-frame safe-rect check) since it is the property most directly tied to the product's core promise that the subject never gets cropped out of frame:

it('the camera never leaves the cursor outside the safe rect while stationary beyond the hysteresis window', () => {
  fc.assert(
    fc.property(arbitraryTelemetryStream(), (telemetry) => {
      const result = solveZoomTimeline(telemetry, { presetVersion: 3 })
      const path = simulateCameraPath(result, telemetry)
      return path.frames.every((frame, i) => {
        const insideSafeRect = isWithinInnerSafeRect(frame.cursorPosition, frame.cropRect, 0.6)
        if (insideSafeRect) return true
        const stationaryMs = stationaryDurationBeforeFrame(path.frames, i)
        return stationaryMs < HYSTERESIS_BAND_MS
      })
    }),
    { seed: 20260819, numRuns: 2000 },
  )
})

25.5.4 Perceptual regression testing of rendered frames #

A subset of golden fixtures are rendered end-to-end through the actual FFmpeg render worker (not just the solver in isolation) into real video frames, and specific frames at known timestamps are extracted and compared pixel-by-pixel against checked-in reference PNGs using pixelmatch, with a threshold of ≤ 0.1% of pixels differing by more than 5 (of 255) per channel, accounting for legitimate encoder non-determinism (FFmpeg's exact byte output can vary trivially across FFmpeg patch versions even for the same 7.x major line per Section 3, while the visual result must not). A failure produces a diff image artifact attached to the CI run for visual inspection, not just a numeric failure.

import pixelmatch from 'pixelmatch'
import { PNG } from 'pngjs'

it('renders the typing-burst fixture within the perceptual regression threshold', async () => {
  const renderedFramePng = await renderFrameAtTimestamp('typing-burst.json', 4200 /* ms */)
  const referencePng = PNG.sync.read(await readFixture('typing-burst-frame-4200ms-reference.png'))
  const diff = new PNG({ width: referencePng.width, height: referencePng.height })
  const diffPixelCount = pixelmatch(
    renderedFramePng.data, referencePng.data, diff.data,
    referencePng.width, referencePng.height, { threshold: 5 / 255 },
  )
  const totalPixels = referencePng.width * referencePng.height
  expect(diffPixelCount / totalPixels).toBeLessThan(0.001)
})

25.6 Testing the non-destructive invariant #

A property test (not just an example test) asserts that applying then reverting any sequence of EDL operations returns exactly the original timeline (Section 11's non-destructive editing invariant, backed by Section 9's "original is immutable" rule). Sequences of operations (trim, silence-remove, filler-remove, reorder-within-EDL) up to length 20 are generated by fast-check, applied, individually reverted in reverse order, and the resulting timeline is asserted deep-equal to the pre-operation timeline — including a revertAll() shortcut path tested separately for equivalence to the individual-reverts path.

import fc from 'fast-check'
import { applyEdlOperation, revertEdlOperation, revertAllEdlOperations } from '../../src/edl/operations'
import { arbitraryEdlOperationSequence, arbitraryTimeline } from '../fixtures/arbitraries'

it('reverting every applied operation, in reverse, restores the exact original timeline', () => {
  fc.assert(
    fc.property(arbitraryTimeline(), arbitraryEdlOperationSequence({ maxLength: 20 }), (originalTimeline, ops) => {
      let timeline = originalTimeline
      const applied = []
      for (const op of ops) {
        timeline = applyEdlOperation(timeline, op)
        applied.push(op)
      }
      for (const op of applied.reverse()) {
        timeline = revertEdlOperation(timeline, op)
      }
      return deepEqual(timeline, originalTimeline)
    }),
    { seed: 20260819, numRuns: 1000 },
  )
})

it('revertAll is equivalent to reverting every operation individually in reverse', () => {
  fc.assert(
    fc.property(arbitraryTimeline(), arbitraryEdlOperationSequence({ maxLength: 20 }), (originalTimeline, ops) => {
      const viaRevertAll = revertAllEdlOperations(ops.reduce(applyEdlOperation, originalTimeline))
      const viaIndividualReverts = ops.reduce(applyEdlOperation, originalTimeline)
      const individuallyReverted = [...ops].reverse().reduce((tl, op) => revertEdlOperation(tl, op), viaIndividualReverts)
      return deepEqual(viaRevertAll, originalTimeline) && deepEqual(individuallyReverted, originalTimeline)
    }),
  )
})

25.6.1 Source-object integrity: SHA-256 before and after re-render #

The property tests above prove the EDL/timeline data structure round-trips correctly, but they operate entirely in memory — they do not prove the render worker's actual output writes never touch the source object in storage. A separate integration test closes that gap by treating "the original is immutable" as a claim about bytes in the restricted bucket (STORAGE_BUCKET_RESTRICTED, Section 26.2), not just about the EDL document: it computes the SHA-256 digest of the source media object before and after N randomized edit-and-re-render operations and asserts the digest is unchanged. This is what makes the non-destructive invariant actually testable end to end rather than merely asserted at the data-structure layer — a render worker bug that opened the source object in write mode, or a storage-key collision that overwrote the source instead of writing a new rendition, would pass every property test above (the EDL document itself is untouched) while still corrupting the one thing the product guarantees is safe.

// apps/worker/test/integration/non-destructive-source-integrity.test.ts
import { createHash } from 'node:crypto'
import fc from 'fast-check'
import { withTransactionalDb } from '../setup/db'
import { createTestVideo } from '@reelay/db/test/factories/video'
import { applyEditAndRerender } from '../../src/edl/apply-and-rerender'
import { getObjectStream } from '../../src/storage/client'
import { arbitraryEdlOperationSequence } from '../fixtures/arbitraries'

async function sha256OfSourceObject(storageKey: string): Promise<string> {
  const hash = createHash('sha256')
  for await (const chunk of await getObjectStream(storageKey)) hash.update(chunk)
  return hash.digest('hex')
}

it('the source media object is byte-identical after N randomized edit-and-re-render cycles', async () => {
  await withTransactionalDb(async (db) => {
    const video = await createTestVideo(db, { fixtureFile: 'synthetic-1080p-20s' })
    const sourceKey = video.sourceObjectKey // STORAGE_BUCKET_RESTRICTED key, Section 26.2
    const before = await sha256OfSourceObject(sourceKey)

    await fc.assert(
      fc.asyncProperty(arbitraryEdlOperationSequence({ maxLength: 20 }), async (ops) => {
        for (const op of ops) {
          // Applying an operation and re-rendering must only ever write a NEW rendition object;
          // it must never open the source object for writing.
          await applyEditAndRerender(db, video.id, op)
        }
      }),
      // Fewer runs than the pure-EDL property test above, since each run here performs a real
      // render-worker invocation rather than an in-memory operation.
      { seed: 20260819, numRuns: 25 },
    )

    const after = await sha256OfSourceObject(sourceKey)
    expect(after).toBe(before)
  })
})

This is the acceptance criterion Section 28 cites for the non-destructive-editing milestone — a milestone is not "done" until this test exists and passes in CI, not merely until the in-memory property tests above pass. The two are required to stay in lockstep: a PR that changes the set of EDL operation types exercised here (trim, silence-remove, filler-remove, reorder-within-EDL, matching the arbitrary generator above) without updating the corresponding acceptance language in Section 28, or vice versa, is treated as an incomplete change — a gap between "what the test actually covers" and "what the milestone claims is covered" is exactly what this test exists to close.

25.7 Testing redaction as a security property #

Redaction (Sections 11.7, 22.2) is tested as a security control, not a visual feature — the test suite treats "the blur was applied" and "sharing is blocked until it is applied" as security assertions with automated verification, not manual QA.

25.7.1 Pixel-level redaction verification, parameterized across every delivery path #

The pixel-verification test is parameterized across every path a viewer can receive delivered content through, not just the primary rendition — rendition, poster, GIF export, WebM export, MP4 export. Each delivery path is produced by a different code path in the render worker, and none of them may be assumed correct because another one is; a redaction test that only checks one output path is how an unredacted poster ships:

Delivery path Produced by
rendition The primary HLS-ladder rendition, burned in during the standard render (Section 9).
poster The still-frame poster extraction — burned in independently since it is generated from a single frame, not the video stream.
gif_export The GIF export encoder path (Section 21).
webm_export The WebM export encoder path (Section 21).
mp4_export The MP4 export encoder path (Section 21).
import pixelmatch from 'pixelmatch'

const REDACTED_REGION = { x: 100, y: 100, width: 200, height: 200, startMs: 0, endMs: 5000 }

describe.each([
  { path: 'rendition', render: renderRenditionWithRedaction },
  { path: 'poster', render: renderPosterWithRedaction },
  { path: 'gif_export', render: renderGifExportWithRedaction },
  { path: 'webm_export', render: renderWebmExportWithRedaction },
  { path: 'mp4_export', render: renderMp4ExportWithRedaction },
])('redaction pixel verification: $path', ({ path, render }) => {
  it(`the delivered ${path} does not reveal the original content in a redacted region`, async () => {
    const rendered = await render('qr-pattern-fixture.json', { region: REDACTED_REGION })
    const redactedFrame = await extractRegion(rendered, { timestampMs: 2000, ...boundsOf(REDACTED_REGION) })
    const originalFrame = await extractRegion(
      await renderWithoutRedaction('qr-pattern-fixture.json', { path }),
      { timestampMs: 2000, ...boundsOf(REDACTED_REGION) },
    )
    const similarity = 1 - (pixelmatchCount(redactedFrame, originalFrame) / (200 * 200))
    // redacted region must be substantially different from the original, on EVERY delivery path
    expect(similarity).toBeLessThan(0.1)
  })
})

(renderPosterWithRedaction extracts a single still frame instead of a video frame at a timestamp; renderGifExportWithRedaction / renderWebmExportWithRedaction / renderMp4ExportWithRedaction decode the respective export container instead of an HLS rendition segment — each helper is a thin wrapper so the pixel-comparison assertion logic above is shared across all five and only the decode step differs per path.)

25.7.2 A failed perceptual-hash verification blocks readiness and blocks delivery-bucket writes #

Burning in a redaction region is necessary but not sufficient — the render worker also perceptual-hashes each redacted region post-render and compares it against a hash of the original content at the same coordinates as a machine-checkable verification step (renditions.redaction_verified, Section 5), before any output is considered servable (renditions.servable, Section 5). This is tested as its own security-critical failure path, independent of 25.7.1's happy-path pixel check:

it('a failed perceptual-hash verification blocks the transition to ready and blocks any write to the delivery bucket', async () => {
  const putObjectSpy = vi.spyOn(storageClient, 'putObject')

  const result = await runRedactionVerification('qr-pattern-fixture.json', {
    region: REDACTED_REGION,
    // Test seam: forces the render worker's burn-in step to produce output whose perceptual hash of the
    // redacted region still matches the original — simulating a burn-in bug, not exercising the happy path.
    forcePerceptualHashMismatch: true,
  })

  expect(result.video.status).not.toBe('ready')
  expect(
    result.renditions.every((r) => r.redactionVerified === false && r.servable === false),
  ).toBe(true)
  expect(putObjectSpy).not.toHaveBeenCalledWith(
    expect.objectContaining({ bucket: env.STORAGE_BUCKET_DELIVERY }),
  )
})

The assertion order matters: redactionVerified === false alone is not sufficient proof of safety, since a bug could mark verification failed but still have already written the unverified output to the delivery bucket earlier in the pipeline. The test asserts both the state flag and the absence of the delivery-bucket write, so a regression that reorders "write" before "verify" fails this test even if the status flag ends up correct.

25.7.3 Pending-redaction share block #

An integration test creates a video, adds a redaction region without waiting for its burn-in render to complete, and asserts that attempting to create or activate a share link for that video returns the error envelope with code redaction_pending (Section 7.6) rather than succeeding — this is tested at the API integration layer, not just as a UI disabled-button state, since the enforcement must be server-side (consistent with the plan-enforcement posture in Section 21).

25.8 Player testing #

Bundle-size CI gate. size-limit runs on every PR touching packages/player and fails the build (not just warns) if the loader exceeds 8 KB gzipped or the lazy-loaded player core exceeds 20 KB gzipped (Section 15.2's budget, enforced here per Section 27.4):

// packages/player/.size-limit.json
[
  { "name": "embed loader", "path": "dist/embed.js", "gzip": true, "limit": "8 KB" },
  { "name": "player core", "path": "dist/player-core.js", "gzip": true, "limit": "20 KB" }
]

Lighthouse CI against a real host page. A minimal static host page embedding the player via the real embed.js script (not a mocked player) is served in CI, and @lhci/cli runs against it, asserting the budgets in Section 15.2/27.3: 0 LCP contribution is verified indirectly by asserting the host page's own LCP element (unrelated to the player) is unaffected by the player's presence, CLS <= 0.0 is asserted directly via Lighthouse's CLS audit, and a Lighthouse performance score budget assertion catches any regression in the embed's total blocking time contribution.

Cross-browser matrix. Playwright projects for Chromium, Firefox, and WebKit (covering Safari behavior) run the player's E2E suite (play/pause/seek/fullscreen/keyboard operation/quality selection) on every PR; a nightly job additionally runs against real mobile Safari and Chrome via a cloud device-farm provider (BrowserStack or equivalent) since mobile WebKit's MediaSource support and autoplay policy differ meaningfully from desktop and from Playwright's WebKit build.

Email-client rendering verification. Since the player degrades to an animated-GIF/poster fallback in email (Section 15.2), rendering correctness is verified via a cloud email-rendering-preview service (e.g. Litmus or Email on Acid) against a fixed matrix of clients: Gmail (web, iOS, Android), Outlook (desktop Windows, web, iOS/Android), Apple Mail (macOS, iOS), Yahoo Mail. This runs on every change to the email template rendering path, not on every PR (it is a paid, rate-limited external service), and results are manually reviewed since automated pixel diffing across that many rendering engines produces too many false positives to gate a build on.

25.9 Authorization testing #

Requirement: every API endpoint has a negative-authorization test for every role that should NOT be able to perform that action — not just a positive test that the correct role can. For a workspace with the four roles (Section 6), an endpoint like DELETE /v1/videos/:videoId (member-or-above, own video only) must be tested for: owner (allowed on any video), admin (allowed on any video), member (allowed on own video, denied on another member's video), viewer (denied unconditionally), and an authenticated user from a different workspace entirely (denied with video_not_found, not forbidden — Section 22's information-disclosure posture of not confirming a resource's existence to a non-member).

Enforced structurally, not by discipline. Rather than trusting every route author to remember to write five negative tests, a single generated test suite iterates a machine-readable route-permission manifest (a table co-located with each route's definition, mapping route → required role-per-method → resource ownership scope) and automatically generates the full positive/negative matrix as parameterized tests:

// apps/api/test/authz/generated-matrix.test.ts
import { routePermissionManifest } from '../../src/routes/manifest'
import { ALL_ROLES } from '@reelay/shared/roles'

describe.each(routePermissionManifest)('authorization matrix: $method $path', (route) => {
  it.each(ALL_ROLES)('role=%s', async (role) => {
    const allowed = route.allowedRoles.includes(role)
    const res = await callRouteAs(route, { role })
    if (allowed) {
      expect(res.status).toBeLessThan(400)
    } else {
      expect(res.status).toBe(403)
      expect(res.body.error.code).toBe('insufficient_role')
    }
  })
  it('a user from a different workspace entirely is denied as not-found, not forbidden', async () => {
    const res = await callRouteAs(route, { crossWorkspace: true })
    expect(res.status).toBe(404)
  })
})

A route added without a corresponding routePermissionManifest entry fails a separate CI check (a script that diffs the Fastify route registry against the manifest) — this makes "forgot to write authorization tests" structurally impossible rather than a code-review reminder, which is the mechanism the brief calls for: the completeness guarantee comes from the manifest-driven generation, not from author discipline.

25.9.1 Anonymous share_viewer actors are a separate tested population from the workspace viewer role #

Section 6.9 defines three Actor variants: an authenticated workspace member (one of the four roles), an API key, and the anonymous share_viewer ({ type: 'share_viewer', viewerToken, videoId }) used by unauthenticated share-link playback. The generated matrix above exercises workspace-role actors and API-key actors; it does not and must not stand in for share_viewer coverage, because share_viewer is evaluated against its own allow-list and never against ROLE_CAPABILITIES['viewer'] (Section 6.9). A second, independently generated matrix exercises every share-link-facing endpoint (playback-token issuance, comment submission on a shared video, reaction and CTA/email-capture submission — Section 17) against share_viewer actors specifically:

// apps/api/test/authz/share-viewer-matrix.test.ts
describe('share_viewer authorization — tested independently of the workspace viewer role', () => {
  it('a share_viewer actor is never evaluated against ROLE_CAPABILITIES.viewer', async () => {
    const authorizeSpy = vi.spyOn(authzModule, 'authorize')
    const shareViewer = { type: 'share_viewer', viewerToken: 'tok_test1', videoId: video.id }
    await callRouteAs(playbackTokenRoute, { actor: shareViewer })
    expect(authorizeSpy).not.toHaveBeenCalledWith(
      expect.objectContaining({ actor: expect.objectContaining({ type: 'viewer' }) }),
    )
  })

  it('a share_viewer actor scoped to one video cannot use its token to act on a different video', async () => {
    const shareViewer = { type: 'share_viewer', viewerToken: sharedToken, videoId: otherVideo.id }
    const res = await callRouteAs(commentCreateRoute, { actor: shareViewer })
    expect(res.status).toBe(403)
  })

  it('revoking the originating share link invalidates the viewerToken for every subsequent check, not just future issuance', async () => {
    const shareViewer = { type: 'share_viewer', viewerToken: sharedToken, videoId: video.id }
    await revokeShareLink(shareLinkId) // Section 14.1.1
    const res = await callRouteAs(playbackTokenRoute, { actor: shareViewer })
    expect(res.status).toBe(403)
  })
})

Conflating the two populations in test coverage is the specific escalation risk the review identified: a workspace viewer and an anonymous share_viewer are different populations by design (Section 6.9), and a test suite that only ever tests them together can pass while a share_viewer silently receives workspace-viewer capabilities (e.g. seeing other videos in the workspace's library) or a workspace viewer is incorrectly evaluated against the anonymous allow-list and denied something they should have. This matrix is generated from the same kind of manifest pattern as the workspace-role matrix above (a route-level flag marking which endpoints accept share_viewer at all), so "someone forgot to add the share-link isolation test for a new share-link-facing endpoint" is caught by the same completeness CI check described above, applied to a second manifest column, rather than left to reviewer memory.

25.10 Accessibility testing in CI #

axe-core (via @axe-core/playwright) runs against every page template in apps/web (marketing, dashboard, editor, watch page — Section 23) as part of the standard E2E suite. The CI failure threshold is zero critical or serious impact violations; moderate and minor violations are reported as warnings that do not fail the build but are tracked and triaged weekly, since axe-core's moderate/minor categories include some context-dependent false positives that require human judgment (e.g. color-contrast findings against a brand-kit custom color the workspace owner explicitly chose, Section 18).

import AxeBuilder from '@axe-core/playwright'

test('editor page has no critical or serious accessibility violations', async ({ page }) => {
  await page.goto('/editor/vid_test1')
  const results = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag22aa']).analyze()
  const blocking = results.violations.filter((v) => v.impact === 'critical' || v.impact === 'serious')
  expect(blocking).toEqual([])
})

Manual accessibility audit cadence: a full manual audit (screen reader walkthrough with VoiceOver and NVDA, keyboard-only navigation of every core journey, zoom-to-400% reflow check) is performed quarterly by a person, since automated tooling — even at zero violations — cannot verify genuine usability for assistive technology users, only the presence of correct markup and contrast ratios.

25.11 Load and soak testing #

Scenario Target Tooling
API read load Sustain 500 req/s against GET /v1/videos and GET /v1/videos/:videoId at the p95/p99 latency budgets in 27.2, for 15 minutes, with error rate < 0.1%. k6
API write load Sustain 100 req/s against video-creation and share-link-creation endpoints for 15 minutes at the write-endpoint latency budget (27.2). k6
Upload burst 200 concurrent multipart uploads (8 MB parts, Section 9) starting within a 60-second window, confirming the upload success rate SLO (24.4) holds and object storage/CDN do not throttle. k6 with a custom multipart-upload scenario script
Playback burst 5,000 concurrent simulated viewers requesting signed playback URLs and initiating HLS playback within a 2-minute window (simulating a single popular video shared widely), confirming CDN cache hit ratio and origin request rate stay within the capacity plan (24.9). k6 + a lightweight HLS-segment-fetching scenario (not full browser instances — segment fetch load only)
Queue soak Sustained 10 jobs/s enqueued to the transcode-orchestration queue for 4 hours, confirming queue depth and oldest-job-age stay within the alert thresholds (24.5.2) and no memory growth (leak) in worker processes over the run. k6 (job enqueue driver) + the observability platform for the memory/depth assertions
Soak (general) The full application (API + workers) run at 50% of launch-scale peak load (27.1) continuously for 24 hours, watching for memory growth, connection pool exhaustion, and file-descriptor leaks that only manifest over time, not in a 15-minute burst test. k6 sustained scenario + infrastructure metrics review

Load and soak tests run against a dedicated load-testing environment provisioned identically to staging (26.1) — never against production, and never against shared staging during another team's testing window (coordinated via a booking calendar for the load-testing environment, since resource contention would invalidate results). Load test runs happen before any release that changes a hot path (upload, transcode orchestration, playback URL issuance) and otherwise monthly as a regression check.

25.12 Test data management, fixtures, and factories #

Factories. Every core entity (Section 5) has a corresponding factory function in packages/db/test/factories/, built on top of the transactional DB fixture (25.3), producing a valid minimal row with sensible defaults and accepting partial overrides:

// packages/db/test/factories/video.ts
export async function createTestVideo(db: DbClient, overrides: Partial<NewVideo> = {}): Promise<Video> {
  const [video] = await db.insert(videos).values({
    id: uuidv7(),
    workspaceId: overrides.workspaceId ?? (await createTestWorkspace(db)).id,
    title: overrides.title ?? 'Test video',
    status: overrides.status ?? 'ready',
    durationMs: overrides.durationMs ?? 60_000,
    ...overrides,
  }).returning()
  return video
}

Factories compose (createTestVideo calls createTestWorkspace if no workspaceId is given) so a test needing a fully-wired scenario (workspace + member + video + share link) can build it in a few lines rather than hand-writing every foreign key relationship per test.

Media fixture set. A checked-in library of short (5–30 second) real video clips at each resolution and codec the capture matrix (Section 8) and transcode ladder (Section 9) must handle, used across integration, E2E, and the auto-edit/perceptual regression suites (25.5):

Fixture Resolution Codec Purpose
synthetic-480p-15s 854×480 H.264/AAC Lowest-rung transcode ladder verification
synthetic-720p-30s 1280×720 H.264/AAC Default E2E recording fixture (25.4)
synthetic-1080p-20s 1920×1080 H.264/AAC Standard-quality pipeline verification
synthetic-4k-10s 3840×2160 H.264/AAC Upper-bound transcode ladder and export (Section 21's 4K export) verification
synthetic-vp9-webm-15s 1280×720 VP9/Opus Firefox/non-MP4-capable browser codec fallback path (Section 8)
qr-pattern-fixture 1280×720 H.264/AAC Redaction pixel-verification (25.7)
typing-burst, rapid-click, drag-select, scroll-heavy, idle-period telemetry+video pairs 1280×720 H.264/AAC Auto-edit golden fixtures (25.5)
synthetic-tone-30s.wav — audio only PCM Transcription/caption pipeline fixture with a known, transcribable spoken script for asserting transcript accuracy is non-empty and well-formed (not verifying vendor accuracy, which is out of scope — verifying the pipeline delivers whatever the vendor returns correctly)

All media fixtures are synthetically generated or licensed for unrestricted internal use (no fixture is a real customer or employee recording) and are regenerated via a checked-in script (scripts/generate-media-fixtures.ts, using FFmpeg to synthesize test patterns and tone generators) rather than hand-produced, so the fixture set is reproducible and auditable.

25.13 The CI pipeline #

Stage Runs on Trigger Parallelization
Lint + typecheck every workspace package every PR push Turborepo task graph, parallel per package
Unit tests every workspace package every PR push Vitest's built-in worker-thread parallelism, sharded 4-way across CI runners
Integration tests apps/api, apps/worker, packages/db every PR push Sharded 2-way; each shard gets its own testcontainer Postgres/Redis instance
Auto-edit golden + property tests (25.5) apps/worker every PR push touching apps/worker/src/auto-edit/** or nightly otherwise Single runner (property tests are CPU-bound per-run; not worth sharding at this volume)
E2E (Playwright) apps/web, packages/player every PR push, Chromium project only; full cross-browser matrix (25.8) nightly Playwright's built-in test sharding, 4-way
Bundle size gate (25.8) packages/player every PR touching packages/player/**
Lighthouse CI (25.8) apps/web marketing + watch page, packages/player every PR touching those paths, full route set nightly
Accessibility (axe-core, 25.10) apps/web every PR push (part of the standard E2E run) Included in E2E sharding
Load/soak (25.11) dedicated environment pre-release for hot-path changes; monthly scheduled otherwise N/A — not a per-PR gate
Security scanning (dependency audit, secret scanning, SAST) entire monorepo every PR push Parallel to test stages

Nightly-only vs. every-PR: anything that is slow, expensive (third-party service cost), or exercises a wide device/browser matrix runs nightly against the main branch rather than blocking every PR — full cross-browser E2E, full-route Lighthouse CI, load/soak testing, and email-client rendering (25.8, run on template change, not nightly, since it is rate-limited by the external provider). A nightly failure files an automatically-assigned ticket and, if it represents a regression against the previous night's green run, is treated as a same-day-fix priority even though it did not block a merge.

Flake policy. A test that fails intermittently without a code change is quarantined (moved to a flaky tag excluded from the merge gate but still run and tracked) within one failure if it is a known timing-sensitive category (E2E media playback timing, Playwright network-idle waits) or after two unexplained failures otherwise — never silently deleted. A quarantined test has a tracked ticket with an owner and is reviewed weekly; a test quarantined for more than 2 weeks without progress escalates to the engineering lead. Flaky-test rate (quarantined count / total test count) is itself tracked on the reliability dashboard (24.9's capacity review cadence) as a codebase health signal.

Merge gate definition. A PR may merge only when: lint + typecheck pass, unit tests pass with no coverage regression below the package's floor (25.1), integration tests pass, the Chromium-only E2E subset passes, the bundle-size gate passes (if packages/player changed), the axe-core accessibility check passes, the authorization matrix (25.9) passes and the route-manifest completeness check passes, security scanning reports no new high/critical findings, and at least one human approval is recorded. Quarantined flaky tests do not block the gate; everything else does — there is no "merge anyway" override for a genuine red build short of an explicitly logged and time-boxed incident-driven exception approved by the engineering lead.


26. Deployment, Environments & Configuration #

26.1 Environment topology #

Environment Purpose Data Vendor accounts Deploys
Local Individual developer machine. Local Postgres/Redis/MinIO via docker-compose (26.3), seeded fixture data. Vendor sandbox/test-mode credentials shared across the team via the credentials store, never production keys. Manual (pnpm dev), no CI involvement.
Preview (per PR) An isolated, ephemeral environment per open pull request, for reviewer and stakeholder testing before merge. A fresh, seeded database per preview (not a copy of staging/production data), torn down when the PR closes or merges. Vendor sandbox/test-mode credentials, shared pool. Automatic on every push to the PR branch; torn down automatically on PR close.
Staging Pre-production, mirrors production configuration (instance sizes may be smaller). Used for final verification, load testing (25.11), and manual QA before a production release. Synthetic/anonymized data only — never a copy of production customer data (Section 22's data-handling posture applies to lower environments too). Vendor sandbox/test-mode credentials (Mux, transcription vendor, Stripe test mode) except where a vendor requires production credentials to test webhook delivery realistically, in which case a dedicated staging-scoped vendor account is used, never the production account. Automatic on every merge to main.
Production Live customer environment. Real customer data. Live vendor credentials. Manual promotion from a verified staging build (26.4), never a direct main-to-production auto-deploy.

Preview and staging environments never share a database, Redis instance, or object storage buckets (either of the two, Sections 11.7/22.2) with production or with each other — full isolation prevents a bug in a preview environment from ever touching real data, and prevents a load test (25.11) from ever contending with production traffic.

26.2 The complete environment variable reference #

Every configuration value the applications consume is listed here — this is the single canonical place. Values are read once at process startup and validated (26.2.1); nothing reads process.env directly outside the validated config module.

Name Type Required Default Consumed by Description
NODE_ENV enum(development,test,production) required web, api, worker Runtime mode; distinct from APP_ENV below, which is product-environment-aware.
APP_ENV enum(local,preview,staging,production) required web, api, worker The environment label used in logs (24.1.1) and for environment-conditional behavior (e.g. vendor sandbox selection).
DEPLOY_VERSION string required web, api, worker Short commit SHA baked into the image at build time; surfaces as version in logs and /readyz.
DATABASE_URL string (postgres connection URL) required api, worker Primary Postgres connection string, pointed at the pooler (26.8), not directly at the database instance.
DATABASE_URL_READONLY string (postgres connection URL) optional falls back to DATABASE_URL api Read replica connection string for read-only, replica-tolerant queries (26.8, analytics rollups).
DATABASE_POOL_MAX integer optional 10 api, worker Max connections this process holds in its pool (27.7).
REDIS_URL string (redis connection URL) required api, worker Shared Redis instance: queues, rate limiting, session cache.
STORAGE_ENDPOINT string (URL) required api, worker S3-compatible object storage endpoint.
STORAGE_REGION string required api, worker Object storage region.
STORAGE_BUCKET_RESTRICTED string required worker The restricted bucket (Sections 11.7, 22.2) holding unredacted originals only — no CDN origin, IAM-denied by default, physically separate from STORAGE_BUCKET_DELIVERY so a misconfigured bucket policy on one cannot expose the other.
STORAGE_BUCKET_DELIVERY string required api, worker The delivery bucket holding every servable rendition, poster, thumbnail, and export (GIF/WebM/MP4). The only object storage bucket the CDN (26.5) is ever configured as an origin for.
STORAGE_ACCESS_KEY_ID / STORAGE_SECRET_ACCESS_KEY string (secret) required api, worker Object storage credentials, scoped by IAM policy independently per bucket. Loaded from the secrets manager, never a literal .env value in staging/production.
STREAMING_PROVIDER enum(mux) optional mux web, api, worker Selects the streaming/transcoding vendor implementation behind the Section 9 vendor-swappable interface — the single variable an executor changes to swap the streaming vendor. The MUX_* variables below are specific to the mux implementation and are read only when this selector resolves to it.
TRANSCRIPTION_PROVIDER enum(deepgram) optional deepgram worker Selects the transcription vendor implementation behind the Section 12 vendor-swappable interface. TRANSCRIPTION_VENDOR_API_KEY/TRANSCRIPTION_WEBHOOK_SIGNING_SECRET below are specific to whichever vendor this selector resolves to.
LLM_PROVIDER enum(openai) optional openai worker Selects the AI chapters/summaries/titles vendor implementation behind the Section 12 vendor-swappable interface. LLM_VENDOR_API_KEY/LLM_VENDOR_MODEL below are specific to whichever vendor this selector resolves to.
BILLING_PROVIDER enum(stripe) optional stripe api Selects the billing vendor implementation behind the Section 21 vendor-swappable interface. The STRIPE_* variables below are specific to the stripe implementation.
EMAIL_PROVIDER enum(resend) optional resend api, worker Selects the transactional email vendor implementation used for verification, notifications, and usage-cap warning emails. EMAIL_PROVIDER_API_KEY below is the credential for whichever vendor this selector resolves to.
CDN_BASE_URL string (URL) required web, api, worker Public CDN URL fronting STORAGE_BUCKET_DELIVERY, used to construct playback/download URLs. Never fronts STORAGE_BUCKET_RESTRICTED.
MUX_TOKEN_ID / MUX_TOKEN_SECRET string (secret) required worker Mux API credentials.
MUX_WEBHOOK_SIGNING_SECRET string (secret) required api Verifies inbound Mux webhook signatures (Section 9).
MUX_SIGNING_KEY_ID / MUX_SIGNING_KEY_PRIVATE string (secret) required api Signs playback tokens (Section 14, 6h TTL).
TRANSCRIPTION_VENDOR_API_KEY string (secret) required worker Transcription vendor credentials.
TRANSCRIPTION_WEBHOOK_SIGNING_SECRET string (secret) required api Verifies inbound transcription vendor webhook signatures.
LLM_VENDOR_API_KEY string (secret) required worker AI chapters/summaries/titles generation vendor (Section 12).
LLM_VENDOR_MODEL string optional vendor's current default worker Model identifier, kept out of code so it can be updated without a deploy.
STRIPE_SECRET_KEY string (secret) required api Stripe Node SDK credential (Section 21).
STRIPE_WEBHOOK_SIGNING_SECRET string (secret) required api Verifies inbound Stripe webhook signatures.
STRIPE_PUBLISHABLE_KEY string required web Client-side Stripe Elements key — not secret, but still centrally documented.
EMAIL_PROVIDER_API_KEY string (secret) required api, worker Transactional email vendor (verification, notifications, warnings).
EMAIL_FROM_ADDRESS string (email) required api, worker Default sender address.
GOOGLE_OAUTH_CLIENT_ID / GOOGLE_OAUTH_CLIENT_SECRET string / string (secret) required api Google OAuth 2.0 PKCE flow (Section 6).
SLACK_CLIENT_ID / SLACK_CLIENT_SECRET string / string (secret) required for the Slack integration api Slack integration OAuth (Section 20).
NOTION_CLIENT_ID / NOTION_CLIENT_SECRET string / string (secret) required for the Notion integration api Notion integration OAuth (Section 20).
HUBSPOT_CLIENT_ID / HUBSPOT_CLIENT_SECRET string / string (secret) required for the HubSpot integration api HubSpot integration OAuth (Section 20).
SESSION_COOKIE_SECRET string (secret, 32+ bytes) required api, web Signs the session cookie (Section 6).
JWT_SIGNING_KEY string (secret) required api Signs short-lived API JWTs derived from session (Section 6).
API_KEY_HASH_PEPPER string (secret) required api Additional pepper mixed into the SHA-256 hash of public API keys (Section 6), stored separately from the database.
WEBHOOK_SIGNING_SECRET_PREFIX string optional whsec_ api Prefix used when generating outbound webhook signing secrets for customer endpoints (Section 20).
FEATURE_FLAGS_PROVIDER enum(db,launchdarkly) optional db web, api, worker Selects the feature-flag backend (26.7).
LAUNCHDARKLY_SDK_KEY string (secret) required if FEATURE_FLAGS_PROVIDER=launchdarkly web, api, worker Only consumed if the LaunchDarkly backend is selected.
OTEL_EXPORTER_OTLP_ENDPOINT string (URL) required web, api, worker Observability collector endpoint (24.2, 24.3).
LOG_LEVEL enum(trace,debug,info,warn,error,fatal) optional info web, api, worker Minimum log level emitted (24.1.3).
PORT integer optional 3000 (web), 3001 (api) web, api HTTP listen port.
WORKER_CONCURRENCY_TRANSCODE_ORCHESTRATION integer optional 10 worker BullMQ concurrency for the lightweight orchestration queue.
WORKER_CONCURRENCY_RENDER integer optional 2 worker Concurrency for the CPU-heavy video.render.compose queue — deliberately low per process; horizontal scaling (24.9) handles throughput.
WORKER_CONCURRENCY_TRANSCRIPTION integer optional 20 worker I/O-bound, higher concurrency per process.
FFMPEG_SCRATCH_DIR string (path) optional /tmp/reelay-render worker Local scratch directory for in-progress renders (24.2.2, 24.9).
RATE_LIMIT_DEFAULT_PER_MINUTE integer optional 120 api Default per-token rate limit (Section 7.9) absent a per-key override.
CORS_ALLOWED_ORIGINS string (comma-separated) required api Allowed origins for browser-facing API routes.
EMBED_MANIFEST_URL string (URL) required web, packages/player build CDN URL of the player-core version manifest (Section 15.14, 24.6.11).
SENTRY_DSN (or equivalent error-tracking DSN) string (secret) optional disabled web, api, worker Client/server exception capture, complementary to structured logging.

26.2.1 Startup-time validation #

Every app defines its own Zod schema of the environment variables it actually consumes (a subset of the table above), and validates process.env against it once, at the top of the entry point, before any other initialization runs. A missing required variable or a malformed value (wrong type, invalid URL, empty string where a non-empty string is required) throws immediately and the process exits with a non-zero code and a fatal-level log line (24.1.3) — the process never starts in a partially-configured state and never discovers a bad variable lazily when the code path that uses it first executes.

// apps/api/src/config/env.ts
import { z } from 'zod'

const envSchema = z.object({
  NODE_ENV: z.enum(['development', 'test', 'production']),
  APP_ENV: z.enum(['local', 'preview', 'staging', 'production']),
  DEPLOY_VERSION: z.string().min(1),
  DATABASE_URL: z.string().url(),
  DATABASE_URL_READONLY: z.string().url().optional(),
  DATABASE_POOL_MAX: z.coerce.number().int().positive().default(10),
  REDIS_URL: z.string().url(),
  STORAGE_ENDPOINT: z.string().url(),
  STORAGE_REGION: z.string().min(1),
  STORAGE_BUCKET_RESTRICTED: z.string().min(1),
  STORAGE_BUCKET_DELIVERY: z.string().min(1),
  STORAGE_ACCESS_KEY_ID: z.string().min(1),
  STORAGE_SECRET_ACCESS_KEY: z.string().min(1),
  STREAMING_PROVIDER: z.enum(['mux']).default('mux'),
  TRANSCRIPTION_PROVIDER: z.enum(['deepgram']).default('deepgram'),
  LLM_PROVIDER: z.enum(['openai']).default('openai'),
  BILLING_PROVIDER: z.enum(['stripe']).default('stripe'),
  EMAIL_PROVIDER: z.enum(['resend']).default('resend'),
  CDN_BASE_URL: z.string().url(),
  MUX_TOKEN_ID: z.string().min(1),
  MUX_TOKEN_SECRET: z.string().min(1),
  MUX_WEBHOOK_SIGNING_SECRET: z.string().min(1),
  STRIPE_SECRET_KEY: z.string().min(1),
  STRIPE_WEBHOOK_SIGNING_SECRET: z.string().min(1),
  SESSION_COOKIE_SECRET: z.string().min(32),
  JWT_SIGNING_KEY: z.string().min(32),
  CORS_ALLOWED_ORIGINS: z.string().min(1).transform((s) => s.split(',').map((o) => o.trim())),
  LOG_LEVEL: z.enum(['trace', 'debug', 'info', 'warn', 'error', 'fatal']).default('info'),
  PORT: z.coerce.number().int().positive().default(3001),
  RATE_LIMIT_DEFAULT_PER_MINUTE: z.coerce.number().int().positive().default(120),
  // ...remaining variables from the reference table follow the same pattern
})

export type Env = z.infer<typeof envSchema>

export function loadEnv(): Env {
  const result = envSchema.safeParse(process.env)
  if (!result.success) {
    // eslint-disable-next-line no-console -- the structured logger is not initialized yet
    console.error(JSON.stringify({
      level: 'fatal',
      message: 'startup failed: invalid environment configuration',
      issues: result.error.issues.map((i) => ({ path: i.path.join('.'), message: i.message })),
    }))
    process.exit(1)
  }
  return result.data
}

export const env = loadEnv()

26.3 Local development setup #

Prerequisites: Node.js and pnpm matching the version lines in Section 3, Docker (for docker-compose), and a vendor sandbox credential set from the team's credentials store (never production credentials).

docker-compose for Postgres/Redis/MinIO:

# docker-compose.yml
services:
  postgres:
    image: postgres:17
    environment:
      POSTGRES_USER: reelay
      POSTGRES_PASSWORD: reelay_dev
      POSTGRES_DB: reelay_dev
    ports: ["5432:5432"]
    volumes: ["pgdata:/var/lib/postgresql/data"]
  redis:
    image: redis:8
    ports: ["6379:6379"]
  minio:
    image: minio/minio
    command: server /data --console-address ":9001"
    environment:
      MINIO_ROOT_USER: reelay
      MINIO_ROOT_PASSWORD: reelay_dev_secret
    ports: ["9000:9000", "9001:9001"]
    volumes: ["miniodata:/data"]
volumes:
  pgdata:
  miniodata:

MinIO stands in for the S3-compatible object storage locally (STORAGE_ENDPOINT=http://localhost:9000) — application code talks to it through the same AWS SDK v3 S3 client used against the real provider in staging/production, since MinIO implements the S3 API, so no code path branches on "local vs. real storage." The seed step below also creates the two required buckets in MinIO (STORAGE_BUCKET_RESTRICTED, STORAGE_BUCKET_DELIVERY, Sections 11.7/22.2) alongside the demo data, so the two-bucket model is exercised identically in local development rather than simulated with a single local bucket and prefixes.

Exact command sequence, clone to running app:

git clone <repository-url> reelay && cd reelay
pnpm install
cp .env.example .env               # fill in vendor sandbox credentials from the credentials store
docker compose up -d               # starts postgres, redis, minio
pnpm --filter @reelay/db migrate   # applies all Drizzle migrations
pnpm --filter @reelay/db seed      # creates a demo workspace, users for each role, and sample videos
pnpm dev                           # runs apps/web, apps/api, apps/worker concurrently via Turborepo

pnpm dev starts apps/web on :3000, apps/api on :3001, and apps/worker in the foreground with verbose (debug) logging enabled by default locally. apps/desktop is started separately (pnpm --filter @reelay/desktop dev) since it opens a native Electron window and is not typically needed for backend/web work.

Vendor callbacks locally. Mux, the transcription vendor, and Stripe all deliver webhooks to a public HTTPS URL, which localhost is not. Local development uses an HTTP tunnel (e.g. ngrok http 3001 or the Stripe CLI's stripe listen --forward-to localhost:3001/v1/webhooks/stripe for Stripe specifically, which avoids needing a public tunnel at all for Stripe testing) to forward webhook deliveries to the developer's local API process. The tunnel URL is registered as the webhook endpoint in each vendor's sandbox dashboard for the developer's own test account. This is documented as a one-time per-developer setup step, not something the seed script automates, since tunnel URLs are ephemeral per session.

26.4 Build and deploy #

Pipeline per app:

App Build artifact Deploy target
apps/web Next.js production build (next build), containerized Per 26.5
apps/api Compiled TypeScript, containerized Per 26.5
apps/worker Compiled TypeScript, containerized, includes FFmpeg 7.x binary in the image Per 26.5
apps/desktop Electron-builder packaged installers (per-OS: .dmg/notarized, .exe/signed, .AppImage) Auto-update feed (26.5)
packages/player Bundled, minified, gzipped embed.js and versioned player-core bundle CDN (26.6)

Migration execution order relative to deploy. Migrations run as a distinct pipeline step BEFORE the new application code deploys, against the still-running previous version, via drizzle-kit migrate executed by the CI/CD pipeline — never by the application process on boot, since concurrent instances racing to apply the same migration is a correctness hazard.

Zero-downtime migration discipline: expand/contract. Every schema change that could break the currently-running previous version is split into separate deploys, never combined:

  1. Expand — add the new column/table/index backward-compatibly (nullable column or default; new table; index created CONCURRENTLY). The old code is unaffected and needs no matching code deploy yet.
  2. Migrate + dual-write — deploy code that writes both old and new representations (if data is moving) and backfills existing rows in batches (never a single long-running UPDATE on a large table, e.g. video_view_events). Reads still use the old representation until backfill is confirmed complete.
  3. Contract — once backfill is confirmed and the new code has run successfully for a full deploy cycle, deploy code that reads/writes only the new representation, then drop the old column/table.

The rule for deploying a schema change and the code that needs it: a migration that ADDS capacity may ship in the same release as the code using it, since old code ignores a new column harmlessly. A migration that REMOVES or RENAMES anything the currently-deployed code still reads must never ship alongside the code that stops needing it — it is always the final "contract" step. A migration-linter CI step flags any migration containing a DROP COLUMN/DROP TABLE/RENAME alongside application code in the same PR that still references the dropped/renamed identifier, failing the build rather than relying on review discipline.

Rollback procedure. Code rollback is a redeploy of the previous immutable, versioned build artifact — the pipeline retains the last 10 successful builds ready for immediate redeploy. Because of expand/contract, rolling back to the immediately-previous version is always safe against the current schema. A migration itself is rolled back only when the expand step was wrong (e.g. a bad default), via drizzle-kit's generated down-migration — never by hand-editing the schema.

Auto-rollback health check. Every deploy is followed by a mandatory 5-minute soak during which the pipeline watches the new revision's /readyz (24.7) and api_request_errors_total; if /readyz fails for more than 3 consecutive minutes or the error rate exceeds 3x the pre-deploy baseline, the pipeline automatically redeploys the previous version and pages on-call (24.5) — this is the deploy-health-failure alert in 24.5.2.

26.5 Infrastructure #

App Recommended hosting Reasoning
apps/web A platform purpose-built for Next.js App Router/RSC deployment (e.g. Vercel), or a self-managed container runtime if the team prefers full infrastructure control. Next.js 16's RSC streaming, edge middleware, and ISR caching are best supported by a platform that understands the framework's deployment model natively; the operational cost of replicating that on generic containers (proper streaming SSR, image optimization, edge caching) is not worth it for the marketing/dashboard/editor/watch surfaces, none of which are CPU-bound in a way that benefits from bespoke infrastructure.
apps/api Containerized, running on a managed container orchestration service (e.g. AWS ECS Fargate or equivalent) behind an application load balancer. The API is a long-running Fastify process needing full control over connection pooling, WebSocket-adjacent long-lived connections (if added later), and predictable autoscaling behavior tied to the metrics in 24.9 — a serverless-functions model would fight the connection-pool-reuse assumptions in 27.7.
apps/worker Containerized on the same orchestration platform as apps/api, as a separate service definition per queue group (orchestration, render, transcription) so each scales independently per 24.9's per-queue triggers. Render workers need FFmpeg installed, meaningful CPU allocation, and local scratch disk (24.2.2) — this requires container-level control over CPU/memory/disk that a generic function-as-a-service platform does not cleanly provide, and the workload is inherently long-running per job (minutes, not seconds).
apps/desktop Not "hosted" — distributed as signed/notarized installers via an auto-update feed hosted on the same CDN as the marketing site. Standard Electron distribution pattern; no server-side compute required beyond serving the update manifest and installer artifacts.
Postgres A managed PostgreSQL 17 service with automated failover (e.g. AWS RDS Multi-AZ or equivalent) Managed failover (24.6.10) and automated backups (26.8) are core requirements for a small team without dedicated database operations staff.
Redis A managed Redis 8.x service with persistence enabled (e.g. AWS ElastiCache or equivalent) Queue durability (Section 9) requires Redis persistence (AOF), which a managed service handles with less operational burden than self-hosting.
Object storage An S3-compatible managed object storage service, provisioned as the two physically separate buckets the two-bucket model requires (STORAGE_BUCKET_RESTRICTED, STORAGE_BUCKET_DELIVERY, Sections 11.7/22.2) Directly matches the AWS SDK v3 client used in code (Section 3); the vendor-swappable design means this could be any S3-compatible provider without code changes. The restricted bucket's IAM policy denies all access by default and it is never registered as a CDN origin; only the delivery bucket is CDN-fronted.
CDN A CDN fronting both object storage (media delivery) and the player bundle/embed loader (26.6) Required for the playback-latency and player-load-time budgets (27.5, Section 15.2).

Autoscaling configuration follows the triggers defined in 24.9 directly — apps/api scales 2–20 instances on CPU; each worker service scales per its own queue-depth/CPU trigger with a minimum of 1 instance (never zero, to avoid a cold-start gap before the first job of a new burst is picked up) and a service-specific maximum sized to the capacity plan's headroom target.

Worker scaling by queue depth is implemented via a scaling policy that reads the transcode_queue_depth and transcode_queue_oldest_job_age_seconds metrics (24.2.3) directly — not CPU alone — because a CPU-only policy would under-scale a backlog building from many small, fast jobs that never individually push CPU past the threshold but collectively age the queue past the acceptable staleness bound.

CDN configuration: the CDN's only object-storage origin is STORAGE_BUCKET_DELIVERYSTORAGE_BUCKET_RESTRICTED is never configured as a CDN origin, consistent with its no-CDN-origin requirement (Sections 11.7, 22.2). Cache renditions and posters with a long max-age (renditions are immutable once created — a new edit produces a new rendition id, never an in-place overwrite) and Cache-Control: public, immutable; cache the player-core bundle similarly per its own immutable versioned path (26.6); cache the embed loader and the version manifest (26.6) with short TTLs (60 s for the manifest, as stated in 24.6.11) since those are the intentional "can be changed centrally" surfaces. Signed playback URLs are Cache-Control: private and not cached at a shared CDN layer beyond what the 6-hour TTL naturally allows, since caching a signed URL response across viewers would leak one viewer's signed access to another.

DNS: the apex domain and app. subdomain point at the web app; api. at the API load balancer; cdn. at the CDN distribution; status. at the status page (24.10.3); customer custom domains (Section 21, Business plan) are CNAME'd to a dedicated custom-domain CDN distribution that performs TLS termination and host-based routing back to the watch-page rendering path, tracked in the custom_domains table (Section 5) with certificate provisioning/renewal automation and the expiry alert from 24.5.2.

26.6 The embed script's deployment path #

The embed loader and player core are never deployed as part of apps/web's deploy pipeline and never share its release cadence, because they run embedded in other people's pages across the internet — a page that embedded a video a year ago must keep working today without that page owner taking any action. Instead:

  • packages/player has its own build and release pipeline, independent of the other apps.
  • Every release publishes to a versioned, immutable CDN path (the exact versioning scheme, immutability guarantee, and the manifest-based resolution mechanism that lets a bad release be reverted centrally are specified in Section 15.14 — this section states only where it deploys, not how versioning works).
  • The embed loader (embed.js) itself is served from a stable, unversioned URL (since that is the literal script tag URL already embedded in third-party pages) but internally resolves and lazy-loads the versioned player core via the manifest referenced in 26.2's EMBED_MANIFEST_URL.
  • Deploys to the player are gated by the full player test suite (25.8) including the bundle-size and Lighthouse CI gates, and additionally require a manual promotion step (not automatic on merge to main, unlike apps/web/apps/api) given the blast radius described in the runbook at 24.6.11.

26.7 Feature flags #

Mechanism. The default backend is a feature_flags table (Section 5) read through a short-TTL (30 s) Redis cache, keyed by (flagKey, workspaceId | null) so a flag can be global, per-workspace (staged rollout), or per-environment. FEATURE_FLAGS_PROVIDER=launchdarkly (26.2) is available as a drop-in alternative behind the same internal FeatureFlags TypeScript interface for a team that outgrows the DB-backed approach, without application code needing to change at call sites.

// packages/shared/src/feature-flags.ts
export interface FeatureFlags {
  isEnabled(flagKey: string, context: { workspaceId?: string }): Promise<boolean>
}

Flag lifecycle:

  1. Created — added to the feature_flags table with a description, an owner, a default value, and a kind (release — temporary, guards an in-progress feature; ops — the circuit breakers referenced in 24.6.1/24.6.2; permanent — a genuine long-lived configuration toggle, rare and requires explicit justification at creation time).
  2. Rolled out — percentage or explicit workspace-list rollout, monitored against the metrics/SLOs in 24.2/24.4 for regression before widening.
  3. Fully on — flag evaluates true unconditionally.
  4. Removed — for release-kind flags, the flag check and both code branches it guarded are deleted within one release cycle of reaching "fully on" — a stale flag left in code is treated as tech debt with the same priority as a failing test. ops-kind flags (circuit breakers) are permanent by design and exempt from removal. A quarterly audit lists every release-kind flag older than 60 days as a required cleanup item.

Removal discipline enforcement: the flag creation record requires an expectedRemovalDate for every release-kind flag; a flag past that date without being removed surfaces on the engineering team's weekly dashboard, mirroring the tech-debt visibility the flaky-test tracking gets (25.13).

26.8 Database operations #

Connection pooling. Both apps/api and apps/worker connect through a dedicated connection pooler (e.g. RDS Proxy or PgBouncer in transaction-pooling mode) rather than opening pooled connections directly against Postgres from every horizontally-scaled instance — this bounds total connections against Postgres's max_connections ceiling independent of autoscaling (24.9). Each process's in-process pool (DATABASE_POOL_MAX, 26.2) is sized against the pooler, not against Postgres directly.

Read replica posture. One read replica is provisioned at launch scale, used for analytics rollup queries (Section 16), the internal admin dashboard, and any route explicitly marked replica-tolerant (acceptable only where brief replication lag — sub-second normally, alerted above 30 s per 24.5.2 — does not matter; anything a user is likely to immediately re-read after their own write, e.g. "video I just created," reads from the primary). Additional replicas are added per the capacity plan (24.9) as volume grows.

Backup schedule. Continuous WAL archiving with point-in-time recovery to any second within the retention window, a full daily automated snapshot retained for 35 days, and a monthly snapshot retained for 1 year for compliance/audit purposes (Section 22).

Restore drill. A full restore-from-backup runs quarterly against an isolated environment (never staging or production): the latest daily snapshot is restored to a fresh instance, point-in-time recovery is exercised to a timestamp roughly 6 hours before the snapshot, and the application is smoke-tested against the restored instance to confirm both data integrity and that the restore procedure itself is current and executable by someone who did not write it. A failed or overly slow drill is itself an incident requiring a fix before the next quarter — an unverified backup is not a real backup.

26.9 Cost model #

Costs below are structured as launch scale vs. 10x, broken down per service category, expressed as relative proportions of total infrastructure spend rather than absolute currency figures (which drift with vendor pricing changes and negotiated rates) — the scale targets referenced (workspaces, storage, transcode volume) are defined precisely in 27.1.

Cost category Launch scale driver 10x driver Primary cost lever
Compute (API + workers) Baseline fleet sized to 27.1 launch concurrency Scales roughly linearly with request/job volume, worker fleet dominated by render/transcode CPU-seconds Render worker instance-hours — the single largest compute line item, since ffmpeg burn-in rendering is CPU-intensive per Section 9; the primary lever is aggressive scale-in (24.9) so idle render capacity is not paid for between bursts
Managed transcoding/streaming (Mux) Priced per minute of source ingested and per minute of ABR delivery/storage Scales with total minutes recorded across all workspaces and total playback minutes delivered Encouraging efficient viewing (CDN cache hit ratio reduces re-delivery cost) and the transcode-priority-queue segmentation (Business plan, Section 21) which does not change unit cost but affects capacity provisioning
Object storage Priced per GB stored, scales with total video library size across all workspaces net of retention-driven deletion (Section 19) Scales with total stored minutes; retention policy (Section 19) is the direct lever, since Free-plan 90-day inactive deletion and enforced storage quotas (Section 21) bound unconstrained growth Retention policy enforcement and the plan storage quotas — the single biggest lever, since unredacted-original retention (Sections 11.7, 22.2) and multiple renditions per video multiply raw source size several-fold
CDN egress Priced per GB delivered, scales with total playback volume and embed reach Scales faster than storage, since a single popular embedded video can drive far more egress than its storage footprint would suggest Cache hit ratio (26.5) and appropriate rendition selection (serving the ABR rung matching viewer bandwidth/viewport rather than always the highest)
Transcription vendor Priced per minute of audio processed Scales with total minutes recorded (Pro/Business plans, Section 21, since transcription/captions is not a Free-plan feature) Plan gating itself is the lever — Free-plan recordings do not incur transcription cost
LLM vendor (chapters/summaries/titles) Priced per token, driven by transcript length processed per video Scales with total minutes transcribed on Pro/Business plans Summarization prompt efficiency (processing a chapter-segmented transcript rather than re-processing the full transcript per generated artifact)
Database + Redis Fixed managed-instance cost, scaling in discrete steps (instance size tiers, replica count) rather than continuously Scales in step-function jumps as 24.9's capacity reviews add replicas/upsize instances Query efficiency (27.7) delaying the next instance-size tier longer than volume growth alone would require
Stripe processing fees Percentage of payment volume, scales with subscription revenue directly Scales with revenue Not a lever to reduce — a direct function of revenue growth, tracked as a cost only for full-margin modeling
Observability platform Priced per log/metric/trace volume ingested Scales with request/job volume unless sampling (24.1.4) is tightened The sampling rules in 24.1.4 are the direct lever — they exist explicitly to keep this cost sublinear to raw request volume as scale grows

At 10x launch scale, the cost model shifts from being compute-dominated (launch scale, where baseline fleet and fixed managed-instance costs are a larger proportion of spend) to being usage-metered-dominated (object storage, CDN egress, and Mux minutes, which scale directly with the metrics in 27.1) — this is the expected and intended shape, since usage-metered costs track revenue-generating activity (more recordings, more views) while compute is provisioned ahead of need only within the 40% headroom target in 24.9.


27. Performance Budgets & Scale Targets #

27.1 Scale targets #

Dimension Launch 10x
Active workspaces 2,000 20,000
Total videos stored 150,000 1,500,000
Concurrent recordings (browser + desktop, in progress at once) 150 1,500
Concurrent viewers (simultaneous playback sessions, across all videos) 5,000 50,000
Peak analytics events per second (POST /v1/collect, Section 16) 400 4,000
Total storage volume (all renditions + originals + restricted unredacted originals) 40 TB 400 TB
Peak API requests per second 300 3,000
Peak transcode-orchestration jobs enqueued per minute 100 1,000

These targets are the sizing basis for the capacity plan (24.9), the cost model (26.9), and the load-testing scenarios (25.11). "Launch" is the scale the system must run correctly and within every budget in this section from day one; "10x" is the scale the architecture must accommodate through horizontal scaling and the capacity-planning process (24.9) without an architectural rewrite — it is a design constraint, not a provisioned-on-day-one capacity.

27.2 API latency budgets and payload-size budgets #

Endpoint class Example p50 p95 p99
Simple read (single-resource fetch) GET /v1/videos/:videoId 40 ms 150 ms 400 ms
List read (cursor-paginated, Section 7) GET /v1/videos 60 ms 250 ms 500 ms
Write (create/update, no vendor call) PATCH /v1/videos/:videoId 80 ms 300 ms 600 ms
Write with a synchronous vendor call POST /v1/videos (creates the Mux direct-upload target, Section 9) 150 ms 600 ms 1200 ms
Auth (login, session refresh) POST /v1/auth/session 100 ms 350 ms 700 ms
Analytics ingestion (public, unauthenticated, high volume) POST /v1/collect 15 ms 60 ms 150 ms
Webhook receipt (signature verification + enqueue only, no synchronous processing) POST /v1/webhooks/mux 20 ms 80 ms 200 ms

The 24.4 SLO figures (p95 < 400 ms read, p95 < 800 ms write) are the aggregate commitments across each class; the per-endpoint-class table above is the more granular budget each individual route is engineered against, and is what the merge gate's performance regression check (27.9) measures against directly.

Payload-size budgets: a single API response body is budgeted at ≤ 100 KB uncompressed for a list endpoint at the default page size (25 items, Section 7's pagination default) and ≤ 20 KB for a single-resource fetch; a response approaching these limits is a signal to review whether the endpoint is over-fetching (returning fields a caller does not need — public API responses in particular should not silently grow the per-item payload without a version consideration, Section 7). Request bodies are capped at 1 MB for standard JSON endpoints (Fastify body-limit configuration) — anything larger (media itself) never goes through a JSON endpoint at all and instead uses the S3 multipart upload path (Section 9), which bypasses the API's body parser entirely via presigned URLs.

27.3 Web app performance budgets #

Route class LCP INP CLS JS bundle budget (gzipped, route-specific chunk)
Marketing site (public pages) ≤ 2.0 s ≤ 150 ms ≤ 0.05 ≤ 90 KB
Dashboard (library, folders) ≤ 2.5 s ≤ 200 ms ≤ 0.10 ≤ 180 KB
Editor (timeline, EDL manipulation, Section 11) ≤ 3.0 s ≤ 200 ms ≤ 0.10 ≤ 320 KB (the editor is the most feature-dense surface and is allotted the largest budget accordingly)
Watch page (embeds the player, Section 15) ≤ 1.8 s ≤ 150 ms ≤ 0.05 ≤ 60 KB for the watch-page shell itself — the player bundle is separately budgeted in Section 15.2/27.4 and is not counted against this figure, since it lazy-loads independently

Targets are "good" thresholds per the Core Web Vitals field-data methodology (75th-percentile field data, not lab-only), measured continuously against real traffic once at launch scale, and as a CI lab-data proxy (Lighthouse CI, 25.8) on every relevant PR before that traffic exists. Route-specific JS bundle budgets are enforced by size-limit configured per Next.js route chunk, mirroring the mechanism used for the player (25.8) — a PR that grows a route's chunk past budget fails CI and must either justify the increase with an explicit budget-change PR reviewed by the team, or reduce the chunk (code-splitting a rarely-used feature behind a dynamic import is the typical fix).

27.4 The player budget #

Section 15.2 owns the player's size and host-page-impact figures in full (shorthand: loader <= 8KB gz, core <= 20KB gz) — this section references that budget rather than restating its individual figures, so the numbers cannot drift between the two sections. Within the overall performance posture, the player budget is the strictest in the document because it is the only budget that executes inside someone else's page rather than Reelay's own surfaces, where any regression is immediately and visibly a Reelay problem on a customer's marketing site, sales deck page, or product page. It is enforced in CI exactly as described in 25.8 (the size-limit gate and the Lighthouse-against-a-real-host-page check), and a regression here is treated with the same severity as the runbook in 24.6.11 describes for a bad release already in production — caught before merge, not after.

27.5 Media performance #

Metric Target Measured by
Upload-complete to playable, 10-minute recording p95 < 6 minutes (matches the SLO in 24.4) media_upload_to_playable_seconds{source_duration_bucket="2m_10m"} (24.2.3)
Player start-time-to-first-frame p75 < 1000 ms, p95 < 2000 ms player_start_time_to_first_frame_ms (24.2.3)
Rebuffer ratio p75 < 1%, p95 < 5% of session time spent rebuffering player_rebuffer_ratio (24.2.3)
Auto-edit solver throughput The solver must process telemetry faster than real-time: for an N-minute recording, solver wall-clock time budget is ≤ N/4 minutes (i.e. a 10-minute recording's zoom timeline is computed in ≤ 150 seconds), so solving never becomes the bottleneck in the upload-to-playable budget above — the render (ffmpeg burn-in) step, not the solver, dominates that budget A dedicated solver-throughput benchmark run in the nightly CI job (25.13) against the largest golden fixture telemetry file (25.5.1), failing if wall-clock time exceeds the ratio above on the CI runner's reference hardware profile

27.6 Editor performance #

Interaction Budget
Timeline scrub/zoom/pan interaction response (input to visual update) ≤ 16 ms (single frame at 60 fps) — the timeline UI must never drop below 60 fps during direct manipulation
Waveform render time (initial, for a video up to the 4-hour soft cap, Section 21) ≤ 500 ms to render the visible viewport's waveform from cached peak data; full-file peak-data generation happens server-side during ingest (Section 9) and is fetched, never computed client-side from raw audio
Preview scrub latency (dragging the playhead to a new position and seeing the corresponding frame) ≤ 100 ms from pointer position to displayed frame, using the already-transcoded low-resolution preview rendition (a dedicated fast-seek rendition generated during the transcode ladder, Section 9, distinct from the delivery renditions) rather than seeking the full-resolution source
EDL operation apply (trim, silence-remove) UI feedback ≤ 50 ms from user action to the timeline visually reflecting the operation (the operation itself, per Section 11, is a local EDL document mutation, not a server round-trip — persistence to the server happens asynchronously and does not block the UI update)
Frame budget (editor UI overall, per animation frame during active interaction) ≤ 16 ms total JS execution per frame; any computation exceeding this (waveform peak recalculation, timeline ruler relayout at extreme zoom levels) is moved off the main thread via a Web Worker

27.7 Database performance #

Slow-query threshold: any query exceeding 200 ms in production is logged at warn level (24.1.3) with its parameterized query shape (not literal parameter values, per the never-log-sensitive-data discipline extended here to avoid logging user content embedded in query parameters) and flagged on the query performance dashboard; any query exceeding 1000 ms additionally fires a warn-level alert (below SEV3 paging threshold, tracked not paged) since a single slow query is rarely an incident by itself but a rising rate of them is an early capacity signal (24.9).

Index strategy summary: Section 5 owns the full index definitions per table. The general policy applied there: every foreign key column is indexed, every column used in a WHERE clause on a route in the performance-budget-critical path (27.2) is covered by an index verified via EXPLAIN ANALYZE during development against production-scale synthetic data (not just the small local seed set, 26.3), and the partitioned video_view_events table (Section 16) is indexed per-partition with monthly partition pruning relied upon to keep query scan volume bounded regardless of total historical event count.

N+1 prevention policy: Drizzle queries in any route handler or job handler that fetches a collection and then needs related data per item must use a single joined query or a single batched IN (...) follow-up query — never a query inside a loop. This is enforced by a lint rule that flags any Drizzle query call (db.select/db.query) found lexically inside a for/forEach/map callback, requiring an explicit // eslint-disable-next-line -- batched intentionally, see <reason> escape hatch for the rare legitimate exception, which itself is caught in code review by the presence of the comment.

Connection budgets: per 26.8, connections are bounded at the pooler, not per-instance; the aggregate budget is Postgres's max_connections minus a reserved headroom for the restore-drill/admin/replica-setup connections (26.8), split across the pooler's transaction-pooling multiplexing such that the effective application-visible connection ceiling scales with autoscaled instance count without approaching the underlying Postgres ceiling — the pooler is sized so that even at 10x scale's peak instance count (27.1), aggregate pooled connections against the database itself stay under 80% of max_connections.

27.8 Queue performance #

Queue Throughput target Max acceptable job age Backpressure policy
video.ingest 200 jobs/min at launch, 2,000/min at 10x 60 s New uploads still accepted (the upload itself, per Section 9, is independent of this queue's depth); ingest jobs simply queue longer, which lengthens the upload-to-playable budget (27.5) — this is the queue where backpressure is most visible to users first, so its age threshold feeds the queue-backlog alert (24.5.2) at a tighter threshold than other queues
video.transcode.request (orchestration, lightweight) 100 jobs/min launch, 1,000/min at 10x 60 s Same as above — orchestration itself is not the bottleneck (it calls Mux and returns), so backpressure here almost always indicates a downstream Mux-side or worker-capacity issue (24.6.1, 24.6.3), not orchestration queue saturation itself
video.render.compose 20 renders/min launch, 200/min at 10x (CPU-bound, scales with worker fleet size per 24.9) 300 s When queue depth exceeds capacity, jobs wait rather than being dropped — BullMQ queues never drop jobs under load, they only age; sustained backpressure here triggers the scaling trigger in 24.9 before it becomes a user-visible delay beyond the 27.5 budget
transcription.request 50 jobs/min launch, 500/min at 10x 120 s Same wait-not-drop policy; a sustained backlog here is a vendor-capacity or vendor-outage signal (24.6.2) more often than a Reelay-side capacity issue, since transcription is I/O-bound on the vendor
analytics.rollup.hourly Fixed cadence (hourly), not a continuous-throughput queue 1 rollup cycle (i.e. an hourly rollup must complete before the next hour's rollup is due) If a rollup run overruns into the next scheduled run, the next run is skipped (not queued to run twice back-to-back) and an alert fires — rollups are idempotent and re-runnable, so a skipped cycle is caught up by the following successful run reading the full un-rolled-up event window, per Section 16
video.deletion.purge 30 jobs/min 3600 s (deletion is correctness-critical, not latency-critical, per Section 19 — a slower purge is acceptable, a lost purge is not, so this queue's age threshold is deliberately loose while its correctness verification, 24.6.6, is strict) Never backpressure-limited on intake — every deletion request is accepted immediately; only processing throughput is throttled, since deletions must never be rejected due to load

General backpressure policy: no Reelay queue drops a job under load — BullMQ's Redis-backed persistence means backpressure always manifests as increased job age, never data loss, which is why the DLQ (24.8) and the queue-age alerting (24.5.2) rather than a rejection/drop mechanism are the operative safety nets. Where a queue's producer-side rate genuinely needs limiting (protecting against a runaway retry storm), rate limiting is applied at the BullMQ queue's own limiter configuration (max jobs per time window per queue) rather than by rejecting the enqueue call, so the caller (an API request handler, for instance) never has to handle a "queue is full" error case — it always succeeds in enqueuing; the queue absorbs the timing.

27.9 Measurement, surfacing, and regression handling #

Every budget stated in this section is measured continuously in production via the metrics defined in 24.2 and the client-reported beacons defined in Section 16, and as a pre-merge CI proxy via the mechanisms in 25.8 and 25.13 (Lighthouse CI, size-limit, the solver-throughput benchmark) — a budget with no continuous production measurement is not considered a real budget, since CI lab conditions alone cannot catch a regression caused by real-world network conditions, real device diversity, or organic scale growth.

Where the numbers surface: a single internal performance dashboard aggregates every budget in this section against its current measured value, color-coded against the budget threshold (green under 80% of budget, amber 80–100%, red over budget), reviewed in the same monthly (weekly once past 500 workspaces) capacity-review cadence as 24.9, so performance and capacity are reviewed together rather than as separate disciplines that could silently drift apart.

What happens when a budget regresses:

  1. A CI-caught regression (bundle size, Lighthouse score, solver throughput benchmark) fails the build directly (25.8, 25.13) — the PR cannot merge until the regression is fixed or the budget is explicitly and deliberately raised via a reviewed budget-change PR that states the tradeoff being made.
  2. A production-measured regression that breaches an SLO-backing budget (API latency, playback start, time-to-ready — the subset of this section's budgets that are also 24.4 SLOs) follows the burn-rate alerting and error-budget policy in 24.4 directly, including the deploy-pause consequence of a fully consumed error budget.
  3. A production-measured regression in a budget that is not itself an SLO (e.g. editor frame budget, 27.6, which has no client-reported beacon feeding an SLO today) is caught by the dashboard's amber/red threshold and filed as a SEV3-equivalent ticket (24.5.1) at the next capacity/performance review rather than paging anyone, since these budgets are engineering quality targets rather than customer-facing reliability commitments — the distinction is deliberate: not every number in this document is an SLO, but every number is measured, surfaced, and owned.

28. Milestones & Execution Plan #

28.1 How to read this plan #

This section sequences the entire build into 19 milestones, M0 through M18. Each milestone states its goal, the ordered work items, the sections of this document that specify the work (by number — this section never redefines anything, it only sequences and tests), the milestones it depends on, and exit criteria that are verifiable by running a command, a test suite, or a CI job. An exit criterion that cannot be automated is not an exit criterion in this plan — where a check is inherently manual (a design review, a legal sign-off), it is called out explicitly as manual and kept out of the pass/fail gate.

Work within a milestone should be built in the listed order where items depend on each other, but is not required to land in a single pull request. Section 28.6 (definition of done) applies to every milestone regardless of size.

28.2 Milestone M0 — Foundations #

Goal: Stand up the monorepo, CI, environment validation, the database, and core authentication so every later milestone has a working build, a database, and a way to log a user in.

Specifies this work: Sections 3, 4, 5 (core auth/workspace tables only), 6 (authentication portion only — role enforcement is M1), 24 (baseline logging), 25 (test tooling baseline), 26 (environments and config validation).

Depends on: nothing — this is the entry point.

Work items:

  1. Initialize the pnpm-workspaces + Turborepo monorepo with apps/web, apps/api, apps/desktop, apps/worker, packages/player, packages/db, packages/shared, packages/ui as empty, buildable packages, each named for its directory (e.g. the package at apps/web is named web).
  2. Configure TypeScript project references, ESLint, Prettier, and pre-commit hooks per Section 4.
  3. Define the environment variable schema (Zod) and fail-fast startup validation per Section 26.
  4. Provide a docker-compose.yml bringing up PostgreSQL 17 and Redis 8 for local development.
  5. Write the Drizzle schema for users, sessions, oauth_accounts, mfa_credentials, workspaces, workspace_members (structure only — role enforcement logic is built in M1) per Section 5.
  6. Generate and run the first migration with drizzle-kit.
  7. Implement email + password registration/login (Argon2id per Section 6), Google OAuth 2.0 (PKCE), session cookie issuance, and email verification gating per Section 6.
  8. Wire the CI pipeline: lint, typecheck, unit test, build, on every pull request.
  9. Add baseline structured logging and request-id propagation per Section 24.

Exit criteria:

  • pnpm install && pnpm turbo run build exits 0 across all eight packages.
  • pnpm turbo run typecheck and pnpm turbo run lint both exit 0.
  • docker compose up -d db redis && pnpm --filter db migrate applies every migration with no errors; psql "$DATABASE_URL" -c "\dt" lists users, sessions, oauth_accounts, mfa_credentials, workspaces, workspace_members.
  • pnpm --filter api test passes, including a test that POST /v1/auth/signup returns 202 Accepted with the success envelope from Section 7.3, and a subsequent POST /v1/auth/login returns a session cookie.
  • Starting apps/api with a required environment variable unset exits non-zero with a readable error before binding a port (proves fail-fast validation).
  • The CI workflow runs green on a clean pull request opened against a fresh clone.

28.3 Milestone M1 — Workspaces, roles, permissions, the authorization enforcement point #

Goal: Every workspace has enforced role-based access control so no request can act outside the caller's permitted scope.

Specifies this work: Sections 5 (workspace_invites and related schema), 6 (roles), 7 (error envelope for 403).

Depends on: M0.

Work items:

  1. Extend the schema with workspace_invites and seat-accounting fields per Section 5.
  2. Implement the four roles — owner, admin, member, viewer — exactly as Section 6 defines them, enforcing exactly one owner per workspace.
  3. Build the single authorization enforcement point used by every API route — no per-route ad hoc checks — per Sections 6 and 7.
  4. Build workspace CRUD and the invite/accept/revoke flow per Section 6.
  5. Build seat accounting (viewer seats free and unlimited; recording seats counted) per Section 6, with full cap enforcement deferred to M13.
  6. Write a role × action contract-test matrix.

Exit criteria:

  • pnpm --filter api test -- rbac runs a matrix test asserting all four roles against a representative action set (create video, invite member, change billing, delete workspace, watch, comment) matches Section 6's table exactly; any mismatch fails the test.
  • An admin calling DELETE /v1/workspaces/:id (owner-only) receives 403 with error code forbidden in the Section 7.6 envelope; the test asserts the exact code string.
  • Attempting to create a second owner on a workspace fails; a test asserts the rejection.
  • A repository-wide check (CI-run grep or lint rule) confirms no authorization decision is made by matching an email domain — proof that membership is never implicit, per Section 6.

28.4 Milestone M2 — Browser capture, local-first buffering, resumable upload, ingest #

Goal: A user can record their screen in the browser and have the recording reliably reach the server through app crashes and network loss.

Specifies this work: Section 8 (browser capture matrix), Section 9 (local-first buffering, resumable upload, ingest).

Depends on: M0, M1 (a recording belongs to a workspace member).

Work items:

  1. Implement getDisplayMedia + getUserMedia + MediaRecorder capture with the codec probe/fallback order from Section 8.
  2. Persist 3000 ms chunks to OPFS (IndexedDB fallback) per Section 9.
  3. Build the dedicated upload Worker: S3 multipart, 8 MB parts, up to 4 concurrent, exponential backoff with jitter, per Section 9.
  4. Detect and offer resume on next app launch; retain local chunks until the server confirms multipart completion, then delete, per Section 9.
  5. Implement the video.ingest job: create videos/recordings/media_assets rows, trigger ffprobe per Section 9.
  6. Write a chaos test that kills the network mid-upload and verifies resume completes without loss.

Exit criteria:

  • A Playwright test using a fake-media-capable browser records at least 5 seconds, uploads, and asserts the resulting media_assets.size_bytes matches the local blob size.
  • An integration test starts a 5-part multipart upload, kills the connection after part 2, restarts the client, and asserts the upload completes with a checksum identical to the source file — the automated proof of "a recording is never lost because the network failed" (Section 9).
  • A unit test asserts the fallback order from Section 8 is followed when higher-preference mime types report unsupported via MediaRecorder.isTypeSupported, and that the chosen mime is persisted on the recording row.
  • An E2E test force-closes the tab mid-recording, reopens the app, accepts the resume prompt, waits for the resumed upload to finish, and asserts the resumed upload's checksum is identical to a non-interrupted control recording of the same fixture. A resume prompt appearing is necessary but not sufficient to pass this criterion — a resume that completes but produces a corrupt or truncated file must fail it.

28.5 Milestone M3 — Media pipeline, transcode, playback, the watch page #

Goal: An ingested recording becomes a watchable, adaptively streamed video on its own watch page.

Specifies this work: Section 9 (pipeline), Section 15 (the player core, used by the watch page — the full embed loader and its performance budget gates are M4).

Depends on: M2.

Work items:

  1. Integrate Mux direct-upload/asset-create behind the vendor-swappable interface from Section 9.
  2. Trigger the transcode ladder and implement the signature-verified, idempotent webhook handler per Sections 7 and 9.
  3. Generate posters and thumbnails per Section 9.
  4. Build the watch page route in apps/web, rendering the player core against the Mux playback ID.
  5. Configure BullMQ per Section 9: 5 attempts, exponential backoff, per-queue concurrency, *.dlq dead letter queues, job.id = <entity>:<operation>:<version>.

Exit criteria:

  • Uploading a fixture MP4 through the M2 pipeline results in a watch page that plays back within 2 minutes in a Playwright test asserting the player reaches readyState >= 3.
  • Delivering the same Mux webhook fixture twice results in exactly one transcode-complete state transition — proves idempotency per Sections 7 and 9.
  • Killing the worker process mid-job and restarting it resumes from BullMQ's persisted state rather than restarting the pipeline, verified by asserting the job log shows no duplicate ffprobe invocation.
  • A webhook delivered with an invalid signature is rejected with 401 and never mutates the video row.

28.6 Milestone M4 — The embeddable player and its performance budget gates #

Goal: Any video can be embedded on a third-party host page inside an isolated, measurably lightweight player.

Specifies this work: Section 15 (player), Section 27 (performance budgets referenced), Section 23 (keyboard operability referenced).

Depends on: M3.

Work items:

  1. Build packages/player's embed loader (embed.js) and lazy-loaded player core as two separate bundles per Section 15.
  2. Implement closed Shadow DOM style isolation with zero global CSS leakage per Section 15.
  3. Implement intent-based lazy load (viewport intersection or click) with preload="none" until intent, per Section 15.
  4. Reserve the aspect-ratio box pre-load and use a loading="lazy" decoding="async" poster <img> per Section 15.
  5. Implement sendBeacon analytics with a rotating anonymous viewer id and zero host-domain cookies per Section 15.
  6. Build the email-embed fallback pipeline (poster/GIF + link) per Section 15.
  7. Wire a size-limit CI job enforcing the two byte budgets from Section 15.

Exit criteria:

  • The size-limit CI job fails the build if embed.js exceeds 8 KB gzipped or the player core exceeds 20 KB gzipped; a throwaway branch that adds 1 KB of dead weight is confirmed to fail this gate.
  • A Lighthouse CI run against a fixture host page embedding the player reports a CLS contribution of 0.0 and excludes the poster from the LCP element, with numeric thresholds asserted in CI config.
  • A Playwright test drives the player using only the keyboard (Tab, Space, Arrow keys) and asserts play, pause, and seek all work.
  • Inspecting document.cookie on the host page after player load returns no Reelay-set cookies.
  • Rendering the player with JavaScript disabled (email-client simulation) shows the poster/GIF fallback with a working link, verified by a snapshot test.

Goal: A video can be shared with precisely the intended audience, and every permission change is recorded.

Specifies this work: Section 14 (share link security, audit trail).

Depends on: M3, M1 (reuses the M1 enforcement point).

Work items:

  1. Build share_links schema/CRUD: visibility, password (Argon2id), expiry, domain allowlist, disable-download, disable-comments, require-email, per Section 14.
  2. Implement signed, short-TTL Mux playback token issuance (6h TTL), re-issued by the watch page and player, per Section 14.
  3. Build share_link_recipients personalized tokens per Section 14.
  4. Write a share_audit_events row on every visibility/permission change (actor, before, after, timestamp, IP) per Section 14.
  5. Enforce the domain allowlist server-side, not only via referer header, per Section 14.

Exit criteria:

  • Setting a link to private and attempting anonymous playback returns 403 in an integration test.
  • Changing a link's visibility writes exactly one share_audit_events row whose before/after JSON matches the request, verified by a diff assertion.
  • A playback token issued at T0 is rejected after T0+6h1m in a test using an expired fixture token, and the player is shown to request re-issuance.
  • A password-protected link rejects an incorrect password with a rate-limited 401 and accepts the correct password exactly once per session.
  • A named regression test (e.g. share-link-privacy-regression.spec.ts) creates a private link and asserts it is neither enumerable nor playable by an unauthenticated client — the automated guard against "a video made public that was meant to be internal."

28.8 Milestone M6 — Transcription, captions, the transcript view #

Goal: Every video has an accurate, searchable transcript and standards-compliant captions.

Specifies this work: Section 12 (transcription and captions portion), Section 5 (transcripts, transcript_segments, captions schema).

Depends on: M3.

Work items:

  1. Build the audio-extract-then-transcribe async job per Sections 9 and 12.
  2. Store transcript_segments with word/segment-level timestamps per Section 12.
  3. Generate VTT captions and attach them to renditions per Section 12.
  4. Build the transcript view UI: synced highlighting, click-to-seek.
  5. Implement the idempotent, signature-verified transcription webhook per Sections 7 and 9.

Exit criteria:

  • A fixture video with known speech produces a transcript whose word count is within an asserted tolerance of a golden transcript, checked automatically in CI.
  • The generated VTT file validates against the WebVTT format with zero syntax errors using an automated parser.
  • A Playwright test clicks a transcript segment and asserts the player seeks to that segment's start time within ±250 ms.
  • Re-delivering the same transcription webhook twice produces exactly one set of transcript_segments rows.

28.9 Milestone M7 — The auto-edit engine (telemetry, interest events, zoom timeline, motion, backgrounds, render) #

Goal: A raw recording is automatically transformed into a polished, cursor-aware edited video with no manual editing step.

Specifies this work: Section 10 (auto-edit engine, in full), Section 8 (cursor telemetry, browser path), Section 5 (cursor_telemetry_blobs, edit_decision_lists, auto_edit_presets schema).

Depends on: M2 (telemetry capture), M3 (render/transcode infrastructure).

Sequencing note: the auto-edit engine requires only that telemetry exist in the common cursor_telemetry_blobs shape (Section 5) — it does not require the desktop app. Browser pointer-event telemetry from M2 (degraded per Section 8) is sufficient to build, test, and ship this milestone. The desktop app (M14) later supplies higher-fidelity 120 Hz native telemetry through the same schema with no interface change. This confirms the suggested spine's ordering is correct: M7 precedes M14 because M14 is a data-quality upgrade to an already-working engine, not a prerequisite for it.

Work items:

  1. Build the interest-event detector (click, drag start/end, typing burst, scroll start, window focus change) per Section 10.
  2. Build the zoom timeline builder using the exact constants from Section 10 — 1.6x default zoom, 2.5x max, 1200 ms minimum hold, 800 ms minimum gap, 400 ms lead-in, 600 ms ease-out, 900 ms coalescing window — plus the safe-area/resolution guard.
  3. Implement the critically damped spring camera path (ω₀ = 9.0 rad/s, ζ = 1.0) with frame-rate- independent integration, a One-Euro filter on cursor position, and the anti-jitter deadzone, per Section 10.
  4. Implement auto-framing: the inner 60% safe rect and the hysteresis band, per Section 10.
  5. Implement background presets (gradient, solid, image, blurred-screenshot, macOS-style), with the padding, corner-radius, shadow spec, and aspect-ratio conversion rules from Section 10.
  6. Persist output as an EditDecisionList JSON document, never baked pixels; tag the video with an immutable auto_edit_preset_version per Section 10.
  7. Implement the render worker path that consumes EDL + source per Sections 9 and 10.
  8. Build a determinism test harness: same source + same telemetry + same preset version must produce a byte-identical render.

Exit criteria:

  • Given a fixture telemetry blob, the interest-event detector's output matches a golden fixture list exactly (unit test, exact equality).
  • Given a fixture interest-event list, the zoom timeline builder's output satisfies every numeric constant in Section 10 individually (no hold below 1200 ms, no gap below 800 ms, coalescing window respected, lead-in and ease-out present on every segment).
  • Rendering the same source, telemetry, and preset version twice produces two files with an identical SHA-256 checksum — the automated proof of the determinism requirement.
  • A property-based test sweeping interest-event coordinates to the extreme edges of the source frame produces zero crops that exceed source resolution or leave the safe area.
  • After rendering, the original media_assets row and its bytes are checksum-identical to before rendering, while a new rendition exists — proves auto-edit never destroys the original.

28.10 Milestone M8 — The timeline editor and the EDL, non-destructive editing, redaction burn-in #

Goal: A human can manually adjust the auto-edit output — trim, cut, redact — without ever altering the original recording, and redacted regions are unrecoverable by any viewer.

Specifies this work: Section 11 (EDL, timeline editor), Section 12 (filler-word/silence removal), Sections 11.7, 22.2 (redaction as a security property, cross-referenced).

Depends on: M7 (edits a real EDL), M3 (render worker).

Work items:

  1. Build the timeline editor UI reading and writing the canonical EDL schema from Section 11, on top of the auto-edit EDL produced in M7.
  2. Implement filler-word and silence removal as EDL operations with explicit source-time ranges, individually revertible, where "restore all" returns exactly the original timeline, per Section 11.
  3. Build redaction region authoring (static rect or keyframed track) in the editor per Sections 11 and
  4. Implement server-side redaction burn-in in the render worker into every delivered rendition and export; retain the unredacted original only in the restricted storage bucket defined in Section 22.2.2, never served to a viewer, per Sections 11.7 and 22.2.
  5. Implement the restricted_media.accessed audit-log write (Section 22.2.3) as part of this milestone: every request that resolves to the unredacted-original asset URL — whether allowed or denied — inserts exactly one audit_events row (actor, action restricted_media.accessed, video id, result, timestamp). This write is pulled forward into M8 rather than deferred to the Section 22 hardening pass in M18: this milestone's own exit criteria assert on it directly, so it must exist by the time those criteria run, or the exit criteria would not be buildable in sequence. (This is the general pattern to watch for across this whole plan: an exit criterion may never assert on a capability whose only build step lives in a later milestone. Every other milestone in this section has been checked against that same rule; this is the one place it was found to be violated, and it is fixed here.)
  6. Guard: a video with a pending, unrendered redaction cannot be shared or made playable.

Exit criteria:

  • Applying then reverting a single filler-word removal, then "restore all," reproduces an EDL JSON byte-identical to the pre-edit state (automated JSON diff, zero delta).
  • Frame-sampling a rendered export at a redacted timestamp shows no legible content in the redacted pixel rect, verified by an automated pixel-region variance/blur threshold assertion.
  • Requesting a share link for a video with redaction_status = pending returns a 409-class error with code redaction_pending in the Section 7.6 envelope, and no signed playback URL is issued.
  • A viewer-role request for the unredacted original asset URL returns 403 and writes exactly one restricted_media.accessed audit_events row (actor, denied result, timestamp), per Section 22.2.3 — the write path built by work item 5 above, verifiable in this milestone without waiting on M18.
  • Running the non-destructive-invariant test defined in Section 25.6 — N randomized edit-and-re-render cycles driven through this milestone's timeline editor — confirms the source media_assets object's SHA-256 checksum is byte-for-byte identical before and after every one of the N cycles. This exit criterion and the Section 25.6 test definition are the same test, cross-referenced by section number here so the two cannot drift apart.

28.11 Milestone M9 — AI chapters, summaries and titles with the human-acceptance flow #

Goal: Every video gets AI-suggested chapters, a summary, and a title that never become visible to anyone until a human explicitly accepts them.

Specifies this work: Section 12 (AI metadata), Section 5 (ai_metadata_suggestions schema).

Depends on: M6 (needs a transcript), M3.

Work items:

  1. Build the chapter/summary/title generation job consuming transcript_segments per Section 12.
  2. Store suggestions in ai_metadata_suggestions as pending; never copy into public video metadata automatically, per Section 12.
  3. Build the acceptance UI: accept, edit, or reject per suggestion; only accepted content becomes the video's public chapters, summary, or title.
  4. Build the regeneration path for when a transcript is corrected or re-run.

Exit criteria:

  • Generating suggestions for a fixture video populates ai_metadata_suggestions while leaving the video's public title, chapters, and summary fields unchanged, verified by an integration test.
  • A test attempting to write the public metadata fields through any path other than the acceptance endpoint is rejected by a database constraint/trigger or an application-layer guard — the automated proof that acceptance is the only path to publication.
  • Rejecting a suggestion leaves existing public metadata untouched, and the rejected row is retained with status = rejected.
  • An E2E test generates suggestions, views a pending suggestion, accepts it, refreshes the watch page, and asserts the new title and chapters are visible.

28.12 Milestone M10 — Analytics collection, rollups and dashboards #

Goal: Every playback session produces reliable engagement data that rolls up into dashboards workspace members can trust.

Specifies this work: Section 16 (analytics model).

Depends on: M4 (player emits events), M3.

Work items:

  1. Build the public, rate-limited POST /v1/collect endpoint per Sections 7 and 16.
  2. Build monthly-partitioned video_view_events ingestion per Section 16.
  3. Build hourly rollup jobs producing video_view_daily and video_engagement_curve (capped at 1800 buckets) per Section 16.
  4. Implement viewer identity: anonymous 30-day viewerToken versus named viewer, per Section 16.
  5. Implement DNT/GPC handling per Sections 16 and 22.
  6. Build the dashboard UI, reading rollups only, never raw events.

Exit criteria:

  • Sending a sendBeacon batch of play/heartbeat/pause/complete events to /v1/collect produces rows in video_view_events within the same integration test's request cycle.
  • Running the hourly rollup job against a fixture day of raw events produces a video_view_daily row whose aggregates match a hand-computed golden value exactly.
  • A fixture 90-minute video's engagement curve has no more than 1800 buckets, asserted directly.
  • A request sent with DNT: 1 is not associated with a named viewer identity even when a recipientToken is present.
  • A query-log assertion confirms dashboard requests read only rollup tables, never video_view_events directly.

28.13 Milestone M11 — Comments, reactions, CTAs, email capture #

Goal: Viewers can engage with a video and workspace members can see and moderate that engagement.

Specifies this work: Section 17 (engagement).

Depends on: M4 (player surface), M5 (viewer/link context), M10 (viewer identity model reused).

Work items:

  1. Build comments (threaded, timestamped-in-video) and comment_reactions schema/API per Section 17.
  2. Build moderation (delete/hide) restricted to member+ roles per Sections 6 and 17.
  3. Build ctas and cta_events: configurable CTA overlays with click tracking per Section 17.
  4. Build email_captures, feeding the require-email-to-watch gate from Section 14.
  5. Enforce the disableComments share-link flag from Section 14.

Exit criteria:

  • A viewer can post a comment; a viewer deleting another viewer's comment gets 403; a member+ deleting it succeeds — verified by a role-matrix test reusing the M1 enforcement point.
  • A share link with disableComments = true returns 403 on POST /v1/videos/:id/comments.
  • A Playwright test clicking a CTA writes exactly one cta_events row and increments the dashboard's click-through count by 1.
  • A require-email-to-watch link blocks playback token issuance until an email_captures row exists for that viewer/link pair.

28.14 Milestone M12 — Library, folders, brand kit, custom domains #

Goal: Workspaces can organize videos at scale and present them under their own brand and domain.

Specifies this work: Section 18 (library, folders, brand kit, custom domains).

Depends on: M1 (workspace/roles), M5 (share links apply brand kit to the watch/share experience).

Work items:

  1. Build folders and folder_permissions (nested, per-folder grants) per Section 18.
  2. Build the library video-cap enforcement hook point, with real enforcement wired in M13, per Sections 18 and 21.
  3. Build brand_kits (logo, colors, fonts) applied to the watch page/player chrome and the email fallback, per Sections 18 and 15.
  4. Build custom_domains: CNAME verification flow and TLS provisioning per Section 18, gated to Business plan in M13.

Exit criteria:

  • Granting a viewer folder-level access in a nested folder structure allows watching videos in that folder without workspace-wide access.
  • A brand-kit-enabled workspace's watch page renders the configured colors and logo, asserted in the DOM by a Playwright test.
  • Submitting a CNAME followed by a mock DNS check returning the correct target flips custom_domains.status to verified; an incorrect DNS response leaves it pending with an incremented retry count.
  • Moving a video between folders never changes its share URL, verified by capturing the URL before and after the move and asserting equality.

28.15 Milestone M13 — Billing, plans, usage metering and server-side enforcement #

Goal: Plan limits are enforced everywhere they apply, entirely server-side, without ever interrupting an already-shared video.

Specifies this work: Section 21 (billing, plans, enforcement), Section 19 (the iron rule, cross-referenced).

Depends on: M1, M2, M3, M12 (the metered features must exist to be metered).

Work items:

  1. Build plans, subscriptions, usage_counters, usage_events, invoices schema and the Stripe Node SDK integration per Section 21.
  2. Build server-side enforcement middleware, checked on every capped action (recording length, library cap, storage quota, seats), reusing the M1 enforcement pattern, per Sections 6 and 21.
  3. Build the 80%/100% warning notifications (in-app and email) per Section 21.
  4. Build the downgrade-to-read-only-for-creation state machine, verifying playback is never gated by plan state, per Sections 19 and 21.
  5. Wire the player watermark toggle by plan (Free forced on) per Sections 21 and 15.
  6. Build the public API/webhooks capability gate (Business only), ready for M16.

Exit criteria:

  • A direct API call attempting to start a recording longer than 5 minutes on the Free plan is rejected server-side with 403 code plan_limit_exceeded, even when the call bypasses client-side checks.
  • Downgrading a workspace from Pro to Free while it holds 40 library videos (over the 25 cap) is followed, in the same test, by a fresh playback-token issuance request for one of those 40 already-shared videos — the same request the watch page/player makes to re-issue its short-TTL signed playback token, per Section 14 — and that request must still return 200 with a valid, playable signed URL post-downgrade. New video creation in the same workspace, in the same test run, returns 403 plan_limit_exceeded. This is the actual assertion oracle for the iron rule: the specific playback request that must still succeed after the workspace breaches its cap is the playback-token (re-)issuance request for a video the workspace already shared before the breach — not merely an assertion that the share_links row remains un-revoked. This is the automated proof of the iron rule in Sections 19 and 21.
  • Crossing 80% of storage quota triggers exactly one warning per rolling period, with no duplicates from repeated small uploads under the threshold.
  • A Free-plan export is capped at 720p with a watermark; a Business-plan export of the same source is unwatermarked and allows up to 4K, asserted from the render job's output parameters.
  • A call-graph or coverage check confirms every capped action in Section 21's limits table routes through the single enforcement module.

28.16 Milestone M14 — The desktop app (system audio, high frame rate, cursor telemetry) #

Goal: Users get studio-quality capture — system audio, 60 fps, native cursor telemetry — via the Electron app, upgrading every downstream feature's input fidelity transparently.

Specifies this work: Section 8 (desktop capture matrix).

Depends on: M2 (shared ingest/upload pipeline), M7 (consumes the same cursor_telemetry_blobs schema).

Work items:

  1. Build the Electron shell and desktopCapturer integration per Section 8.
  2. Implement macOS ScreenCaptureKit/Core Audio tap (13+) and Windows WASAPI loopback for system audio per Section 8.
  3. Implement native cursor telemetry hooks at 120 Hz per Section 8.
  4. Implement local hardware encode and local-first disk write, reusing the M2 resumable-upload contract, per Section 9.
  5. Build the desktop auto-update channel.

Exit criteria:

  • A desktop recording's cursor_telemetry_blobs row has an average inter-sample interval of approximately 8.3 ms (±10%, i.e. 120 Hz), compared to a browser recording's approximately 16.7 ms (60 Hz), verified by an automated comparison test.
  • A desktop-app recording captures non-tab system audio, verified by an automated audio-track presence and non-silence check against a fixture playing audio from an application other than the browser.
  • Force-quitting the desktop app mid-recording and relaunching produces a resumed upload checksum-identical to a non-interrupted control recording, using the same chaos-test pattern as M2.
  • The M7 determinism and interest-event test suite passes unmodified against a desktop-fixture telemetry blob, proving the auto-edit engine required zero code changes.

28.17 Milestone M15 — Screenshots and beautification #

Goal: A user can pull a beautified still frame or free-standing screenshot out of any video or capture session.

Specifies this work: Section 13 (screenshot capture and beautification).

Depends on: M3 (frame extraction from renditions), M7 (reuses background/shadow/padding presets from Section 10).

Work items:

  1. Build the timestamped frame-grab endpoint and a standalone screenshot capture mode per Section 13.
  2. Build beautification (background presets, padding, corner radius, shadow, chrome/device frame mockups) per Section 13.
  3. Build the screenshots entity and export formats per Section 13.

Exit criteria:

  • A frame grab at a given timestamp returns an image whose dimensions match the source rendition and whose extraction timestamp is within one frame duration of the request, verified via ffprobe.
  • A beautification preset produces an output image with the exact padding, corner-radius, and shadow parameters specified in Section 13, verified by pixel-region and alpha-channel assertions.
  • Screenshot export respects the same plan-based resolution cap as video export from Section 21.

28.18 Milestone M16 — Integrations, public API and outbound webhooks #

Goal: Business-plan workspaces can connect Reelay to their own tools and automate on Reelay events.

Specifies this work: Section 20 (integrations), Section 7 (API conventions reused by the public API).

Depends on: M13 (Business-plan gate), M1 (API keys scoped to workspace/role).

Work items:

  1. Build api_keys: SHA-256 hashed at rest, prefix stored for display, scoped, rotatable, per-key rate limits, per Sections 6 and 7.
  2. Expose the public API surface as the internal surface scoped by key, per Section 7.
  3. Build webhook_endpoints and webhook_deliveries: signed payloads, retries, delivery log, per Section 20.
  4. Build the Slack, Notion, and HubSpot connectors (integration_connections) per Section 20.

Exit criteria:

  • Creating an API key on a Free-plan workspace is rejected at creation time with feature_not_available; the identical call on a Business-plan workspace succeeds.
  • A registered webhook endpoint receives a correctly HMAC-signed payload for a subscribed event within an asserted time bound, using a local HTTP receiver in the test; an intentionally failing receiver causes logged retries per the backoff policy and a webhook_deliveries row marked failed after the max-attempts ceiling from Section 9.
  • Revoking an API key invalidates it immediately — the next request using the old key returns 401 in the same test run.
  • Every public API response carries X-RateLimit-Limit, -Remaining, -Reset; a burst past the limit returns 429 with Retry-After.

28.19 Milestone M17 — Retention, deletion and the purge verification pass #

Goal: Data is deleted on the schedule and in the manner the product promises, and deletion is provably complete.

Specifies this work: Section 19 (retention, deletion, lifecycle).

Depends on: M3 (media assets), M5 (share links must stop resolving), M12 (folders), M13 (downgrade-driven retention states).

Work items:

  1. Build retention_policies per plan per Sections 19 and 21.
  2. Build deletion_requests (user-initiated and policy-driven), honoring the soft/hard delete rules from Sections 7 and 19.
  3. Build the purge job: hard-deletes media assets, verifies zero remaining references, per Section 19.
  4. Build the prior-notice email flow ahead of policy-driven deletion per Section 19.
  5. Build a purge verification report asserting no bytes remain in any storage bucket path for a purged video.

Exit criteria:

  • Running the purge job against a fixture video whose retention window has elapsed results in videos.deleted_at set, zero objects remaining under that video's storage prefix (verified by an automated bucket-listing check), and any existing share link returning a video_deleted-class error rather than a stale playable URL.
  • A user-initiated deletion request completes within the documented SLA window, verified by a test that submits the request and polls for completion.
  • Attempting to purge a video before the documented prior-notice period has elapsed is rejected by the job, with a test asserting the guard specifically.
  • A Business-plan (unlimited retention) fixture is never selected by the scheduled retention sweep, in a test seeding fixtures across all three plans.

28.20 Milestone M18 — Accessibility conformance pass, security hardening pass, launch readiness #

Goal: The product meets its accessibility, security, and operational bars and is ready for public commercial launch.

Specifies this work: Section 22 (security/privacy/compliance), Section 23 (accessibility), Section 24 (observability/reliability), Section 25 (testing strategy, full-suite gate), Section 26 (deployment/ environments), Section 27 (performance budgets).

Depends on: every prior milestone — this is the cross-cutting close-out pass.

Work items:

  1. Run an automated accessibility audit (axe-core or equivalent) across the dashboard, editor, watch page, and embed, per Section 23.
  2. Run a manual keyboard-only and screen-reader pass on the primary flows per Section 23 (manual — tracked, not CI-gated).
  3. Run security hardening: dependency audit, secrets scan, header/CSP review, abuse-case pass per Section 22.
  4. Stand up observability dashboards and alerts for queue depth, error rate, webhook failure rate, transcode latency, per Section 24.
  5. Run full performance-budget verification against Section 27's numeric targets, including the player budgets from Section 15.
  6. Run a production environment/config review per Section 26; rotate any dev-default secrets out.
  7. Run the full regression pass of every exit criterion listed in M0 through M17 in a production-like environment.

Exit criteria:

  • An automated axe-core scan of the four primary surfaces returns zero WCAG 2.2 AA violations, CI-gated.
  • pnpm turbo run test and the full Playwright E2E suite pass at 100% in a CI environment configured to mirror production.
  • A dependency audit reports zero known critical or high vulnerabilities with no unaddressed exceptions.
  • A load test against the ingest and collect endpoints meets the throughput/latency targets in Section 27, with results captured in a CI-attached report.
  • A fault-injection test (spiking queue depth or killing a worker) triggers the corresponding alert within the SLA window from Section 24.
  • A single scripted end-to-end test completes the full critical path — register, create workspace, record, auto-edit, share, view, comment, hit and recover from a plan cap — with zero manual steps.

28.21 Dependency graph #

graph TD
  M0[M0 Foundations] --> M1[M1 Workspaces/Roles/AuthZ]
  M1 --> M2[M2 Browser Capture/Upload/Ingest]
  M2 --> M3[M3 Media Pipeline/Watch Page]
  M3 --> M4[M4 Embeddable Player]
  M1 --> M5[M5 Share Links/Audit]
  M3 --> M5
  M3 --> M6[M6 Transcription/Captions]
  M2 --> M7[M7 Auto-Edit Engine]
  M3 --> M7
  M7 --> M8[M8 Timeline Editor/EDL/Redaction]
  M3 --> M8
  M6 --> M9[M9 AI Chapters/Summaries]
  M3 --> M9
  M4 --> M10[M10 Analytics]
  M3 --> M10
  M4 --> M11[M11 Comments/Reactions/CTAs]
  M5 --> M11
  M10 --> M11
  M1 --> M12[M12 Library/Folders/Brand Kit]
  M5 --> M12
  M1 --> M13[M13 Billing/Plans/Enforcement]
  M2 --> M13
  M3 --> M13
  M12 --> M13
  M2 --> M14[M14 Desktop App]
  M7 --> M14
  M3 --> M15[M15 Screenshots/Beautification]
  M7 --> M15
  M13 --> M16[M16 Integrations/Public API]
  M1 --> M16
  M3 --> M17[M17 Retention/Deletion/Purge]
  M5 --> M17
  M12 --> M17
  M13 --> M17
  M0 --> M18[M18 Accessibility/Security/Launch]
  M1 --> M18
  M2 --> M18
  M4 --> M18
  M6 --> M18
  M8 --> M18
  M9 --> M18
  M10 --> M18
  M11 --> M18
  M14 --> M18
  M15 --> M18
  M16 --> M18
  M17 --> M18

28.22 Critical path #

The longest chain of hard dependencies is:

M0 → M1 → M2 → M3 → M5 → M12 → M13 → M17 → M18 (eight sequential dependency edges).

This chain determines the minimum wall-clock length of the build: none of these nine milestones can start before its predecessor in the chain is done. Every other milestone (M4, M6, M7, M8, M9, M10, M11, M14, M15, M16) has slack — it can be built in parallel with some portion of the critical path once its own, shorter, prerequisite chain is satisfied. A team optimizing for the shortest calendar time should staff the critical-path milestones first and use any excess capacity on the parallel branches described in Section 28.23.

28.23 Parallelisation guide #

Milestone Earliest start (after) Can run concurrently with
M0 none (blocks everything)
M1 M0 none
M2 M1 none
M3 M2 none
M4 M3 M5, M6, M7
M5 M1, M3 M4, M6, M7
M6 M3 M4, M5, M7
M7 M2, M3 M4, M5, M6
M8 M3, M7 M9, M10, M12, M14, M15
M9 M3, M6 M8, M10, M12, M14, M15
M10 M3, M4 M8, M9, M12, M14, M15
M11 M4, M5, M10 M8, M9, M12, M14, M15
M12 M1, M5 M8, M9, M10, M11, M14, M15
M13 M1, M2, M3, M12 M14, M15 (already started)
M14 M2, M7 M8, M9, M10, M11, M12, M15
M15 M3, M7 M8, M9, M10, M11, M12, M14
M16 M1, M13 M17
M17 M3, M5, M12, M13 M16
M18 all others none (close-out pass)

A team of three or four workstreams can, after M3 lands, run one stream on M4→M10→M11, one on M5→M12→ M13, one on M6→M9, and one on M7→M8, converging on M14/M15/M16/M17 as capacity frees up, then closing together on M18.

28.24 Risk register #

# Risk Impact Likelihood Mitigation Owning milestone
1 Managed transcode/ABR vendor (Mux) outage or breaking API change High Low Vendor-swappable interface (Section 9); integration tests target the interface, not the vendor SDK directly M3
2 Browser codec/telemetry fragmentation causes capture failures on a subset of browsers Medium Medium Explicit fallback matrix and degraded-mode statement (Section 8); CI test matrix across target browsers M2
3 Auto-edit renders are non-deterministic in practice, causing support burden and eroding trust in "byte-identical" claims High Medium Determinism test harness is a hard exit criterion of M7, run on every PR touching the render path M7
4 Plan-cap enforcement is bypassed via a client-only check, causing revenue leakage or, worse, breaking a shared link on downgrade High Medium Single server-side enforcement module; the iron-rule regression test is a hard exit criterion of M13 M13
5 Redaction burn-in fails silently, exposing sensitive content through a share link High Low Pixel-region automated tests plus the pending-redaction share guard, both hard exit criteria of M8 M8
6 Retention/deletion does not fully purge data, creating GDPR/compliance exposure High Low Automated bucket-listing purge verification is a hard exit criterion of M17 M17
7 Embed player exceeds its byte or performance budget as features accrete after launch Medium High CI size-limit and Lighthouse gates block any merge that regresses the budget, not just the initial build M4, M18
8 Desktop native modules break across OS updates (macOS/Windows) Medium Medium Native code isolated behind a narrow interface; CI matrix runs on both target OS versions M14
9 Outbound webhook delivery to third parties is unreliable, damaging integration trust Medium Medium Retry/backoff with a dead-letter queue and a customer-visible delivery log M16
10 Scope creep delays the first commercial release High High Explicit required-for-v1 versus can-follow split (Section 28.25); milestones after the split are deliberately deferred all

28.25 Definition of done for every milestone #

A milestone is not done until all of the following hold, in addition to its own exit criteria:

  • Every work item is implemented per the sections it references, with no deviation from the locked stack, roles, plan limits, identifiers, or API envelope.
  • Every exit criterion passes in CI, not only on a developer's machine.
  • Unit and integration tests exist for every new code path; test coverage does not regress.
  • No TODO, FIXME, or TBD remains in code for work that was in scope for this milestone.
  • Any new or changed API surface is reflected in packages/shared's Zod schemas, consumed by both client and server, so client and server cannot silently drift.
  • Every migration is either reversible or explicitly documented as one-way with a stated reason.
  • A feature that ships incomplete across a milestone boundary is gated behind a feature_flags row (Section 5) rather than left half-built and reachable.
  • Every new asynchronous job emits structured logs and metrics per Section 24.
  • Work lands via a pull request merged to main with CI green; there are no direct pushes to main.
  • The milestone's exit-criteria commands are added to CI so they run on every future pull request — a milestone's guarantees are regression-proofed, not verified once and forgotten.

28.26 Required for a first usable release versus can follow #

Milestone Required for v1 Why
M0 Yes Nothing else can be built without it.
M1 Yes No safe multi-tenant product exists without enforced RBAC.
M2 Yes The core capture path — without it there is nothing to share.
M3 Yes The core playback path.
M4 Yes The embed is the product's primary distribution and growth loop.
M5 Yes Sharing is the core use case; a demo tool that cannot be shared is not the product.
M6 Yes (minimum: captions) Captions are required for the WCAG 2.2 AA conformance gate in M18; accessibility is not optional at launch.
M7 Yes The stated differentiator (Section 10). Shipping without it is a materially different, lesser product.
M8 Can follow Auto-edit output (M7) is usable on its own; manual timeline refinement is a valuable fast-follow, not a launch blocker.
M9 Can follow Metadata enhancement on top of an already-watchable, already-shareable video.
M10 Partial Basic event collection (Section 16 ingestion) should ship with v1 for product telemetry; full dashboards can follow.
M11 Can follow Engagement features increase value after launch but are not required to record, share, or watch.
M12 Partial Basic folders should ship with v1 for usability at small scale; brand kit and custom domains (Business-only) can follow.
M13 Yes (minimum) Free-plan limits and the watermark must be enforced before any public launch; full Stripe billing can follow only if the initial launch is invite-only or free — any paid launch requires the full milestone.
M14 Can follow Browser capture (M2) is sufficient for v1; the desktop app is a fidelity upgrade, not a new capability.
M15 Can follow Adjacent capability, not required for the core record-edit-share-watch loop.
M16 Can follow Business-plan only; no Free/Pro launch is blocked by its absence.
M17 Partial A manual, self-service "delete my data" path must exist at launch for legal compliance; the fully automated scheduled sweep can follow, since no retention window elapses before that fast-follow ships.
M18 Yes Launch readiness is definitionally required before calling anything a release.

A team under time pressure should sequence: M0 → M1 → M2 → M3 → M4 → M5 → M7 → M6 (captions only) → M13 (minimum enforcement) → M17 (minimum, self-service deletion) → M18, then treat M8, M9, M10 (full), M11, M12 (full), M13 (full billing), M14, M15, M16, M17 (full automation) as the fast-follow backlog in priority order.


29. Executor Instructions #

29.1 How to read this document #

Every top-level heading is ## <N>. <Title>, numbered exactly as listed in this section's index (29.8). Subsections are ### <N>.<M>, and finer detail is #### <N>.<M>.<K>. Every cross-reference in this document is by section number — "per Section 10", "the envelope in Section 7.6" — never by restating or paraphrasing content that another section owns.

The canonical-source rule: each concern is specified in exactly one section. Every other section that touches that concern references it by number instead of redefining it. If you find two sections that appear to describe the same concern differently, that is a defect in this document, not a genuine choice — resolve it by treating the owning section as authoritative and, if the referencing section's text is actually inconsistent with the owner, fix the referencing section's prose to match the owner before writing any code against it. Record that fix as a one-line entry in your project's own DECISIONS.md (Section 29.6) so the correction is traceable.

The table below states which section owns which concern, down to the specific subsection where two sections could otherwise plausibly both claim it, so a disagreement can be resolved in seconds:

Concern Owning section
Technology stack, version lines, monorepo architecture 3
Naming conventions (database, TypeScript, API, files, env var naming style, queue job naming style) 4
Data model — every CREATE TABLE, every column list, every CHECK constraint, identifiers, ID prefixes; no section other than this one may define a table 5
Roles, permissions, authentication 6
The single authorization enforcement point — the Actor shape (including the share_viewer variant), the Action vocabulary, authorize(), and its throw-on-deny contract 6.9
API envelope (success and error shapes), pagination, idempotency, rate limits 7
The error code catalogue — every stable code string used anywhere in this document 7.6
Capture matrix (browser and desktop), codec fallback order 8
Media pipeline, local-first buffering, resumable upload 9
Queue and job names — every BullMQ queue name and every job.id value 9.3.1, 9.8
AI auto-editing engine — all numeric constants 10
Timeline editor, Edit Decision List schema, non-destructive editing invariant 11
Redaction — editor authoring behaviour and the render contract, plus the security property; never Section 10, which is camera-motion physics only 11.7, 22.2
Transcription, captions, chapters, AI metadata, human-acceptance flow 12
Screenshot capture and beautification 13
Share link security, access control, audit trail 14
The embeddable player 15
The player's byte and performance budgets 15.2
Viewer analytics model 16
Comments, reactions, CTAs, email capture 17
Library, folders, brand kit, custom domains 18
Retention, deletion, data lifecycle, the "cap never breaks a shared link" rule 19, 21
Integrations, public API, outbound webhooks 20
Plans, limits, billing, server-side usage enforcement 21
Security, privacy, compliance 22
The restricted-media two-bucket storage model — which physical bucket every media_assets.kind value lives in 22.2.2
Accessibility conformance 23
Observability, reliability, operations 24
Testing strategy and quality gates 25
Deployment, environments, configuration 26
The environment-variable catalogue — every variable, its purpose, and every *_PROVIDER vendor selector 26.2
Performance budgets and scale targets 27
Milestone sequencing and exit criteria 28
Executor working practices 29

29.2 Build order and how it maps to Section 28 #

Build in the milestone order M0 through M18 as sequenced in Section 28, respecting the dependency graph in Section 28.21 and using the parallelisation guide in Section 28.23 wherever more than one workstream is available. Do not start a milestone's work items before its listed dependencies have passed their exit criteria in CI — the exit criteria exist precisely so this is checkable, not a judgment call. Section 28.26 tells you which milestones are required before a first release and which may be deferred; default to that split unless the team you are building for has explicitly stated otherwise.

29.3 Non-negotiable invariants #

These invariants apply across the whole system regardless of which milestone is in progress. Every one of them must have a passing automated test before the milestone that introduces it is considered done, and that test must remain in CI permanently — these are not one-time checks.

Invariant Owning section(s)
A recording is never lost because the network failed. 9.2
The original recording is immutable; all edits are expressed as an Edit Decision List. 11.2
The product never alters what a speaker said — trimming is editing, changing words is fabrication and is never done. 11.6
Redaction is burned in server-side; a video with a pending redaction cannot be shared or played. 11.7, 22.2
Every AI-suggested artefact requires explicit human acceptance before it becomes public metadata. 12.9
Private folders are default-deny: absent an override row in the folder chain, a private folder is inaccessible to everyone except its owner and workspace admins. 6.8.1, 18.2.1
Authorization has exactly one enforcement point, authorize(), and it throws ForbiddenError on denial rather than returning a value a caller could silently ignore; anonymous share-link visitors are share_viewer actors evaluated against their own allow-list and are never evaluated against the workspace viewer role. 6.9
Every plan cap is enforced server-side; client-side checks are UX only. 21.5
Hitting a plan cap never breaks an already-shared or already-embedded link. 19.8, 21.6
Workspace membership is never implicit (no domain matching); it is always an explicit workspace_members row. 6
The embeddable player sets no cookies on the host page for anonymous viewing and stays within its byte and performance budgets. 15.2
All queue job handlers and vendor webhook handlers are idempotent, keyed on a stable job or delivery id. 7, 9.3.1

29.4 Decisions the executor may change safely versus load-bearing decisions #

Safe to change without consulting any other section: internal module layout within a package; specific UI component choices inside packages/ui (as long as the accessibility and player-isolation rules in Sections 23 and 15 are met); CI provider and exact CI job wiring, as long as the gates in Section 25 and the exit criteria in Section 28 still run and still block merges; internal, non-public queue names, as long as the job.id convention from Section 4 is preserved; the order of independent work items within one milestone; the specific asset list behind a background preset (beyond the documented spec fields in Section 10); email template copy; the structured-logging library; hosting provider and region, as long as data-residency and compliance commitments in Section 22 are met; internal (non-API) error message text — never the stable code string from Section 7.6.

Load-bearing — do not change without re-reading and updating every section that depends on it: the four role names and their exact permission boundaries (Section 6 — depended on by 1, 5, 7, 9, 11, 14, 17, 18, 21); every value in the plan limits table (Section 21 — depended on by 9, 13, 17, 19); the API success and error envelope shapes (Section 7 — depended on by every client and every milestone's exit criteria that assert on response JSON); cursor-based pagination (Section 7); ID formats and prefixes (Section 5 — depended on by every entity reference in the API); the auto-edit numeric constants — zoom levels, hold/gap/coalescing timings, spring parameters (Section 10 — several M7 exit criteria assert these exact numbers); the player's byte budgets (Section 15 — CI-gated in M4 and M18); the rule that a plan cap never breaks a shared link (Sections 19 and 21 — the single most consequential invariant in the product, tested explicitly in M13); the redaction-burn-in requirement (Sections 11, 14, 22); the soft-versus-hard delete matrix (Section 7's data conventions and Section 19); the render-determinism requirement for auto-edit (Section 10); the restricted-media two-bucket storage split — two physically separate buckets, never prefixes within one bucket (Section 22.2.2 — depended on by 9, 11, 22, and every M8/M17 exit criterion that inspects bucket contents); the single, throwing authorization enforcement point and its Actor/Action contract, including the share_viewer actor type (Section 6.9 — depended on by every other section that performs an authorization check); default-deny folder permissions (Sections 6.8.1, 18.2.1); poster and thumbnail URL versioning keyed to the share link's playback_key_version, so a revoked or downgraded link invalidates its poster (Section 14.1.1); and the loader-script requirement for domain-restricted embeds, including the stated iframe-fallback limitation where domain restriction is not enforceable and the product says so (Sections 14, 15).

29.5 Definition of done for the whole project #

  • Every milestone M0 through M18's exit criteria (Section 28) pass in CI on main.
  • The full unit, integration, and E2E suites (pnpm turbo run test, Playwright) pass at 100% in an environment configured to mirror production.
  • The automated accessibility scan (Section 28.20) reports zero WCAG 2.2 AA violations.
  • The dependency and secrets audits (Section 22) report zero unaddressed critical or high findings.
  • The player's byte and performance budgets (Section 15) are met and CI-enforced against regression.
  • Every non-negotiable invariant in Section 29.3 has a passing, permanent regression test.
  • Observability dashboards and alerts (Section 24) are live and have been proven to fire in a fault-injection drill.
  • A production deploy runbook (Section 26) exists and has been executed at least once end to end against a staging environment that mirrors production.
  • The project repository contains a README.md describing local setup and a DECISIONS.md recording every ambiguity resolved during the build (Section 29.6).
  • Zero open defects classified as blocking the invariants in Section 29.3 or the exit criteria of any required-for-v1 milestone (Section 28.26).

29.6 Working practices #

Handling ambiguity: this document is written to be executed without asking clarifying questions. When you encounter a genuine ambiguity — something this document does not answer and that is not settled by an owning section — decide using the closest applicable convention already established elsewhere in this document (naming, envelope shape, error handling pattern, numeric-constant style), write one entry to a DECISIONS.md file at the repository root (date, the decision, the reasoning, and which section of this document it touches or extends), and continue building. Never stop and wait for an answer that will not come.

Commit and PR discipline: scope each pull request to one work item or one closely related exit criterion; reference the milestone and section numbers touched in the PR description; require CI green before merge; never push directly to main; keep history section-traceable so a reviewer can find the part of this document that justifies any given diff.

When to write a test first: write the test before or alongside the implementation for anything that touches a security boundary (authorization, redaction, plan-cap enforcement, share-link visibility), a numeric constant from Section 10, a state machine transition (billing downgrade, redaction status, custom-domain verification), or a webhook/idempotency handler — these are exactly the load-bearing items in Section 29.4, and Section 28's exit criteria already specify most of these tests precisely enough to write from. Pure layout or copy work may be tested after the fact.

29.7 First-week plan #

The first ten tasks, in order, get a fresh checkout to a green M0 and into M1:

  1. Initialize the pnpm-workspaces + Turborepo monorepo skeleton with all eight packages building empty (Section 3, Section 4).
  2. Add environment-variable schema validation and a docker-compose.yml for PostgreSQL 17 and Redis 8 (Section 26, Section 3).
  3. Write the Drizzle schema for users, sessions, oauth_accounts, mfa_credentials, workspaces, workspace_members, and run the first migration (Section 5).
  4. Implement email + password registration and login with Argon2id and session cookie issuance (Section 6).
  5. Implement Google OAuth 2.0 PKCE and the email-verification gate (Section 6).
  6. Stand up the CI pipeline: lint, typecheck, unit test, build, on every pull request (Section 25, Section 26).
  7. Implement the single authorization enforcement point and its role-matrix contract tests (Section 6, Section 7) — this is the start of M1.
  8. Implement workspace CRUD and the invite/accept/revoke flow (Section 6).
  9. Add M0's and M1's exit-criteria checks to CI so every subsequent pull request is gated by them (Section 28.2, Section 28.3).
  10. Create the repository's DECISIONS.md and record the first entries for any default chosen while completing tasks 1 through 9.

29.8 Quick-reference index #

Concern Section
Customization decisions before starting 1
Product vision, scope, and what is explicitly deferred (SSO/SCIM) 2
Stack, version lines, monorepo architecture 3
Naming and code organization conventions 4
Database schema and every entity 5
Auth, workspaces, roles, permissions 6
REST API, public API, webhooks, error/success envelopes 7
Browser and desktop capture 8
Upload, transcode, storage, delivery pipeline 9
The AI auto-editing engine 10
Timeline editor and the Edit Decision List 11
Transcription, captions, chapters, AI metadata 12
Screenshots and beautification 13
Sharing, link security, access control 14
The embeddable player 15
Viewer analytics 16
Comments, reactions, CTAs, email capture 17
Library, folders, collections, brand kit 18
Retention, deletion, data lifecycle 19
Integrations and outbound webhooks 20
Billing, plans, usage enforcement 21
Security, privacy, compliance 22
Accessibility 23
Observability, reliability, operations 24
Testing strategy 25
Deployment, environments, configuration 26
Performance budgets and scale targets 27
Milestones and execution plan 28
Executor instructions 29

29.9 What "finished" looks like for a first release #

A first release is finished when every milestone marked "Required for v1" in Section 28.26 has passed its exit criteria in CI on main, every checklist item in Section 29.5 that applies to those milestones is checked, every non-negotiable invariant in Section 29.3 has a permanent passing regression test, and a single scripted end-to-end run — register, create a workspace, record a video in the browser, watch the auto-edit engine produce a polished result with no manual editing, share it with a password and an expiry, embed it on a third-party page within the player's performance budget, view it as an anonymous viewer, leave a comment, and hit and safely recover from a Free-plan cap without breaking the link just created — completes with zero manual intervention. Everything else in this document is real, specified, and ready to build, but it ships in the fast-follow that begins the day after this bar is met.


Generated at GenerateSpecs.com — one idea in, one buildable spec out.

Licensed under CC BY 4.0. Use it for anything — just credit GenerateSpecs.com.