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 #
- Before You Start — Customization Decisions
- Product Overview, Vision & Scope Boundaries
- Technology Stack & System Architecture
- Conventions, Standards & Code Organization
- Data Model & Database Schema
- Identity, Workspaces, Roles & Permissions
- API Design — REST, Public API & Webhooks
- Capture — Browser & Desktop Recording
- Media Pipeline — Upload, Transcode, Storage & Delivery
- AI Auto-Editing Engine — Zoom, Motion & Backgrounds
- Timeline Editor & Edit Decision List
- Transcription, Captions, Chapters & AI Metadata
- Screenshot Capture & Beautification
- Sharing, Link Security & Access Control
- The Embeddable Player
- Viewer Analytics
- Engagement — Comments, Reactions, CTAs & Email Capture
- Video Library, Folders, Collections & Brand Kit
- Retention, Deletion & Data Lifecycle
- Integrations — Slack, Notion, HubSpot & Webhooks
- Billing, Plans & Usage Enforcement
- Security, Privacy & Compliance
- Accessibility — WCAG 2.2 AA
- Observability, Reliability & Operations
- Testing Strategy & Quality Assurance
- Deployment, Environments & Configuration
- Performance Budgets & Scale Targets
- Milestones & Execution Plan
- 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 touchingshare_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.mdin 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 #
- 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).
- 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.
- 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.
- 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.
- 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.
- Viewer watches: recipient opens the watch page or embedded player. No account or app install
required for
link/publicvisibility. Viewing generates analytics events (Section 16.2) from first frame. - 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.
- 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/ownersee workspace- wide analytics and audit log (Section 6.8).
2.7 In scope — the 15 core features #
- Browser-based screen + webcam + microphone recording (Section 8.1–8.2)
- Desktop capture app for OS-audio, high-fps, multi-display recording (Section 8.4)
- Local-first capture with resumable, network-loss-proof upload (Section 9.2)
- Automatic zoom-to-cursor, smooth camera motion, and background compositing (Section 10)
- Non-destructive timeline editor with a re-renderable Edit Decision List (Section 11)
- Filler-word and silence detection and removal (Section 12.2, Pro+)
- Automatic transcription, closed captions, and chapter/summary/title generation (Section 12)
- Server-side burned-in redaction (blur/box, static or tracked) (Sections 11.7 and 22.2, 14.8)
- Screenshot capture and beautification (background, shadow, device frame) (Section 13)
- Share links with visibility, password, expiry, domain restriction, and audit log (Section 14)
- An embeddable, performance-budgeted player for web and email (Section 15)
- Per-viewer and aggregate watch analytics with drop-off curves (Section 16)
- Engagement: comments, reactions, CTAs, and email-gated viewing (Section 17)
- Team libraries: folders, collections, brand kit, workspace roles and permissions (Sections 6, 18)
- 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.jsonPackage 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 window —
video_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-frontedPer-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 | Build — packages/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 aValidationErrorper Section 4.4) on every request body/query/params object — never accept an unvalidatedany. - 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.inferbecomes 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/webneeds 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 tofeat,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 (activeis ambiguous between a boolean and a status enum). - Enum-like columns are
snake_casestring enums with a fixed, documented value set (never a raw boolean pair standing in for a 3+-state concept), e.g.videos.statusis'processing' | 'ready' | 'failed', not two booleansis_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:
- Time-sortable: UUIDv7 embeds a 48-bit millisecond Unix timestamp in the high bits, so
primary keys sort chronologically. B-tree indexes on
idstay 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. - 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.
- 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_assetsandrenditionsrows, 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; amedia_assetsrow 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 jobsemail 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" lookupCREATE 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 expirysessions 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 joinowner_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 failsThe 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, orderedcreated_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 kindkind 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_operationstable would only reconstruct via anORDER BY sequencequery 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 migrationEvery 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:
- 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. - 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. - 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.
- 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 historyEnforcing 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.10video_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:
- 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.
- 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.
- 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:
- Validate shape via the shared Zod schema (Section 7).
- Normalize email (lowercase handled transparently by
citext; trim whitespace). - Check breach list (6.1.2); reject
password_breach_detectedif hit. - Check for an existing
usersrow 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). - If an account already exists, respond with the SAME
202 Acceptedshape 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. - Otherwise, hash the password, create the
usersrow (email_verified_at = NULL), send a verification email with a single-use time-limited token (6.4.1), and respond202 Acceptedwith the same generic body.
6.1.4 Login flow #
POST /v1/auth/login — { email, password }.
- Look up
usersby email. If not found, ordeleted_at IS NOT NULL, or the password does not match: respond401with error codeinvalid_credentials— the same code and message in every one of these cases, so an attacker cannot distinguish "no such account" from "wrong password" from "deleted account." - If
locked_untilis in the future: respond401 invalid_credentialsas 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). - On password mismatch: increment
failed_login_count. At 10 consecutive failures within a rolling 15-minute window, setlocked_until = now() + 15 minutesand reset the counter. - On success: reset
failed_login_countto 0, clearlocked_until, updatelast_login_at, and:- If
mfa_credentialsexists for this user, respond200with a short-lived (5-minute)mfaChallengeTokenand no session yet — the client must complete 6.3.3 before a session is issued. - Otherwise, create a
sessionsrow (6.3) and respond200with the session cookie set.
- If
- Rate limiting:
POST /v1/auth/loginis 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 }.
- Look up the Redis key; if missing or expired, respond
400 invalid_or_expired_token. - Validate
newPasswordagainst 6.1.2 rules including breach check. - 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). - 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. - 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 #
GET /v1/auth/google/start— server generates a PKCEcode_verifier(43-128 char random string) andcode_challenge(BASE64URL(SHA256(code_verifier))), and a randomstatevalue. Bothcode_verifierandstateare 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 opaqueoauth_flow_id. The server redirects to Google's authorization endpoint withcode_challenge,code_challenge_method=S256,state,scope=openid email profile, and the registeredredirect_uri.- Google redirects back to
GET /v1/auth/google/callback?code=...&state=.... - The server reads
oauth_flow_idfrom the cookie, looks up the storedstate/code_verifierin Redis, and rejects the callback outright (400 invalid_oauth_state) if: the cookie is missing, the Redis key has expired, or the returnedstatedoes 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 exactcode_verifierthis specific server-side flow generated. - The server exchanges
code+code_verifierfor tokens at Google's token endpoint, validates the returned ID token's signature, issuer, audience, and expiry, and extractssub,email,email_verified,name,picture. - The Redis key and
oauth_flow_idcookie 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 #
6.3.1 Cookie and token #
sessions.token_hashstoresSHA-256(opaque_token); the opaque token itself (256 bits ofcrypto.randomBytes, base64url-encoded) is set in a cookie namedreelay_session.- Cookie attributes:
httpOnly; Secure; SameSite=Lax; Path=/; Max-Age=2592000(30 days).SameSite=Lax(notStrict) because top-level navigation into a shared video link or an OAuth-callback redirect must carry the cookie for the dashboard's own domain;Strictwould 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_atand, if more than 24 hours have elapsed sinceexpires_atwas last extended, re-issuesexpires_at = now() + 30 days(both server-side row and cookieMax-Agerefreshed). 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 |
blocked — 403 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/enrollgenerates a random 160-bit TOTP secret, returns it as both aotpauth://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 tomfa_credentials) until confirmed:POST /v1/auth/mfa/enroll/confirmwith 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 therecovery_codes_encJSON array, encrypted at rest per Section 22) tomfa_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 returns403 step_up_requiredwith astepUpChallenge: trueflag 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:
- Caller must be the current
owner(only the owner can initiate transfer — an admin cannot transfer ownership to themselves or anyone else). - Caller must pass MFA step-up (6.5) if MFA is enrolled.
toMemberIdmust reference an existing, acceptedworkspace_membersrow in this workspace withrole IN ('admin', 'member')— aviewercannot receive ownership directly (they must first be promoted to at leastmember, a separate, ordinary role-change action).- Executed as a single
SERIALIZABLEtransaction (5.4.2 explains why):UPDATE workspace_members SET role = 'admin' WHERE workspace_id = $1 AND role = 'owner'followed byUPDATE workspace_members SET role = 'owner' WHERE id = $toMemberId. If either statement would violateworkspace_members_one_owner_unique, the whole transaction rolls back and the endpoint returns409 ownership_transfer_conflict. - An
audit_eventsrow (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
pendinginvite 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
usersaccount: accepting the invite (after login, or as part of a login-then-accept redirect chain if not currently authenticated) creates theworkspace_membersrow with the preselectedroleand setsstatus = '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_atat accept-time) yields410 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:
- The
workspace_membersrow is hard-deleted (membership is not in the soft-delete list). - Every
videosrow whereowner_idwas the removed user, within this workspace, hasowner_idreassigned to the workspace's currentowner. 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. - Every
share_linksrowcreated_bythe 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). - Every
folder_permissionsrow 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, becausefolder_permissions.principal_user_idreferencesusers.iddirectly (a grant is a statement about a person, addressable across that person's whole account lifetime), andusers.idsurvives 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. - An
audit_eventsrow records the removal, actor, and the video-reassignment count. - The removed user retains their
usersaccount 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_byatauthorize()-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:
membersees analytics scoped tovideos WHERE owner_id = actor.userId;owner/adminsee workspace-wide analytics with noowner_idfilter. This is enforced in the analytics query layer (Section 16) by the sameauthorize()call, not by a separate ad hoc check — see 6.9. viewernever appears in aworkspace_membersrow 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 aviewer, 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:
- If the requesting user's workspace role is
owneroradmin: always allow, atmanagelevel, regardless of the folder'svisibilityor anyfolder_permissionsrow. Owner/admin have workspace-wide "all videos" access as a role capability (permission matrix, 6.8); folder grants exist to scope downmember/vieweraccess 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. - Else if the folder's
visibility = 'workspace': allow atviewlevel (or higher, if afolder_permissionsrow grants more) — a workspace-visible folder is readable by every member by design, that is whatvisibility = 'workspace'means. - Else if an explicit
folder_permissionsgrant applies to this user — either auser-typed grant naming them directly, or arole-typed grant naming the role they currently hold — allow at the grantedpermission_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). - 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.
visibilityis never inherited;folder_permissionsgrants are. These are two separate mechanisms and conflating them is a security bug. A folder'svisibilitycolumn isNOT 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: forfolder_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 notowner/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.- 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
viewerwith amanagegrant on a folder still cannot record new videos into it — recording is a role capability theviewerrole does not have, full stop, and no folder-level permission level is capable of expressing "grant the recording capability." What a folder grant on aviewerDOES 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. - A
memberalways 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 thememberrole'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
usersrow and either finds an existingworkspace_membersrow (login) or funnels into the exact sameworkspace_invitesacceptance 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
/v1surface. 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 withinv1without 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: trueand ax-sunset-dateextension 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: trueSunset: <HTTP-date>— RFC 9110 format, e.g.Sunset: Wed, 15 Apr 2026 00:00:00 GMTLink: <https://developers.reelay.app/changelog/2026-04-endpoint-name>; rel="deprecation"After the sunset date, the endpoint returns410 Gonewith error codeendpoint_sunsetfor 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.
v2is not scoped in this document. If a future breaking change is required, it is introduced as a new path prefix (/v2/...) that coexists with/v1for 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
httpOnlysession cookie described in Section 6; it is never persisted client-side beyond memory and carriessub(user id),wsId(active workspace id),role, and a 15-minuteexp. The web app silently exchanges it for a fresh JWT using the session cookie on a 401 with error codetoken_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
Authorizationheader, an unparseable header, or a credential that fails verification returns401 Unauthorizedwith error codeunauthenticated(Section 7.6). A request with a valid credential but insufficient role or scope returns403 Forbiddenwith error codeforbidden— the distinction between 401 and 403 is never blurred. - Every authenticated request additionally resolves a workspace context. Web/session requests carry
wsIdin the JWT; if the caller needs to act on a different workspace than their active one, they passX-Workspace-Id: <ws_id>. On every such switch, the server re-resolves the actor's role fresh fromworkspace_membersfor the target workspace — it never carries over theroleclaim 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 noworkspace_membersrow for the requestedX-Workspace-Idgets403 Forbidden/workspace_mismatch; a caller who IS a member, but holds a different role there than in their home workspace (e.g.adminin workspace A,viewerin 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 forauthorize()(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-Idis rejected with403 Forbidden/workspace_mismatchif 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), ornullfor actions with no return payload other than confirmation (rare; most actions return the affected resource).meta—nullunless the endpoint carries pagination (Section 7.4), a rate-limit summary, or an endpoint-specific side channel (e.g. a count). When present,metais always a flat JSON object, never an array.- Resource identifiers in
dataalways 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 trailingZ, stored on keys suffixedAt(createdAt,publishedAt). All durations are integer milliseconds on keys suffixedMs(durationMs). All byte sizes are integer bytes on keys suffixedBytes(sizeBytes). No field is ever a float representing seconds or a human-formatted duration string. 204 No Contentis used only for actions that genuinely return nothing and cannot fail partially (e.g.DELETEof 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, default25, maximum100. A value above 100 is clamped to 100, not rejected (keeps naive integrators working); a value below 1 returns400/validation_error.cursor— opaque, omitted on the first page.
- Response
metaon a list endpoint:
{
"data": [ { "...": "..." }, { "...": "..." } ],
"meta": { "nextCursor": "eyJvIjoiMjAyNi0wOC0xOVQxMjowMDowMFoiLCJpZCI6InZpZF8yaDlLIn0", "hasMore": true }
}nextCursor—nullwhenhasMoreisfalse. Otherwise an opaque, base64url-encoded string.hasMore— boolean, always present.- Cursor encoding: the cursor is
base64url(JSON.stringify({ o: <orderValue>, id: <publicId> }))whereois 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) andidis that row's public ID, used as a tiebreaker for rows with identicalovalues. The server decodes the cursor, validates its shape with the shared Zod schemaCursorSchema, and translates it into a keyset predicate (WHERE (created_at, id) < (:o, :id)or the ascending equivalent), never an SQLOFFSET. 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 guaranteeOFFSETcannot make. - A cursor is opaque and versioned: the encoded JSON includes a
v: 1field. A cursor from a future API version, a tampered cursor, or a cursor that fails to decode returns400/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}/commentsdefaults ascending by timeline position).
7.5 Filtering, Sorting & Search #
- 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 with400/validation_error(not silently ignored) so integrators discover typos immediately rather than getting unfiltered results. - Date-range filters: fields ending in
After/Beforeaccept ISO 8601 timestamps, e.g.?createdAfter=2026-01-01T00:00:00Z&createdBefore=2026-06-01T00:00:00Z. Ranges are inclusive ofAfterand exclusive ofBefore. - Sorting:
?sort=<field>for ascending,?sort=-<field>for descending (leading hyphen). Only fields explicitly documented as sortable for that endpoint are accepted; others return400/validation_error. Multi-key sort is not supported in v1 — every documented sortable field is itself a stable, unique-enough ordering when combined with theidtiebreaker from Section 7.4. - Search: endpoints that support free-text search expose
?q=<string>, 1–200 characters, matched against a Postgrestsvectorgenerated column (GINindex) covering the fields documented per endpoint (typically title + transcript text for videos). Search is combinable with filters and sort; whenqis present and nosortis given, results default to relevance rank rather thancreatedAt.
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-readablesnake_casestring. 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 (nevernull, so callers can always safely.forEach). Populated forvalidation_error(one entry per failing field,fieldas a dot-path likerecipients[0].email,issuea stable sub-code such asrequired,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 theX-Request-Idresponse 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;
codecarries the specific cause. Clients should branch oncode, 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
POSTthat 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,DELETEdo not use this mechanism —PATCH/DELETEare naturally idempotent by resource ID, andGEThas 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_keystable schema — including the(workspace_id, key)composite unique index and thestatus(in_progress|completed|failed) enum referenced throughout this subsection — is owned by Section 5.4.x. A scheduled job purges rows past theirexpires_at(created_at + 24h) hourly. - Replay semantics:
- First request with a given key: server inserts a row with
status = in_progressinside the same transaction as the resource creation begins, computesrequest_hash(a hash of method + path + body), proceeds normally. - A second request with the same key and an identical
request_hashwhile the first is stillin_progressreturns409/idempotency_in_progress— the client should back off and retry after a short delay, not assume failure. - A second request with the same key and identical
request_hashafter 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. - A second request with the same key but a different
request_hashreturns409/idempotency_key_reused— reusing a key for a different logical request is a client bug the API refuses to guess around. - If the original request failed with a
4xx/5xxthat 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 transient5xx/502/503is NOT cached, so a retry with the same key after a server-side failure is allowed to actually re-attempt the operation.
- First request with a given key: server inserts a row with
- 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 (correlationIdfield), 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 atlimit / windowSecondstokens/sec, capped atlimittokens (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).subjectdepends on the class: the workspace ID for session-authenticatedread/write/upload/analyticsrequests, the API key ID for public-API requests, the signed playback token forcollectrequests (Section 7.11.19), and — for theauthclass specifically — two independent buckets keyed by caller IP and by the request'semailfield, 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/minblended acrossread+write, burst capacity60. A workspace with multiple keys has independent buckets per key. Support can raise a specific key's limit; the raised value is stored onapi_keys.rate_limit_overrideand 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_limitedand an additionalRetry-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 honorRetry-Afterwith 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-limitplugin reading the bucket config above frompackages/shared, so limits are enforced identically regardless of whichapps/apiroute 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-Signaturefor the streaming vendor, in the formt=<unix_ts>,v1=<hex_hmac>; the transcription vendor's equivalent header follows the samet=,v1=convention configured at integration setup). The receiver:- Extracts
tandv1from the header. - 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). - Compares the computed digest to
v1using a constant-time comparison (crypto.timingSafeEqual). Mismatch →401/webhook_signature_invalid, logged, not retried by us (the vendor's own retry policy governs redelivery).
- Extracts
- Timestamp tolerance:
tmust 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}) usingSETNX. If the key already exists, the handler returns200 OKimmediately without reprocessing (vendors treat any 2xx as delivered; returning an error would trigger unnecessary vendor retries) and logs aduplicate_deliverymetric. 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
renditionsrow 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 usesjob.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-Signatureheader in the same shape as the two vendors above (t=<unix_ts>,v1=<hex_hmac>), but verification runs through the official Stripe Node SDK'sstripe.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). AStripeSignatureVerificationErrorthrown by the SDK is mapped to the same401/webhook_signature_invalidcode 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
SETNXpattern as 7.10.1, keyedwebhook:seen:stripe:{event.id}using Stripe's ownevt_...event ID namespace (distinct from Reelay's internalevt_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/invoicesrows 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_erroronly (malformed email shape, password under 10 chars, ordisplayNameout of range). There is noemail_already_registeredcode and no other code that reveals account existence. - Side effects when the email is new: creates a
usersrow (display_name,emailVerifiedAt = null), sends a verification email (email_logrow). Noworkspacesorworkspace_membersrow is created at signup time — that happens on the caller's first authenticatedPOST /v1/workspacescall, after login. - Side effects when the email is already registered: none visible to the caller beyond the
identical
202response 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/loginonce 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).
7.11.16 Share Links & Recipients #
| 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
/v1surface, 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 return402/api_key_requires_business), and (2) the specific endpoint must acceptapiKeyauth per the "Auth" column in Section 7.11 — most write-heavy account/billing/member-management endpoints aresession-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 —GETusage 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 staysession-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, nobilling:*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:actionpairs,action ∈ {read, write}except a small number ofmanagescopes 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 everyv1additive 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" }
}idis unique per delivery attempt-group (retries of the same logical event reuse thisid, which is the receiver's dedup key — mirroring the inbound pattern of Section 7.10).datashape 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 avideoIdin a webhook payload always matches the shape returned byGET /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 thev1=digest inReelay-Signatureare recomputed fresh for each attempt of a given logical event — attempt 1 at+0sand the retry at+30s(Section 7.13.4) carry two different, both-valid signatures for the sameReelay-Event-Id. Receivers MUST dedupe onReelay-Event-Id(the payload'sid), 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
deliveredafter the receiver responds2xxwithin 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; onlyReelay-Event-Idis stable across them. - After the final attempt fails, the delivery is marked
failedpermanently (no further automatic retry) and is visible with full request/response detail in the delivery log; the workspace admin can manually triggerPOST .../redeliver(Section 7.11.23) at any time afterward, which creates a fresh delivery attempt (newid) rather than resuming the exhausted schedule. - Delivery log: the
webhook_deliveriestable schema is owned by Section 5.4.x (public ID prefixwhd_; thestatusenum referenced throughout this subsection ispending|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 asstatus: skipped_disabledfor visibility, but no HTTP call is made) until an admin re-enables it viaPATCH .../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 inpackages/shared. Every Fastify route handler inapps/apiregisters 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:generatetask producingpackages/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 theoasdiff-style breaking-change check, forcing an explicit version/deprecation decision per Section 7.1 rather than an accidental break. - Docs surface:
https://developers.reelay.appis a static site (built from the same monorepo, its ownapps/docsNext.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 toondataavailable) 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.
OS-level system audio. No web API exposes the operating system's mixed audio output.
getDisplayMedia'ssystemAudiooption 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.Reliable high frame rate at high resolution.
getDisplayMediaframe 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.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.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. AMediaRecorderinstance 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.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 returnedMediaStreamTrack'sgetSettings()exposes only the capture's own width/height, not the source display's characteristics. This is a native display-enumeration capability the desktop app'sdesktopCapturer+ main-processscreenmodule provide directly (8.5, 8.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 coarsevideoBitsPerSecondhint. 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 bindingsThe 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) | StartCaptureRequest → StartCaptureResult |
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) | recordingId → void |
Drives the 8.10 state machine's recording ⇄ paused transitions |
capture:stop |
renderer → main (invoke) | recordingId → FinalizeResult |
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 nativeSystemAudioSourceinterface 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 standardAVCaptureDevice.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 viax-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::InitializewithAUDCLNT_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.
8.5.9 Deep-link handoff between web app and desktop app #
The web dashboard's "Record with desktop app" action:
- Web app calls
POST /v1/desktop-launch-tokens, receiving a one-time, 60-second-TTL launch token ({ token, expiresAt }). - Web app navigates to
reelay://record?token=<token>&mode=<captureMode>(a custom protocol the desktop installer registers with the OS on install). - 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, viaapp.requestSingleInstanceLock()). - The desktop app's main process extracts
token, callsPOST /v1/desktop-launch-tokens/:token/exchangeto 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. - 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 fromnavigator.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:
CGEventTapon the global event stream; Windows: a low-levelSetWindowsHookEx(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/wheelDOM events over the captured page surface only, rate-limited to 60 Hz viarequestAnimationFrame-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:
- Serializes and gzip-compresses the buffer (8.6.3).
- Requests a presigned PUT URL scoped to
POST /v1/recordings/:id/telemetry-upload-url. - Uploads the blob; on success, calls
POST /v1/recordings/:id/telemetry-completewith the resulting object key and a SHA-256 checksum of the compressed payload. - 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 thecursor_telemetry_blobsrow, 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 paused → finalising (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 statusuploadSessionId 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.m3u8Codec 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.
AbortIncompleteMultipartUploadat 7 days, applied to both buckets — matches theupload_sessions.stalled → abortedtimeout in 9.2 (a stalledvideo_screen/video_camerasession targetsreelay-media-restricted; a stalledtelemetrysession targetsreelay-media-delivery), preventing orphaned multipart uploads from leaking storage cost in either bucket.exports/*objects inreelay-media-deliveryexpire (hard delete) 30 days after creation.originals/*inreelay-media-restrictedandrenditions/*inreelay-media-deliverylifecycle 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_version — posters/{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_version — gif-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.mp4WebM 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.webmGIF 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.gifWatermark 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.mp4If 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:
- 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).
- 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.
- 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.
- 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.
- 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:
- 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 bytSourceMsand scoped to one recording's source timeline. Browser-sourced telemetry carries adegraded: trueflag (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. - 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. - 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
Float32ArrayofdurationMs / 100values. 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/sAt 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.
- Discard clusters with
aggregateWeight < MIN_CLUSTER_WEIGHT. - 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. - Sort raw segments by
rawStartMs. - 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 spansprevious.rawStartMstonext.rawEndMs, itstargetRectis the union of both clusters' target rects, and its zoom level ismax(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. - Repeat step 4 until no adjacent pair violates the gap rule (a merge can create a new violation with the following segment).
- Each surviving segment's
targetRectis 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_targetWith ω₀ = 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:
- 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 existingtargetRectcentroid 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 issolid-black. - 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
zoomLevelsearch 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% oftargetRect, because a narrower frame needs a tighter, not wider, crop to keep the target legible. If evenMAX_ZOOMcannot satisfy 90% coverage at the narrower aspect, the crop is centered ontargetRect's centroid atMAX_ZOOMand 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. - Letterbox pillars/bars (when policy 1 applies and the background alone cannot fully absorb the
gap — e.g.
nonepreset with an extreme aspect mismatch) render as the preset's own fill (background color/gradient/blur), never a hard black bar, except for thenonepreset, where a flat#000000bar is the explicit, documented fallback since there is no background fill to draw from. - 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)
zoomTrackvariants sharing the same sourceEventClusterdata 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.017→ 2.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.5→ 2.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,- = 5400`. Final: 1600–5400 ms, zoom 2.0x.
- Segment B: raw start
9000-400=8600; raw endmax(9800+600, 8600+2200)=max(10400,10800)=10800. Final: 8600–10800 ms, zoom 2.5x. - Segment C: raw start
15200-400=14800; raw endmax(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:
- 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
Cliprange, a removedClipentry, an entry inredactionRegions). 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. - "Restore all" returns exactly the original timeline. The editor provides an explicit "restore
original" action that replaces the current EDL's
clipsarray with a singleClipspanningsourceStartMs: 0tosourceEndMs: <source durationMs>atspeedFactor: 1.0, and clearszoomTrack,redactionRegions(subject to the exception below),speedRamps, andaudioAdjustments. 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 —
Clipentries (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 viaShift+F10/context-menu key) for operations that are inherently spatial (e.g. "draw a redaction rect" has a keyboard path: focus the preview, pressR, type explicit x/y/w/h values or use arrow keys to position a default-sized rect that appears, thenEnterto 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 whatTabnavigation 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 viaaria-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_status → failed; 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_status → failed, 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 status → failed, user sees a retry action (12.9) |
ai-metadata.generate |
transcript ingested, plan allows (12.11) | {videoId}:ai-metadata:{kind}:{promptVersion}:{attemptId} |
suggestion row status → failed, 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 ownstartMs/endMs. - Segments across a transcript are strictly ordered by
indexand non-overlapping. - A gap between
words[i].endMsandwords[i+1].startMs(silence, breath, cross-talk) is legal and expected — it is not filled with a synthetic zero-duration word. confidenceis never null; a vendor that does not report word confidence populates it with the segment-level confidence, and this substitution is recorded intranscripts.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:
- 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-levellanguageCodewhen absent) is populated per segment. - 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
languageHintfor 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 #
- Start a new cue at the next unconsumed word.
- Greedily add words to the current line while
line.length + 1 + word.punctuatedText.length <= 42(the+1accounts 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. - 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.
- Cue timing:
cue.startMs = firstWord.startMs,cue.endMs = lastWord.endMs. - Apply the reading-speed cap: compute
charCount = sum(lines.map(l => l.length)). IfcharCount / ((cue.endMs - cue.startMs) / 1000) > 20, extendcue.endMsup tocue.startMs + max(minCueDuration, charCount / 20 * 1000), capped atmaxCueDuration(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. - Enforce
minCueDuration: ifcue.endMs - cue.startMs < 1000, extendcue.endMstocue.startMs + 1000, but never pastnextCue.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. - 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
TranscriptSegmentboundary 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:
- Apply the muted-audio redaction filter (12.5.7) to the original word-level transcript first, removing
any word whose
[startMs, endMs)range overlaps aRedactionRegionwithmuteAudio = 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, viacaptions.rederive's own trigger on redaction updates). - 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). - A word entirely outside every kept range is dropped from the caption input.
- A word that straddles a cut boundary is clipped to the portion inside the kept range.
- 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.
- 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/endMscan be nudged in 100 ms increments, clamped so a cue never starts before the previous cue'sendMs + 84msgap or ends after the next cue'sstartMs - 84msgap. - 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) thatcaptions.rederiverespects 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 anyRedactionRegionwithmuteAudio = truefor 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 initialcaptions.generatepath and everycaptions.rederivepass — a redaction region added, changed, or removed re-triggerscaptions.rederiveeven 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}/transcriptdownload 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 underlyingtranscript_segments.wordsstorage (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 currentredaction_regionsstate. - 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
startMsand 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 responseContent-Typeistext/vtt,application/x-subrip, ortext/plainrespectively, 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 action — accept 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
summarytext (12.7) and videosummary/descriptiontext (12.8) are generated about the transcript; they are stored as separate fields (12.9.1) and are never written back intoTranscriptWord/TranscriptSegmentdata. - 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:
- 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.
- 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.0Coordinates 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.
13.5 Data Model Reference and Share-Link Reuse #
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
textandstep-badgeannotation 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 |
14. Sharing, Link Security & Access Control #
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/workspace → link/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/public → private/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/public → private/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.
14.2 Share link anatomy #
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_atpasses — enforced by the Mux signed-token expiry being set tomin(expires_at, now + 6h)at issuance time (Section 14.8.1), so a token issued shortly before expiry cannot outlive it. oEmbedand Open Graph endpoints (Section 14.11) return410 link_expiredfor 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:
- The watch page or embed player calls
POST /v1/share-links/{slug}/playback-token. - If the link has a non-empty
domain_allowlistand 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-verifiedattestedOrigin. - The server extracts the eTLD+1 from
attestedOriginusing the Public Suffix List and checks it againstdomain_allowlist. - Only on a match does the server mint the Mux signed playback token. On no match, it returns
403 domain_not_allowedand issues no token — there is no unprotected fallback path. - If the link has a non-empty
domain_allowlistand 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:
- 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. - The player-core, still executing in the host page's top-level document, reads
window.location.origindirectly and callsPOST /v1/share-links/{slug}/playback-tokenwith{ nonce, claimedOrigin: window.location.origin }. - 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
Originheader on this same POST request equalsclaimedOrigin— 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 indomain_allowlist(14.5.2). - 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.
14.6.0 The download endpoint is scoped to the specific share link, not the bare video #
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:
- Resolves
{slug}to itsshare_linksrow 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. - Checks
disable_downloadon that row.true→403 downloads_disabled, checked against the link actually in use, never a different link on the same video. - 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'ssrc/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:
- 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). - 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
loadSourceon the existingHlsinstance, 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.
14.8.4 Per-link key version (immediate revocation) #
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/public → private/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/exportstreams a CSV of the full filtered result set (same filters as 14.9.4), available toowner/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, anyshare_audit_eventsrow whereafterState.visibility === 'public'triggers an immediate email (not batched, not delayed) to everyowner/adminof 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.
14.11.4 Social and link-preview metadata #
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_atpassing (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 touchingshare_linksor 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):
- The loader (
embed.js) — the only script tag the host page ever adds. Its entire job is: read its owndata-*attributes, reserve layout (the aspect-ratio box, Section 15.2), render the poster<img>, set up anIntersectionObserver(Section 15.4), and — only once intent is established — dynamicallyimport()the player core. - 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-TTLloader 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 withoutadoptedStyleSheetssupport, 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:hostand descendant selectors within the shadow root only. - No
!importantleakage in either direction: the player's own stylesheet uses no!importantdeclarations (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!importantrules, 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
@importor<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 ownbox-sizing: border-boxand 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-familyif not explicitly set — mitigated by the player always explicitly setting its ownfont-familyat:hostscope, 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 #
- 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. - An
IntersectionObserver(threshold0.25,rootMargin: "200px 0px") watches the container. Only when the container crosses 25% visible within 200px of the viewport does the loader begin the dynamicimport()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. 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.- 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
tabindexvalues (only0and-1are 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 anaria-labelnaming the video title (aria-label="Video player: {title}"); the seek bar isrole="slider"; menus arerole="menu"/role="menuitem"; a visually-hiddenaria-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, notassertive, 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
localStoragemechanism 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(viamatchMedia, evaluated inside the shadow root's own script context) and, when set toreduce, 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 #
- 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.
- 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. - 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 afetch(..., { keepalive: true })fallback only for the rare browser environment wheresendBeaconis unavailable (per the browser matrix, 15.13).sendBeaconis used specifically because it guarantees delivery is attempted even when the tab is being closed or navigated away from mid-playback — a standardfetchwithoutkeepalivewould 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 batchedsendBeaconcall 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 vialocalStoragescoped 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:
- 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. - 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.
- Player-rendered overlay: the player core (15.1) composites the "Reelay" wordmark as a DOM
element positioned absolutely atop the
- 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 withCache-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 withCache-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 whichplayer-core.<hash>.jsversion it references, reaches every existing embed within minutes without requiring a single embedder to touch their page, while (b) thestale-while- revalidatewindow 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>.jsversion 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 immutableplayer-core.<newhash>.jsasset, (2) deploy a newembed.jsthat 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 stableembed.jsURL, 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:
playbackTokenis present, has a valid signature, and is not expired. If any of these fail, the whole batch is rejected — see the error table below.videoId(as bound to theplaybackToken) 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 — aplaybackToken's signature and TTL stay structurally valid even for a video that was deleted after the token was minted.- 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-nullshareLinkId. If it does not, the whole batch is rejected with400 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).
videoIdmust equal theplaybackToken's boundvideoId. An event carrying a differentvideoIdthan 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 theplaybackToken's boundshareLinkId. 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
(
visibilitychange→hidden,pagehide). - Flush uses
navigator.sendBeacon; if unavailable or the beacon is rejected (sendBeaconreturnsfalse), the player falls back tofetchwithkeepalive: true. sendBeaconcannot set custom headers or aContent-Typethe server can trust, so the server parses the request body as JSON regardless of the declaredContent-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):
- A valid
recipientTokenaccompanies the batch → use the bound identity (path C). - A valid authenticated session resolves to a user with workspace access to the video → use that user's identity (path A).
- The
viewerTokenis already linked to a namedviewersrow from an earlieremail_submitwithin the current 30-day token lifetime → use that identity (path B, remembered). - Otherwise → anonymous; identity is the
viewerTokenalone.
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 pervideo_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(areal[]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:
- The link owner explicitly enables
enableViewerIdentificationon the share link or video. - 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
enableViewerIdentificationremoves 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 (asendBeaconretry after a flaky connection) a no-op viaON 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
viewerTokengap 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_atday 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 | 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.
- If the requesting user's workspace role is
owneroradmin: always allow, atmanagelevel, regardless of folder visibility or anyfolder_permissionsrow. This matches Section 6:adminmanages "all workspace videos." - 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_permissionsoverride 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'spermissionLevel. - Else — no folder anywhere in the chain, including the target folder itself, has an override row —
the target folder's own
visibilitysetting governs, unconditionally:visibility = 'workspace'grantsview;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. - A
memberalways 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'smembercapability 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:
- Admin enters the desired hostname (e.g.
videos.acme.com) in workspace settings. - The system returns a CNAME target and a TXT verification record; the admin creates both DNS records at their registrar/DNS host.
- 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. - From
active, the watch page for every video in that workspace becomes reachable athttps://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 |
text — active | 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:
- 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.
- Sole owner of a workspace with no other members: the workspace is scheduled for deletion under 19.5.3 in the same request.
- Member, admin, or viewer elsewhere: their
workspace_membersrows are deleted; any videos they created transfer per 19.5.2. - The user's own personal data is scrubbed per the erasure procedure in 19.6:
users.nameis replaced with the literal"Deleted User"andusers.emailis replaced with a synthetic, non-routable tombstone address of the fixed formdeleted-<user_id>@tombstone.reelay.invalid;password_hashis cleared, and alloauth_accounts/mfa_credentialsrows are deleted. Theusers.idrow itself is retained as a tombstone — it remains the foreign-key anchor for historicalcomments,audit_events, andjobs_auditrows so that "who did this" history stays internally consistent, while carrying no personal data. - Email delivery logs are scrubbed, not deleted. Every
email_logrow whoseto_emailmatches the deleted user's address hasto_emailoverwritten with the samedeleted-<user_id>@tombstone.reelay.invalidvalue produced in step 4. Every non-PII column on that row —email_type,sent_at, deliverystatus, 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) mapsIntegrationName → IntegrationProviderinstance, 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-sweepruns every 15 minutes, selectingintegration_connectionsrows wherestatus = 'active'and the decrypted token set'sexpiresAtis 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 callsprovider.refreshToken(), re-encrypts and stores the new token set, and updateslastRefreshedAt. - A refresh failure (vendor returns
invalid_grantor equivalent — the user revoked access outside Reelay) setsstatus = '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.) checksstatusfirst 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 (
DELETEon the connection, Settings UI action) callsprovider.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 theintegration_connectionsrow (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 viachat.postMessageusing 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 (alinkvisibility share link is auto-created if the video had none, reusing an existing one otherwise). - Record-and-send from a slash command:
/reelay recordposts 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 thecomment.created/video.viewedwebhook events (Section 7.13.1), not a separate notification pipeline. - Link unfurling (Events API): Reelay subscribes to the
link_sharedevent for thereelay.linkdomain and any connected custom domains (Section 7.11.21). When a Reelay URL is pasted into any Slack channel the app can see (perlinks: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. Aprivate/workspace-visibility link, or alink/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: thelink_sharedrequest 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'sdomainAllowlist— 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), andog:videotags, 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
searchendpoint scoped to pages shared with the integration) and Reelay creates a set of blocks viablocks.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 (anotionPageId+notionBlockIdsarray 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.viewedevents (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 emailemailMatch key derived from share link's video title reelay_last_video_watched(custom property, auto-created on first sync if absent)Free text capturedAtreelay_last_captured_at(custom property)ISO timestamp string share link's idreelay_share_link_id(custom property)For traceability back to Reelay N/A lifecyclestageNever 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 syntheticvideo.readypayload 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 callsPOST .../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 callsPATCHwith{ enabled: true }. - Webhook configuration itself requires
adminrole or above (matching Section 7.11.23's role column);memberandviewerroles 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 (active → past_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 theviewerrole (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_keysare revoked (401on 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 defaultreelay.appshort 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
linkorpublic—workspace/privatevisibility 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_memberstable'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 varSTORAGE_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 varSTORAGE_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:
- 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.
- Every poster/thumbnail/preview object key is scoped by the share link's
playback_key_version(Section 5.4'sshare_linkstable). Revoking a share link or downgrading its visibility (Section 14.1.1) incrementsplayback_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:
- 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. - 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). - If a video has one or more
redaction_regionsrows withstatus = 'pending'orstatus = 'active', the render job's output rendition is taggedredaction_verified = falseuntil every region's post-render check passes; only once all pass is it flipped toredaction_verified = trueand the rendition transitions tostatus = 'ready'. - If any region fails the check, the render job does not fail silently: it transitions to
status = 'failed', writesrenditions.failure_reason = 'redaction_verification_failed', enqueues an alert to the observability pipeline (Section 24) with severitycritical, 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_statusis computed, not stored as an independent source of truth: it isblockedwhenever the video has anyredaction_regionsrow not yet reflected in aredaction_verified = truerendition covering the video's current EDL version. POST /v1/share-linksandPATCH /v1/share-links/{id}(making a link active) both re-checkshare_gate_statussynchronously at request time — server-side, not client-side. If the status isblocked, the API returns409 Conflictwith error coderedaction_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.servableflag is flipped tofalseand the CDN cache is purged for that rendition's URLs) until a newredaction_verified = truerendition 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 = blockedcannot 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 codeinvalid_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
422with error codepassword_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:
- 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/DELETEnavigations — this alone stops the majority of CSRF vectors. - Double-submit CSRF token for all state-changing requests (
POST,PUT,PATCH,DELETE) made from the web app: the server sets acsrf_tokenvalue (opaque, per-session, rotated every 24 hours) as a non-httpOnlycookie readable by the app's JS, and the app must echo it back in anX-CSRF-Tokenheader on every mutating request. The server rejects any mutating request where the header value does not match the cookie value with403 Forbidden, error codecsrf_token_mismatch. This second layer exists becauseSameSite=Laxalone does not protect against same-site scripting or subdomain-takeover-adjacent scenarios and because some older browser/proxy combinations do not enforceSameSitereliably. - 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.
GETrequests 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.
22.3.5 Secure cookie attributes #
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.) includesworkspace_id = :actorWorkspaceIdin itsWHEREclause 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 Foundwith error codevideo_not_found(or the resource-appropriate_not_foundcode), never403 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
publicvisibility 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 sameauthorize()function using theshare_vieweractor type ({ type: 'share_viewer', viewerToken, videoId }, per Section 6.9 and 22.4.1) — never the workspaceviewerrole'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_idis joined back tovideos.workspace_idon 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-itconfigured withhtml: false(raw HTML passthrough disabled entirely) and a link renderer override that force-addsrel="noopener noreferrer nofollow ugc"and validates the link scheme ishttp:orhttps:(rejectingjavascript:,data:,vbscript:). The parsed output is rendered client-side through React's default JSX text/attribute escaping —dangerouslySetInnerHTMLis 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 viajsdom) 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>, allon*event-handler attributes, andstylevalues containingurl(/expression(/javascript:are stripped unconditionally. Any<iframe src>not matchingCTA_IFRAME_SRC_ALLOWLISThas theiframeelement removed entirely (not just thesrcattribute) 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:
- Scheme allowlist: only
https:is accepted (plainhttp:rejected) except for local-development environments, wherehttp:tolocalhost/127.0.0.1is permitted behind an environment flag that must befalsein production. - DNS resolution: the URL's hostname is resolved via DNS to its candidate IP address(es).
- 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.d→a.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. - 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:A resolved address in any of these ranges causes the request to be rejected before any socket is opened, with error code
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::/10url_not_allowed. - 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. - 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.
- 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.
- 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:
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/mp466 74 79 70at offset 4 (ftypbox)video/webm1A 45 DF A3(EBML header)image/png89 50 4E 47 0D 0A 1A 0Aimage/jpegFF D8 FFimage/webp52 49 46 46...57 45 42 50(RIFF....WEBP)image/svg+xmlrejected 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 with422, error codefile_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).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.
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.
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.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
Authorizationheader,Cookieheader, any field named or matching/password|token|secret|apiKey|ssn|cardNumber/iin request bodies, and the full value of anysk_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 thesk_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.appresponses: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_secretandoauth_accounts.refresh_tokenare 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 byauthorize()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-downloadis 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 afterauthorize()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
deliveryonly, structurally incapable of targetingrestricted.
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 dynamicimport()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.appand*.mux.comorigins — 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
postMessagecommunication 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 incomingpostMessageis checked against an expected message schema before being acted on, and the player never executes a string received viapostMessageas 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
Originheader 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 overpostMessagewith 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 additionallyX-Frame-Options: SAMEORIGINfor 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-ancestorspolicy is conditional on the share link's configuration: a share link with no domain allowlist (Section 14) setsframe-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 setsframe-ancestorsto 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
quarantinestorage prefix into its normal restricted/delivery path. A positive detection setsmedia_assets.scan_status = 'infected', the object is deleted from the quarantine prefix (not moved), the owning video is force-transitioned to ablockedstate 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 acontent_reportsrow (video_id,reporter_viewer_tokenorreporter_user_id,reasonenum:spam,harassment,illegal_content,impersonation,other, free-textdetailscapped 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 toworkspace-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.appwatch 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 topublicvisibility (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 markedrevoked, 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
viewerTokenis 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).
22.10.7 Cookie inventory and consent posture #
| 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
viewerTokenis 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
viewerTokenis 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.
22.10.8 Recording-consent — who is responsible when a recording captures a third party #
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 auditplus a dedicated SCA tool such as Dependabot/Snyk) across all workspaces in the monorepo. Acriticalorhighseverity 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/apiorpackages/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.txtpath 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_eventsrow (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 checksrole/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, not403, for the resources listed in 22.4.2. - The
share_vieweractor type is never evaluated againstROLE_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
viewerTokencookies 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.appresponses (22.7.1). -
mfa_credentials.totp_secretandoauth_accounts.refresh_tokenare 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/webroute 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-ancestorsis 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
viewerTokenpersistence and disable named per-viewer identification, verified by an automated test (22.10.6). - EU/UK-detected watch-page sessions use the reduced-persistence
viewerTokenbehavior 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.txtand 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:
- 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).
- The automated conformance gate in CI (23.11) reports zero
serious/criticalaxe-core violations on every route, on every pull request, before merge. - Every release candidate passes the manual audit checklist in 23.11 before shipping.
- Every one of the five surfaces is included in the tested screen-reader/browser matrix in 23.7 at least once per minor release.
- 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_segmentsrow, Section 12), reachable via standardTab/Shift+Tabtraversal in document order, withHome/Endjumping to the first/last segment andArrowUp/ArrowDownmoving between segments without requiring the segment to be a native<a>or<button>element to do so unnaturally — segments are rendered as arole="list"ofrole="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, orSpace) 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 viaaria-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 atitleattribute, which is not reliably announced) in a consistentmm:ssformat 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:
- Play/Pause toggle
- Seek bar (a native-semantics
role="slider"witharia-valuemin/aria-valuemax/aria-valuenowin seconds,aria-valuetextinmm:ss;ArrowLeft/ArrowRightseek ±5s,ArrowUp/ArrowDownseek ±10s,Home/Endjump to start/end) - Current time / duration display (not focusable,
aria-hiddensince the seek bar'saria-valuetextalready conveys the same information to assistive technology, avoiding duplicate announcement) - Volume control (button + slider, same slider semantics as the seek bar,
ArrowUp/ArrowDownadjust ±5%,Mtoggles mute when the volume control has focus) - Playback speed control
- Captions toggle
- Transcript toggle (shows/hides the transcript panel described in 23.4)
- Picture-in-picture toggle (where supported by the browser)
- Fullscreen toggle
- 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 0–9 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.
23.5.5 Skip links #
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"witharia-valuenowupdated 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:
- Parse the submitted color to sRGB.
- 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. - 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 codeaccent_color_insufficient_contrast— with a responsedetailsarray 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). - Independently, the accent color is checked against the watch page's fixed background
(
#FFFFFFlight theme /#0A0A0Adark 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. - 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: booleanparameter, 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 detectprefers-reduced-motion: reduceat 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'sid, oraria-labelledbywhere 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 viaaria-describedby; the error message is also inserted into the page'saria-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[].issuefield (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: reduceenabled 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
onRequesthook checks for an inboundX-Request-Idheader (public API clients doing idempotent retries may supply one); if absent, it generatesreq_<12-char base58>. The id is set on the response headerX-Request-Idand stored inAsyncLocalStoragefor 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
datapayload always includesrequestId(the originating request) andcausationId(the id of the job or request that directly triggered this one — for a job enqueued by another job,causationIdis that job'sjobId; for a job enqueued by an HTTP request,causationIdequalsrequestId). This produces a traceable chain from an inbound HTTP call through however many queue hops a piece of work takes (e.g.video.ingest→video.transcode.request→ webhook-triggeredvideo.render.compose→video.thumbnail.generate). - The worker process, on picking up a job, seeds a new
AsyncLocalStoragecontext fromjob.data.requestIdso 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
requestIdfor the inbound webhook call but setscausationIdto 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 anyGETthat returns a 4xx/5xx. SuccessfulGETrequests to high-volume, low-risk routes (/v1/videos,/v1/videos/:videoId/analytics/heartbeat, the public/v1/collectanalytics endpoint) are sampled at 10% inproductionto 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/tracelogs, when enabled for an investigation, are sampled at 1% inproductionunless scoped to a specificworkspaceIdfilter, in which case 100% for that workspace only.warn,error,fatalare 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/segmentIdreference is fine; the text is not). - Viewer email addresses and any other viewer PII captured via email-gated links (log the
viewerTokenoremailCaptureIdinstead). - 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/apigets 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
traceIdpropagation injob.data, not span parenting: the enqueuing span records the child job's futuretraceIdas a span link). - The render worker's
ffmpeginvocation gets a span covering process spawn to exit, withctxattributes 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.refreshworker.transcode.request,worker.render.compose,worker.transcription.requestdb.query.videos.select,db.query.share_links.insertvendor.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=trueSafety 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 #
- 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.
- 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.
- Communicate: SEV1/SEV2 incidents post to the status page (24.10.3) using the templates below within 15 minutes of declaration.
- 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).
- 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):
- Summary — one paragraph, what happened, customer impact, duration.
- Timeline — timestamped, built from the incident channel log and the observability platform's deploy/alert annotations (24.5.4), not reconstructed from memory.
- Root cause — the technical cause, traced to a specific commit, config change, or vendor event where possible.
- What went well — detection speed, runbook effectiveness, communication.
- What went poorly — detection gaps, missing runbook, alert noise or silence, anything that slowed mitigation.
- 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
localhosttest 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 inpackages/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:
- Sign up, verify email, create a workspace, record a short screen capture in-browser, confirm it appears processing then ready in the library.
- Create a share link with a password and expiry, open it in an unauthenticated context, confirm password gate and successful playback.
- 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).
- 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).
- Invite a member to a workspace, accept the invite, confirm role-appropriate access (25.9 cross-reference).
- 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
403with error codeplan_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 asserts200with a token whose player reaches thefirst_frameevent, 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. - Embed a video on a third-party test page via the embed script and confirm it lazy-loads and plays (Section 15).
- Submit a comment and a CTA email capture as a viewer, confirm both appear in the dashboard's engagement view (Section 17).
- Cancel a subscription, confirm downgrade behavior matches the Iron Rule (existing content unaffected, new creation gated).
- 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 Turborepopnpm 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:
- 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. - 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
UPDATEon a large table, e.g.video_view_events). Reads still use the old representation until backfill is confirmed complete. - 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_DELIVERY — STORAGE_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/playerhas 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'sEMBED_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, unlikeapps/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:
- Created — added to the
feature_flagstable with a description, an owner, a default value, and akind(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). - Rolled out — percentage or explicit workspace-list rollout, monitored against the metrics/SLOs in 24.2/24.4 for regression before widening.
- Fully on — flag evaluates true unconditionally.
- 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 everyrelease-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:
- 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.
- 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.
- 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:
- Initialize the pnpm-workspaces + Turborepo monorepo with
apps/web,apps/api,apps/desktop,apps/worker,packages/player,packages/db,packages/shared,packages/uias empty, buildable packages, each named for its directory (e.g. the package atapps/webis namedweb). - Configure TypeScript project references, ESLint, Prettier, and pre-commit hooks per Section 4.
- Define the environment variable schema (Zod) and fail-fast startup validation per Section 26.
- Provide a
docker-compose.ymlbringing up PostgreSQL 17 and Redis 8 for local development. - 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. - Generate and run the first migration with drizzle-kit.
- Implement email + password registration/login (Argon2id per Section 6), Google OAuth 2.0 (PKCE), session cookie issuance, and email verification gating per Section 6.
- Wire the CI pipeline: lint, typecheck, unit test, build, on every pull request.
- Add baseline structured logging and request-id propagation per Section 24.
Exit criteria:
pnpm install && pnpm turbo run buildexits 0 across all eight packages.pnpm turbo run typecheckandpnpm turbo run lintboth exit 0.docker compose up -d db redis && pnpm --filter db migrateapplies every migration with no errors;psql "$DATABASE_URL" -c "\dt"listsusers,sessions,oauth_accounts,mfa_credentials,workspaces,workspace_members.pnpm --filter api testpasses, including a test thatPOST /v1/auth/signupreturns202 Acceptedwith the success envelope from Section 7.3, and a subsequentPOST /v1/auth/loginreturns a session cookie.- Starting
apps/apiwith 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:
- Extend the schema with
workspace_invitesand seat-accounting fields per Section 5. - Implement the four roles —
owner,admin,member,viewer— exactly as Section 6 defines them, enforcing exactly oneownerper workspace. - Build the single authorization enforcement point used by every API route — no per-route ad hoc checks — per Sections 6 and 7.
- Build workspace CRUD and the invite/accept/revoke flow per Section 6.
- Build seat accounting (viewer seats free and unlimited; recording seats counted) per Section 6, with full cap enforcement deferred to M13.
- Write a role × action contract-test matrix.
Exit criteria:
pnpm --filter api test -- rbacruns 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
admincallingDELETE /v1/workspaces/:id(owner-only) receives403with error codeforbiddenin the Section 7.6 envelope; the test asserts the exact code string. - Attempting to create a second
owneron 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:
- Implement
getDisplayMedia+getUserMedia+MediaRecordercapture with the codec probe/fallback order from Section 8. - Persist 3000 ms chunks to OPFS (IndexedDB fallback) per Section 9.
- Build the dedicated upload Worker: S3 multipart, 8 MB parts, up to 4 concurrent, exponential backoff with jitter, per Section 9.
- Detect and offer resume on next app launch; retain local chunks until the server confirms multipart completion, then delete, per Section 9.
- Implement the
video.ingestjob: createvideos/recordings/media_assetsrows, triggerffprobeper Section 9. - 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_bytesmatches 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:
- Integrate Mux direct-upload/asset-create behind the vendor-swappable interface from Section 9.
- Trigger the transcode ladder and implement the signature-verified, idempotent webhook handler per Sections 7 and 9.
- Generate posters and thumbnails per Section 9.
- Build the watch page route in
apps/web, rendering the player core against the Mux playback ID. - Configure BullMQ per Section 9: 5 attempts, exponential backoff, per-queue concurrency,
*.dlqdead 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
ffprobeinvocation. - A webhook delivered with an invalid signature is rejected with
401and 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:
- Build
packages/player's embed loader (embed.js) and lazy-loaded player core as two separate bundles per Section 15. - Implement closed Shadow DOM style isolation with zero global CSS leakage per Section 15.
- Implement intent-based lazy load (viewport intersection or click) with
preload="none"until intent, per Section 15. - Reserve the aspect-ratio box pre-load and use a
loading="lazy" decoding="async"poster<img>per Section 15. - Implement
sendBeaconanalytics with a rotating anonymous viewer id and zero host-domain cookies per Section 15. - Build the email-embed fallback pipeline (poster/GIF + link) per Section 15.
- Wire a
size-limitCI job enforcing the two byte budgets from Section 15.
Exit criteria:
- The
size-limitCI job fails the build ifembed.jsexceeds 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.cookieon 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.
28.7 Milestone M5 — Share links and link security, the audit trail #
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:
- Build
share_linksschema/CRUD: visibility, password (Argon2id), expiry, domain allowlist, disable-download, disable-comments, require-email, per Section 14. - Implement signed, short-TTL Mux playback token issuance (6h TTL), re-issued by the watch page and player, per Section 14.
- Build
share_link_recipientspersonalized tokens per Section 14. - Write a
share_audit_eventsrow on every visibility/permission change (actor, before, after, timestamp, IP) per Section 14. - Enforce the domain allowlist server-side, not only via referer header, per Section 14.
Exit criteria:
- Setting a link to
privateand attempting anonymous playback returns403in an integration test. - Changing a link's visibility writes exactly one
share_audit_eventsrow 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
401and 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:
- Build the audio-extract-then-transcribe async job per Sections 9 and 12.
- Store
transcript_segmentswith word/segment-level timestamps per Section 12. - Generate VTT captions and attach them to renditions per Section 12.
- Build the transcript view UI: synced highlighting, click-to-seek.
- 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_segmentsrows.
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:
- Build the interest-event detector (click, drag start/end, typing burst, scroll start, window focus change) per Section 10.
- 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.
- 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.
- Implement auto-framing: the inner 60% safe rect and the hysteresis band, per Section 10.
- 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.
- Persist output as an
EditDecisionListJSON document, never baked pixels; tag the video with an immutableauto_edit_preset_versionper Section 10. - Implement the render worker path that consumes EDL + source per Sections 9 and 10.
- 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_assetsrow 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:
- 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.
- 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.
- Build redaction region authoring (static rect or keyframed track) in the editor per Sections 11 and
- 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.
- Implement the
restricted_media.accessedaudit-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 oneaudit_eventsrow (actor, actionrestricted_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.) - 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 = pendingreturns a409-class error with coderedaction_pendingin the Section 7.6 envelope, and no signed playback URL is issued. - A
viewer-role request for the unredacted original asset URL returns403and writes exactly onerestricted_media.accessedaudit_eventsrow (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_assetsobject'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:
- Build the chapter/summary/title generation job consuming
transcript_segmentsper Section 12. - Store suggestions in
ai_metadata_suggestionsas pending; never copy into public video metadata automatically, per Section 12. - Build the acceptance UI: accept, edit, or reject per suggestion; only accepted content becomes the video's public chapters, summary, or title.
- Build the regeneration path for when a transcript is corrected or re-run.
Exit criteria:
- Generating suggestions for a fixture video populates
ai_metadata_suggestionswhile leaving the video's publictitle,chapters, andsummaryfields 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:
- Build the public, rate-limited
POST /v1/collectendpoint per Sections 7 and 16. - Build monthly-partitioned
video_view_eventsingestion per Section 16. - Build hourly rollup jobs producing
video_view_dailyandvideo_engagement_curve(capped at 1800 buckets) per Section 16. - Implement viewer identity: anonymous 30-day
viewerTokenversus named viewer, per Section 16. - Implement DNT/GPC handling per Sections 16 and 22.
- Build the dashboard UI, reading rollups only, never raw events.
Exit criteria:
- Sending a
sendBeaconbatch of play/heartbeat/pause/complete events to/v1/collectproduces rows invideo_view_eventswithin the same integration test's request cycle. - Running the hourly rollup job against a fixture day of raw events produces a
video_view_dailyrow 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: 1is not associated with a named viewer identity even when arecipientTokenis present. - A query-log assertion confirms dashboard requests read only rollup tables, never
video_view_eventsdirectly.
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:
- Build
comments(threaded, timestamped-in-video) andcomment_reactionsschema/API per Section 17. - Build moderation (delete/hide) restricted to
member+ roles per Sections 6 and 17. - Build
ctasandcta_events: configurable CTA overlays with click tracking per Section 17. - Build
email_captures, feeding the require-email-to-watch gate from Section 14. - Enforce the
disableCommentsshare-link flag from Section 14.
Exit criteria:
- A
viewercan post a comment; aviewerdeleting another viewer's comment gets403; amember+ deleting it succeeds — verified by a role-matrix test reusing the M1 enforcement point. - A share link with
disableComments = truereturns403onPOST /v1/videos/:id/comments. - A Playwright test clicking a CTA writes exactly one
cta_eventsrow and increments the dashboard's click-through count by 1. - A require-email-to-watch link blocks playback token issuance until an
email_capturesrow 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:
- Build
foldersandfolder_permissions(nested, per-folder grants) per Section 18. - Build the library video-cap enforcement hook point, with real enforcement wired in M13, per Sections 18 and 21.
- Build
brand_kits(logo, colors, fonts) applied to the watch page/player chrome and the email fallback, per Sections 18 and 15. - Build
custom_domains: CNAME verification flow and TLS provisioning per Section 18, gated to Business plan in M13.
Exit criteria:
- Granting a
viewerfolder-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.statustoverified; an incorrect DNS response leaves itpendingwith 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:
- Build
plans,subscriptions,usage_counters,usage_events,invoicesschema and the Stripe Node SDK integration per Section 21. - 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.
- Build the 80%/100% warning notifications (in-app and email) per Section 21.
- Build the downgrade-to-read-only-for-creation state machine, verifying playback is never gated by plan state, per Sections 19 and 21.
- Wire the player watermark toggle by plan (Free forced on) per Sections 21 and 15.
- 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
403codeplan_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
200with a valid, playable signed URL post-downgrade. New video creation in the same workspace, in the same test run, returns403plan_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 theshare_linksrow 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:
- Build the Electron shell and
desktopCapturerintegration per Section 8. - Implement macOS ScreenCaptureKit/Core Audio tap (13+) and Windows WASAPI loopback for system audio per Section 8.
- Implement native cursor telemetry hooks at 120 Hz per Section 8.
- Implement local hardware encode and local-first disk write, reusing the M2 resumable-upload contract, per Section 9.
- Build the desktop auto-update channel.
Exit criteria:
- A desktop recording's
cursor_telemetry_blobsrow 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:
- Build the timestamped frame-grab endpoint and a standalone screenshot capture mode per Section 13.
- Build beautification (background presets, padding, corner radius, shadow, chrome/device frame mockups) per Section 13.
- Build the
screenshotsentity 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:
- Build
api_keys: SHA-256 hashed at rest, prefix stored for display, scoped, rotatable, per-key rate limits, per Sections 6 and 7. - Expose the public API surface as the internal surface scoped by key, per Section 7.
- Build
webhook_endpointsandwebhook_deliveries: signed payloads, retries, delivery log, per Section 20. - 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_deliveriesrow 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
401in the same test run. - Every public API response carries
X-RateLimit-Limit,-Remaining,-Reset; a burst past the limit returns429withRetry-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:
- Build
retention_policiesper plan per Sections 19 and 21. - Build
deletion_requests(user-initiated and policy-driven), honoring the soft/hard delete rules from Sections 7 and 19. - Build the purge job: hard-deletes media assets, verifies zero remaining references, per Section 19.
- Build the prior-notice email flow ahead of policy-driven deletion per Section 19.
- 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_atset, zero objects remaining under that video's storage prefix (verified by an automated bucket-listing check), and any existing share link returning avideo_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:
- Run an automated accessibility audit (axe-core or equivalent) across the dashboard, editor, watch page, and embed, per Section 23.
- Run a manual keyboard-only and screen-reader pass on the primary flows per Section 23 (manual — tracked, not CI-gated).
- Run security hardening: dependency audit, secrets scan, header/CSP review, abuse-case pass per Section 22.
- Stand up observability dashboards and alerts for queue depth, error rate, webhook failure rate, transcode latency, per Section 24.
- Run full performance-budget verification against Section 27's numeric targets, including the player budgets from Section 15.
- Run a production environment/config review per Section 26; rotate any dev-default secrets out.
- 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 testand 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 --> M1828.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, orTBDremains 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_flagsrow (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
mainwith CI green; there are no direct pushes tomain. - 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.mddescribing local setup and aDECISIONS.mdrecording 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:
- Initialize the pnpm-workspaces + Turborepo monorepo skeleton with all eight packages building empty (Section 3, Section 4).
- Add environment-variable schema validation and a
docker-compose.ymlfor PostgreSQL 17 and Redis 8 (Section 26, Section 3). - Write the Drizzle schema for
users,sessions,oauth_accounts,mfa_credentials,workspaces,workspace_members, and run the first migration (Section 5). - Implement email + password registration and login with Argon2id and session cookie issuance (Section 6).
- Implement Google OAuth 2.0 PKCE and the email-verification gate (Section 6).
- Stand up the CI pipeline: lint, typecheck, unit test, build, on every pull request (Section 25, Section 26).
- Implement the single authorization enforcement point and its role-matrix contract tests (Section 6, Section 7) — this is the start of M1.
- Implement workspace CRUD and the invite/accept/revoke flow (Section 6).
- 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).
- Create the repository's
DECISIONS.mdand 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.