# Server Functions

All server functions use `createServerFn` from `@tanstack/react-start` with `.inputValidator()` for Zod validation (NOT `.validator()` -- this is a v1.133.5 API).

## marketing-analytics.ts

| Function | Method | Auth | Description |
|----------|--------|------|-------------|
| `getMarketingShareAnalytics` | GET | Required | Returns share-link analytics for one owned script version. |
| `getProfileShareAnalytics` | GET | Required | Returns user-wide writer-profile, Table Read, and Pitch Deck share analytics for Profile → Public Profile → Analytics. |

## performance-booth-fns.ts

Internal pipeline naming remains `performance-booth`; the user-facing Table Read stage is Audio Acting.

| Function | Method | Auth | Description |
|----------|--------|------|-------------|
| `getPerformanceBoothSession` | GET | Required | Loads or creates the current Table Read Audio Acting recording link, projected screenplay lines, and completed line indexes. |
| `getPerformanceBoothByToken` | GET | Token | Loads the microphone-only phone recorder session for a signed recording link. |
| `getPerformanceBoothStatusByToken` | GET | Token | Polls completed Audio Acting line conversions without requiring a dashboard session. |

## account-deletion-fns.ts

Deleting one's own account (legal gap G2). The core is `account-deletion.ts` (request, cancel, status, the admin closure, the daily sweep) and `account-deletion-purge.ts` (the purge, step by step); `/api/v1/account/deletion` reaches the same core for the Story API, CLI, MCP and the mobile app.

| Function | Method | Auth | Description |
|----------|--------|------|-------------|
| `getMyAccountDeletionFn` | GET | Required (any signed-in account) | Where the deletion stands, the credit balance with any top-up still inside its 14-day refund window, what deleting removes and keeps. |
| `requestMyAccountDeletionFn` | POST | Required | Schedules the deletion 7 days out; `confirmEmail` must be the account's email. Refused for an admin, a workshop host or an organization owner. |
| `cancelMyAccountDeletionFn` | POST | Required | Cancels a scheduled deletion until the purge starts. |

## account-export-fns.ts

"Download my data" (legal gap G3). The core is `account-export.ts` (request, status with a fresh signed link, the daily sweep) and `account-export-archive.ts` (the zip, built on the workers); `/api/v1/account/export` reaches the same core for the Story API, CLI and MCP.

| Function | Method | Auth | Description |
|----------|--------|------|-------------|
| `getMyAccountExportFn` | GET | Required (any signed-in account) | Where the latest archive stands; when ready, a signed link that works for an hour. |
| `requestMyAccountExportFn` | POST | Required | Starts building one (or returns the one building). |

## project-deletion-fns.ts

Deleted projects (legal gap G2). The core is `project-deletion.ts` (delete, list, restore, the daily sweep) and `project-deletion-purge.ts` (the purge after the 30-day window). Deleting takes the project's episodes with it: during the window they stay linked and private (`Script.privateAt`), a restore puts back exactly what that delete changed, and at the purge they are deleted with everything under the project (`episodesAtPurge`).

| Function | Method | Auth | Description |
|----------|--------|------|-------------|
| `listMyDeletedProjectsFn` | GET | Writing Lab access | Deleted projects still inside their restore window, with `restorableUntil` and `episodesKept`. |
| `restoreMyProjectFn` | POST | Writing Lab access | Restores one within its window; episodes deleted or moved since stay where they are. |

## courses.ts

| Function | Method | Auth | Description |
|----------|--------|------|-------------|
| `getCourses` | GET | Optional | Lists published courses with lesson counts and per-user completion. Applies admin preview override. |
| `getAllCoursesWithLessons` | GET | Optional | Full course + lesson tree with progress data and sequential unlock state. Used by dashboard teaser and courses page. |
| `getCourseWithLessons` | GET | Optional | Single course with lessons. Input: `{slug}`. Returns `hasAccess`, `isAdmin`, `isAuthenticated`. |
| `updateLessonProgress` | POST | Required | Upserts `LessonProgress`. Input: `{lessonId, watchedSeconds, completed}`. |
| `getSignedPlaybackUrl` | GET | Required | Refuses an unpublished lesson or course except to its mentor or an admin, then validates plan access (the catalog's freemium rule) + sequential lesson order, returns `{playbackId, token}`. Input: `{lessonId}`. |

### Admin Preview Override

All course functions read the `sh_preview_tier` cookie for ADMIN users to override tier/role for testing. Implemented via `applyPreviewOverride()`.

## dashboard.ts

| Function | Method | Auth | Description |
|----------|--------|------|-------------|
| `getDashboardData` | GET | Required | Returns `{user, subscription, profile, progressStats}`. Applies admin preview override to `planTier`/`planStatus`. |
| `updateProfile` | POST | Required | Updates `User.name` and upserts `UserProfile`. Input: `{name?, location?, referralSource?, publicProfileEnabled?, publicSlug?, publicHeadline?, discoverySummary? (About you), …discovery fields}`. |
| `getRandomQuote` | GET | Required | Returns a random active RotatingQuote with `locked` boolean based on sequential lesson order for the user. |

## customer-api-keys.ts

Customer-facing Story API keys are separate from provider/admin `EncryptedAPIKey` records.

| Function | Method | Auth | Description |
|----------|--------|------|-------------|
| `getCustomerApiDashboard` | GET | Required | Returns the current user's customer API keys, allowed scopes, and Studio Credit balance for the `/dashboard/developers` Developers page API Keys tab. |
| `createCustomerApiKey` | POST | Required | Creates a production customer API key, stores only the hashed secret, and returns the plaintext token once. Input: `{name, scopes, allowedIps?, expiresAt?}`. |
| `revokeCustomerApiKey` | POST | Required | Revokes a customer API key owned by the current user. Input: `{keyId}`. |

## customer-api-auth.ts

These are route helpers for `/api/v1`, not `createServerFn` exports.

| Helper | Description |
|--------|-------------|
| `authenticateCustomerApiRequest` | Parses `Authorization: Bearer`, verifies the customer API key hash, checks revocation/expiration/IP allowlist/scopes, and updates `lastUsedAt`. |
| `handleCustomerApiRoute` | Wraps authenticated API routes with request IDs, keyed rate limiting, safe JSON errors, rate-limit headers, and request logging. |
| `runCustomerApiIdempotentJson` | Provides reusable idempotency-key handling for future expensive JSON POST operations. |
| `getCustomerApiCreditSummary` | Returns a Studio Credit balance summary for authenticated API routes. |

## story-api-route.ts

Story API-specific wrappers layered on top of `handleCustomerApiRoute`.

| Helper | Description |
|--------|-------------|
| `handleStoryApiRoute` | Generic route wrapper. Catches `StoryApiError` and emits `{ error: { code, message, requestId } }` at the configured status. |
| `handleStoryApiIdempotentRoute` | POST wrapper. Replays prior responses when an `Idempotency-Key` header is supplied; otherwise runs the handler once. |
| `readJsonBody` | Safe JSON body reader. Throws `validation_failed` on malformed input. |
| `extractProjectId` / `extractSourceId` / publishing extract helpers | Pull path params from the URL — works under both nested route files and catch-all flows. |

## story-projects.ts

Business logic for story projects. Exposed via `/api/v1/story-projects` routes; not `createServerFn`.

| Function | Description |
|----------|-------------|
| `createStoryProject` | Validates name/description/metadata and inserts the row. Returns the serialized envelope. |
| `listStoryProjects` | Cursor-paginated tenant-isolated list, newest first. |
| `getStoryProject` | Single read; throws `project_not_found` for cross-tenant or deleted rows. Returns `workspaceGate` and `tableReadReadiness` (the canon-cast preflight from `table-read-cast.ts`); both are pure reads. |
| `updateStoryProject` | Partial patch (name, description, metadata, archived, `seasonCadence`). `seasonCadence` (`"monthly"` or null) is merged into metadata by `setProjectSeasonCadence` (`episode-season.ts`) so the seasons list survives; it is refused beside a whole-`metadata` write. Serialized projects carry `seasonCadence`. |
| `deleteStoryProject` | Soft delete via `deletedAt`. |

## episode-season.ts

Which season a new episode Script goes into. Not `createServerFn`; called by the plan's table-read scaffold (`story-plan-table-read-adapter.ts`, so Story API jobs, season runs and recurring producers) and `startPlannedEpisode`.

| Function | Description |
|----------|-------------|
| `fileNewEpisodeInSeason` | A project with `metadata.seasonCadence: "monthly"`: the season named for the month (UTC) the episode's source originated (`readEpisodeOriginAt`: the first plan source's `metadata.originatedAt`, else the first source's `createdAt`, else the Script's own creation time), found by name or added after the last season under the project row's lock (`resolveMonthlySeason` in `lib/writing-lab-seasons.ts`; existing seasons are never renumbered). Any other project: the plan's intended season (`readPlanIntendedSeason`) registered on the project, else Season 1. Takes the caller's transaction when the Script is created in one. |
| `readPlanIntendedSeason` | The season a plan intends: its season run's, else its Series Bible entry's stored `seasonNumber`. Null otherwise (labels are never parsed). |
| `setEpisodeSeasonForUser` | THE ONE SEASON MOVE (seasons design §2.1). A planned episode (`episodeId`, or a `scriptId` the plan links) moves through the Bible save (`saveSeriesBibleForProject`), whose transaction moves its script too; a script no Bible entry plans moves alone under the project row lock. Adds the season to `metadata.seasons`. Called by `updateScriptForUser` (`PATCH /scripts/{id}` `seasonNumber`) and `updateWritingLabEpisodeSeason`. |
| `mirrorBibleEpisodeSeasons` | Inside every Bible save's transaction: the scripts of an entry whose `seasonNumber` changed move with it, and a season the Bible now plans in joins `metadata.seasons`. Nothing is read or written when nothing moved. |
| `readArtifactEpisodeDefaults` | The season and the description of the Script a publishable artifact performs (by its script version, script, or plan); the defaults for a release created without either. |
| `setProjectSeasonCadence` | Sets or clears `seasonCadence`, keeping every other metadata key. Shared by `updateStoryProject` (PATCH, MCP, CLI and Slug's `set_project_format`); the Seasons tab reads it with the arc (`getProjectArc`'s `seasonCadence`), read again when Slug finishes `set_project_format`. |

## table-read-cast.ts

The table-read cast precondition — canon-cast (owner decision 2026-09-29). See `docs/table-read.md`, "Headless table reads: canon cast (owner decision 2026-09-29)".

| Function | Description |
|----------|-------------|
| `assertTableReadCastReady` | Called by `createStoryJob` / resume / refine scheduling before the row, quote and reserve. Checks, in order: the job's own screenplay (a resumed job), an adopted `scriptUploadId`, the read a refine revises, else the project cast canon (+ request `voiceMap`, + narrator for a narrated read). Throws 409 `cast_precondition_failed` with `details.reason` / `details.missing`. |
| `pinTableReadCast` | Worker half, after the scaffold: refuses an uncast speaker (the job fails, releasing credits) or writes every member's voice, under every name, onto the read's `voiceMap`. |
| `adoptTableReadScript` | Creates the job-owned `TableReadJob` for an adopted finalized screenplay with its cast pinned; idempotent per story job. |
| `readProjectTableReadReadiness` | The canon half as `project.tableReadReadiness`. |
| `getProjectTableReadReadiness` | `createServerFn` (GET) for the project Cast page's **Table Read cast** line; owner-scoped. |

## story-project-documents.ts (readers)

Every script is born into a project (New Project, an upload, a plan), so there is no "create a project around this script" core left: reading a script never creates a project, and the only write that wraps one is `findOrCreateStoryProjectForScript` (row below). The rows here are the read side.

| Function | Method | Auth | Description |
|----------|--------|------|-------------|
| `getScriptSeriesBible` / `getSeriesBibleForScript` (story-project-documents.ts) | GET | Required | Read-only. Finds the project with `findLiveStoryProjectIdForScript`. For a script in no live project it returns `{ storyProjectId: null, document: null, generation: null, generationAvailable: false, coverageAvailable: false }` and writes nothing (`ScriptSeriesBibleResult` is a union on `storyProjectId`). The script's Overview then shows "This script isn't in a project yet" with the Create a project button, the only UI path that creates one. |
| `findLiveStoryProjectIdForScript` (story-project-documents.ts) | helper | Owner | The live project a script is in, or `null` when it is in none or only in a soft-deleted one. Never creates a project and never re-pins the script. |
| `findOrCreateStoryProjectForScript` (story-project-documents.ts) | helper | Owner | A write: re-pins the script to the live project keyed by its `implicitScriptId`, or creates one. Returns `{ storyProjectId, created }`; `created` is true only when this call made the project. Besides this core, only paths that already write still wrap a script this way: table-read finalize (`findOrCreateImplicitStoryContext`), a trailer render (`startTrailerRenderForUser`) and a dialogue storyboard render. |

`getScriptByRouteIds` (scripts.ts) treats a soft-deleted project as no project, and still finds a script addressed by its own route id in the project slot after it has joined a project. The Writers Lab detail loader then redirects that stale URL to the project's URL, so the Back button and sidebar links built before the script joined keep working.

## script-overrides.ts

The five episode overrides (`scripts.overrides.get` / `scripts.overrides.update`): `avatar` (`table_read_avatar_style`), `theater` (`table_read_theater_style`), `soundscape` (`table_read_soundscape_style`), `pitchDeck` (`pitch_deck_visual_direction`) and `locations` (`storyboard_visual_direction`) — `ScriptSidecar` rows on one script version, layered over the Series Bible's visual canon (the soundscape over the studio default). The list, the keys, the 8,000-character cap and the refusal sentences live once in `@sleeperhit/shared`'s `script-overrides.ts` (`SCRIPT_OVERRIDES`, `parseScriptOverridesPatch`). One core for all four surfaces: the Writers Lab editors (Cast: avatar; Table Read theater settings: theater + soundscape; Videos tab Series assets panel: pitch deck + locations), `GET/PATCH /api/v1/scripts/{scriptUploadId}/overrides`, the `get_script_overrides` / `update_script_overrides` MCP tools, and `sleeperhit script overrides get|update`. Free; saving renders nothing.

Video editing scope (`conversations.videoEditingScope.set`): `setVideoEditingScopeForUser` verifies the existing conversation belongs to the named trailer/deck and changes only its selected segment/chapter. UI Discuss controls, Slug `set_video_editing_scope`, `PATCH /api/v1/conversations/{conversationId}/video-editing-scope`, MCP `set_video_editing_scope`, and `sleeperhit conversations scope set <conversationId> --surface trailer|pitch-deck --job <id> (--scene <sceneIndex>|--whole)` share it. `sceneIndex: null` selects the whole cut. The response includes previous/current focus and current order; structural edits change positions, so read that order before selecting again. The write merges current metadata in a serializable transaction. Free, naturally idempotent, no new conversation, render or change to edit/spending consent. Story API scope: `story:write`.

On shared product-project decks, the author's bound thread and each currently authorized member's own `memberPitchDeckJobId` thread use the same focus core. A viewer may change their own focus but cannot change the plan. Add, remove and reorder clear positional focus on those governing threads in the same plan transaction, including member threads before the author has opened one; revoked, orphan and display-only `editsPitchDeckJobId` threads retain their unrelated metadata.


| Function | Method | Auth | Description |
|----------|--------|------|-------------|
| `readScriptOverridesForUser` | — | Owner; or an admin when `isAdmin` is passed | All five keys (null when unset) plus `soundscapeDefault`. Someone else's or a missing script throws `ScriptOverridesNotFoundError` (the API's 404 `script_not_found`). Only the Writers Lab passes `isAdmin` (from its session), so an admin may edit any script there; the Story API route passes none, so it is owner-only like every other Story API route — an admin's API key included. |
| `updateScriptOverridesForUser` | — | Owner; or an admin when `isAdmin` is passed | Validates the partial change first (`ScriptOverridesValidationError`, the API's 400 `validation_failed`): text sets (trimmed), null or blank text deletes the row, unknown keys and non-text or over-long values are refused. Applies every write in one `$transaction`, creates rows at the script's `versionNumber`, and answers the fresh read. |
| `getScriptSidecars` / `saveScriptSidecar` / `deleteScriptSidecar` (table-read.ts) | GET / POST / POST | Writers Lab access | The editors' server fns over the same core, keyed by sidecar name. All three answer the read above. |

## The writer's own episodes (scripts.ts, project-episodes.ts, writing-lab-projects.ts, script-export.ts)

What the Writers Lab does with a script the writer made — list a project's episodes, upload a screenplay or a new version, read one script and change its fields and season, delete one version, export it, name a season — reached the Story API, MCP and the CLI only through a plan-made artifact, so an episode made in the app was invisible headlessly (parity audit 2026-10-09). Each is now one core that the Writers Lab's server fns and a Story API route both call; MCP and the CLI reach the route through the shared builders in `@sleeperhit/shared`'s `writer-episodes.ts`, which also holds the limits and the one parser per input (`parseScriptMetadataPatch`, `parseScriptUploadBody`, `parseScriptVersionUploadBody`, `parseScriptExportOptions`, `parseProjectSeasonName`). A script id may be the script's own, the id in its Writers Lab URL, or any version's id (`findOwnedScriptId`, owner-only, calibration scripts excluded). All free.

| Core | Surfaces | Notes |
|------|----------|-------|
| `listProjectEpisodesForUser` (project-episodes.ts) | Overview Seasons tab + Videos page; `GET /api/v1/story-projects/{projectId}/episodes`; MCP `list_project_episodes`; `sleeperhit projects episodes` | Reads through `getWritingLabProjectForUser` (with `withCharacters: false`) and `getProjectVideoLibraryForUser`, so it lists exactly what the page lists, earlier attempts marked `supersededBy`. `season` narrows the episodes. |
| `createProjectSeasonForUser` (writing-lab-projects.ts) | "Add season" (`createWritingLabProjectSeason`); `POST /api/v1/story-projects/{projectId}/seasons`; MCP `create_project_season`; `sleeperhit projects seasons add` | 409 `project_precondition_failed` until `canPlan`. Written under the project row lock (`readProjectMetadataForUpdate`, project-metadata.ts). |
| `uploadScriptForUser` (scripts.ts) | Writers Lab upload (`uploadScript`); `POST /api/v1/scripts`; MCP `upload_script`; `sleeperhit script upload` | A script in a project (named with `projectId`, gated; else one of its own). A new screenplay can come from a file (`sourceId`/`fileUrl`, needs `projectId`): still being read answers `waiting`, one source makes its script once. The writer's upload cannot set `isCalibration`; the admin gold-standard upload calls `createInitialScriptUpload` itself. 403 `plan_limit_reached` past the page allowance. |
| `uploadScriptVersionForUser` (scripts.ts) | "Upload a new version" (`uploadScriptVersion`); `POST /api/v1/scripts/{scriptId}/versions`; MCP `upload_script_version`; `sleeperhit script versions add` | Copies the previous version's sidecars forward. 403 `plan_limit_reached` on the free plan or past the allowance. |
| `getScriptForUser` (scripts.ts) | `GET /api/v1/scripts/{scriptId}`; MCP `get_script`; `sleeperhit script show` | The `WriterScript` every write answers with (`lib/writer-scripts.ts`). |
| `updateScriptForUser` (scripts.ts) | Edit dialog (`updateScriptMetadata`), season picker (`updateWritingLabEpisodeSeason`), Slug `set_script_metadata`; `PATCH /api/v1/scripts/{scriptId}`; MCP `update_script`; `sleeperhit script update` | Title (and the latest version's), logline, genre, seasonNumber — no slug (an episode's address is its route id) and no `mediaType` (its shape is the project's, asked once at New Project; BL-4063). The logline is `Script.description` everywhere (D17; coverage keeps its own); genre is free text up to 80 characters with the shared suggestions. A season move goes through `setEpisodeSeasonForUser` (episode-season.ts): a planned episode's Bible entry moves with its script. |
| `readProjectArcForUser` (project-arc.ts) | `getProjectArc` (project-arc-fns.ts, `requireWritingLabWorkspaceAccess`); `GET /api/v1/story-projects/{projectId}/seasons`; MCP and Slug `list_project_seasons`; `sleeperhit projects seasons list` | The full arc: every season (name, arc, `summary` — the one plain line every surface shows, `projectArcSeasonSummary`: a story's arc; a product's arc only when already a short plain line, else read from its features, with `summaryNote` for what comes after its main path — production offer and run), every episode in it (the Bible's plan and every written script it does not plan, `source: script`; a product's features with availability, footage line and feature videos by `TrailerJob.bibleEpisodeId`), its state, and `next`, the one next step (`projectArcNextStep` in shared/project-arc.ts; a product's setup step comes from `readProductVideoPlanningReadiness`). A recurring show reads as its newest six months (`PROJECT_ARC_LOG_WINDOW`); `months` reads its newest N (the Seasons tab's earlier months, "Show 6 more", `?months=`, `--months`). `season` narrows the seasons. `getProjectArc` adds each script's route id and the project's `seasonCadence`. Read-only, free. |
| `updateWritingLabEpisodeSeason` (writing-lab-projects.ts) | The season picker | `{ scriptId, seasonNumber }` for a written episode, or `{ projectId, episodeId, seasonNumber }` for a planned one (a Bible write); both through `setEpisodeSeasonForUser`. |
| `deleteScriptVersionForUser` (scripts.ts) | "Only version N" (`deleteScriptVersion`); `DELETE /api/v1/scripts/{scriptId}/versions/{scriptUploadId}`; MCP `delete_script_version`; `sleeperhit script versions delete` | 409 `script_in_use` from `findScriptVersionDeleteBlockers` (D8: a live release whose read performs the version, a running job, a draft, a season-run slot, a deck or trailer anchored to it); 409 `script_state_invalid` for the only version. |
| `exportScriptForUser` (script-export.ts) | Export menu (`getFountainContent` / `getFinalDraftContent` / `exportScriptPdf`); `GET /api/v1/scripts/{scriptUploadId}/export`; MCP `export_script`; `sleeperhit script export` | Fountain / fdx as text, PDF base64. A script id exports its latest version. Owner-only; the Writers Lab passes `isAdmin` from its session. 409 `script_state_invalid` for a version not parsed yet. |

| `startPlannedEpisodeForUser` (writing-lab-projects.ts) | "Start" on the project and Series Bible pages (`startPlannedEpisode`); `POST /api/v1/story-projects/{projectId}/episodes` (`startPlannedEpisodeEnvelopeForUser`, project-episodes.ts); MCP `start_planned_episode`; `sleeperhit projects episodes start` | The Bible episode's empty draft, or the script that already covers it. 409 `project_precondition_failed` until `canStartEpisode`. Its season is filed by `fileNewEpisodeInSeason` (episode-season.ts). |
| `writeScriptContentForUser` (draft-scripts.ts) | Editor saves (`saveDraftContent` with `mode: 'draft'`, `saveVersionContent` with `mode: 'version'`); `PATCH /api/v1/scripts/{scriptUploadId}/content`; MCP `write_script_content`; `sleeperhit script write` | Headless callers leave `mode` out and the version's status decides. `expectedContent` is a compare-and-swap (409 `script_state_invalid`); season-run versions are refused. Only the newest version is written: an older one is refused (409 `script_state_invalid`, naming the newest version's id) — copy it into a draft to change its words. |
| `finishDraftForUser` (draft-scripts.ts) | Finalize (`upgradeToVersion`); `POST /api/v1/scripts/{scriptUploadId}/finish`; MCP `finish_draft`; `sleeperhit script finish` | Without `coverage`, `judgeCoverageCarryover`'s recommendation; `adopt` / `fresh` lock the draft as the dialog does. |
| `listCoverageReportVersionsForUser` (coverage.ts) | Coverage tab history (`getCoverageReportVersions`); `GET /api/v1/artifacts/{id}/coverage/versions` (`listArtifactCoverageVersions`); MCP `list_coverage_versions`; `sleeperhit coverage versions` | Every report across the lineage, newest first. |
| `getBlackListReportForUser` (blacklist.ts) | Coverage tab; `GET /api/v1/artifacts/{id}/coverage/blacklist` (`getArtifactBlackListAnalysis`); MCP `get_blacklist_analysis`; `sleeperhit coverage blacklist` | A report's analysis, default the latest complete report. |

The coverage and script reads under `/api/v1/artifacts/{artifactId}/` (`getArtifactScriptContent`, `requestArtifactCoverage`, `getArtifactCoverage` and the two above) resolve with `allowWriterScript` (story-jobs.ts): with no artifact of that id, the id may name a writer's own episode — a version, or a script by its id or Writers Lab URL id (its latest version). Music, SFX and performance stay artifact-only.

Drift fixed alongside: the project PATCH merges `metadata` and keeps `metadata.seasons` (`mergeProjectMetadata`, lib/project-metadata.ts — D7). D6, a plan's episode landing in the season it was written for, is `fileNewEpisodeInSeason` / `readPlanIntendedSeason` (episode-season.ts).

## table-read-prepare.ts

Preparing a cast episode's table read (`scripts.tableRead.prepare`, owner decision 2026-10-04): the TABLE_READ charge — priced by the screenplay's pages, `TABLE_READ_SHORT` / `TABLE_READ_LONG` via `resolveTableReadCreditQuote`, the price a Story API job adopting the screenplay reserves — and the media it buys, queued through `dispatchTableReadMediaStages` (`table-read-media-stages.ts`, the call the Story API's own read makes). One core for all four surfaces: Cast's **Continue to Table Read**, the setup pane's `POST /api/table-read/finalize`, `POST /api/v1/scripts/{scriptUploadId}/table-read/prepare`, the `prepare_table_read` MCP tool and `sleeperhit script table-read prepare`.

| Function | Method | Auth | Description |
|----------|--------|------|-------------|
| `prepareTableReadForScript` | — | Owner (the caller's `userId`) | Without `confirmed`: refusals (404 `script_not_found`, 409 `cast_precondition_failed` with `script_not_finalized` / `screenplay_coverage_before_cast` / `script_not_cast_ready`, the project gate, `voice_retired`), then the quote (`status: quoted`, `credits`, `balanceAfter`, `sufficient`, `note`); writes nothing. With `confirmed: true`: in one transaction under the version's `ScriptUpload` row lock it re-reads the read, fills the cast it lacks (creating a read for a canon-only episode), reserves the line under `table-read:prepare:{scriptUploadId}` (feature `TABLE_READ`) and marks the read `media_generating`; then queues the media and settles to `CONSUME_PROVISION` (`status: queued`). A version charged on any door answers `prepared`, a read whose media is in flight `preparing` — both free. Insufficient credits are a 402 that rolls the step back; a failed dispatch releases the line and restores the read. |
| `prepareTableRead` (table-read-prepare-actions.ts) | POST | Writers Lab access | Cast's Continue (`trigger: 'cast_continue'`) and the Table Read tab's Prepare control (`from: 'table_read'`, `trigger: 'table_read_tab'`): the core with `surface: 'app'`. |
| `isTableReadVersionPaid` / `assertTableReadVersionPaid` (table-read-paid.ts) | — | Server-only | Whether a version's table read is paid for on any door (held or spent line for the version, a paid refine that wrote it, a live Story API job that wrote or performs it). The assert refuses with 409 `table_read_not_prepared`; it gates both Pipecat session starts, the bot's page feed, the durable audio render, the creative pass's media, the defined-clips baseline, and both doors' charge-once. |
| `dispatchTableReadMediaStages` (table-read-media-stages.ts) | — | Server-only | Queues the sound effects, the defined-clip score and the adaptive soundtrack direction (`deferMusic` holds the music; `keepFinishedSoundtrack` keeps a complete soundtrack instead of composing a second). Returns `soundscapeError` rather than throwing: the Story API run logs it, the preparation releases its charge. |

## series-bible-interview.ts

The Writing Lab's New Project action opens a resumable, DB-sidecar-driven Series Bible interview. The model chooses one adaptive question and 2–5 project-specific options at a time; code validates structured turns, persists the full interview state and transcript, merges the Series Bible patch, and decides completion mechanically.

| Function | Method | Auth | Description |
|----------|--------|------|-------------|
| `startSeriesBibleInterview` | POST | Required | Creates a draft StoryProject and interview Conversation, invokes `B2B_STORY_API / series_bible_adaptive_interview` for the first structured question, and returns the conversation slug. |
| `getSeriesBibleInterview` | GET | Required | Restores the exact saved question, progress, completion state, and eventual script handoff for an owned interview. |
| `listSeriesBibleInterviewDrafts` | GET | Required | Lists the current user's resumable project-setup drafts for the Writing Lab library. |
| `answerSeriesBibleInterview` | POST | Required | Validates an option/custom response, persists the transcript and Series Bible patch, requests the next adaptive question, and creates the first Script only after every required Bible area is complete. |

The same Series Bible document is exposed to API, CLI, and MCP clients. Its serialized response includes `readiness.development` and `readiness.visuals`; headless clients can fill the document directly and use those fields as the shared completion contract. In the Writing Lab, that document is presented as read-only project canon; browser-side changes are proposed and approved through the persistent Slug conversation, while foundational coverage remains a separate analysis action.

## app-captures.ts / app-captures-fns.ts

App captures: real screen recordings of a web app, for a product trailer's beat to play. One core for the Story API (`/story-projects/{id}/captures`), the CLI (`sleeperhit capture`), MCP (`import_app_capture`, `list_app_captures`, `delete_app_capture`), Slug's `list_app_captures` and the project page's Captures panel (`app-captures-fns.ts`: `listProjectCapturesFn`, `createProjectCaptureFn`, `finalizeProjectCaptureFn`, `deleteProjectCaptureFn`, workspace access required). Free.

| Function | Description |
|----------|-------------|
| `createAppCaptureForUser` | Checks the body field by field (unknown fields refused by name), creates the row in `uploading`, and answers presigned PUTs (`presignR2ExactPut`, size and type signed) for the MP4 and, when declared, the recorder's `events.json` and checkpoint stills. |
| `finalizeAppCaptureForUser` | HEADs the objects (409 `capture_not_uploaded` while one is missing, until an hour past the URL's expiry, then `rejected`), streams the MP4 once to a temp file while hashing it, runs ffprobe twice (streams, then every packet's timestamp), and settles `ready` or `rejected` with `lib/app-capture/conform.ts`: H.264 yuv420p, constant 30 fps measured frame by frame, at most 1920×1080, bt709 tags, AAC when there is sound. A repeat answers the capture as it is. |
| `listAppCapturesForUser` / `getAppCaptureForUser` | Owner- and project-scoped reads; a ready capture carries a presigned `playbackUrl` (one hour). |
| `deleteAppCaptureForUser` | Deletes every object under the capture's prefix, then the row. |

## claims-ledger.ts / claims-ledger-fns.ts

The claims ledger (app-to-trailer round 2 S7b). One core (`readClaimsLedgerForUser`, `decideClaimForUser`) for the Story API (`GET /story-projects/{id}/claims-ledger`, `story:read`; `POST /story-projects/{id}/claims-ledger/{claimId}` with `{ decision: confirm|reject|reopen, note? }`, `story:write`; 404 `claim_not_found`, 409 `project_not_product`), the CLI (`sleeperhit app claims|claim`), MCP (`get_claims_ledger`, `decide_claim`), Slug's tools of the same names (product projects' chats only) and the claims list in the project page's "Learn from your site" panel (`claims-ledger-fns.ts`: `getProjectClaimsLedgerFn`, `decideProjectClaimFn`; workspace access required). Free.

## product-bible.ts / product-bible-evidence.ts

The product Bible writer (round 2 S7b). `generateSeriesBibleForProject` (story-project-documents.ts) writes a `kind: product` project's Bible with the `story_api_product_bible` sidecar from the feature inventory and the claims ledger, then saves it through the ordinary save; every surface that generates a Bible reaches it. `product-bible-evidence.ts` is pure: stable evidence keys (`src-…` from a page's address, `claim-…` from a claim's wording), the evidence the model is given, and `holdProductBibleToEvidence` (`availabilityBound`: every feature bounded by the inventory feature its id names or starts with, else the founder's record, else at most `planned`/`unknown`; `stripRejectedClaims`: a sentence stating a rejected claim is struck from every field but "do not claim"). The writer asks once more when a draft states a rejected claim, and the save holds the draft again under the claims ledger's lock (`lockAndReadClaimsLedger`, inside the save's transaction via `contentFromBase(base, db)`), recording `claimsLedgerVersion` (at save), `claimsLedgerVersionAtWrite`, `rejectedClaimsAskedAgain` and `rejectedClaimsStruck` in `generatedFrom`. Product coverage gets the same evidence index (`productCoverageEvidenceBlock` in series-bible-coverage-worker.ts).

## feature-inventory.ts / feature-inventory-fns.ts

"Learn from your site" (app-to-trailer round 2 S7a). One core (`startFeatureInventoryLearning`, `readFeatureInventoryForUser`) for the Story API (`GET|POST /story-projects/{id}/feature-inventory`, reads `source:read`, starting `source:write`; 409 `project_not_product` for a story project), the CLI (`sleeperhit app learn|inventory`), MCP (`learn_from_site`, `get_feature_inventory`), Slug's tools of the same names (mounted on product projects' chats only) and the project page's "Learn from your site" panel (`feature-inventory-fns.ts`: `getProjectFeatureInventoryFn`, which answers `isProduct: false` for a story project, and `learnProjectFromSiteFn`; workspace access required). The work runs on the `feature-inventory-learn` queue (`feature-inventory-worker.ts`; render-owned). Free, at most 10 runs a day per account, counted under an account-level advisory lock; a story project is refused with the shared F3 refusal (`project_not_product`, `details.work: 'learn_from_site'`).

`connectionId` is tri-state on UI, Story API, CLI, MCP and Slug: omitted chooses the project's verified app automatically, `null` reads public pages only, and a nonblank trimmed id (at most `FEATURE_INVENTORY_LIMITS.connectionId`, 128 characters) chooses that owned connection. An in-flight run is returned unchanged with its actual id/site/connection, without starting a second run. The exact existing `CHAT` app subject (`metadata.source: app-connections`, `projectId`) mounts the product-gated inventory/readiness tools, `attach_source` and only the existing `update_series_bible` write for feature-map confirmation/refinement. Shared reads provide the current Bible and sources. Saves retain their project permissions and relock confirmation. Slug's inventory digest supplies stable canonical `src-*` evidence ids plus human titles, without page addresses or private screenshot keys. The UI read separately projects `projectFeatures` from the saved product Bible, so a missing learned inventory does not erase saved features. Product app/Bible guidance is read live from `PRODUCT_APP_FEATURES_COACHING`, including when base behavior rows are customized.

## app-connections.ts / app-connections-fns.ts

Stored app logins for hosted capture (H3). One core for the Story API (`/story-projects/{id}/app-connections`, reads `source:read`, writes `app:connect`), the CLI (`sleeperhit app`), MCP (`list_app_connections`, `connect_app` (a link, never a secret), `create_app_connection` and `update_app_connection` (settings only; a secret is never an argument — the writer types it through `connect_app`'s link), `test_app_login`, `delete_app_connection`), Slug's `connect_app` (a link only) and the project page's "Apps to record" panel (`app-connections-fns.ts`: `listProjectAppConnectionsFn`, `getProjectAppConnectionFn`, `createProjectAppConnectionFn`, `updateProjectAppConnectionFn`, `saveProjectAppConnectionSecretFn`, `testProjectAppConnectionLoginFn`, `deleteProjectAppConnectionFn`, workspace access required). Every write is audited (`AppConnectionEvent`). Free.

| Function | Description |
|----------|-------------|
| `createAppConnectionForUser` | Metadata only (a secret field is refused by name): an https public origin, extra origins, the sign-in page on one of them, method, header kind, preconditions, blur list, retention. At most 20 per project (409 `app_connection_limit_reached`). |
| `updateAppConnectionForUser` | Locks the owned connection before reading its current fields and applying the patch. A change that adds an origin deletes every saved secret; a new method or header deletes the secrets the old one used. Each removal is audited. Concurrent label edits cannot restore a removed origin or carry newly saved credentials onto it. |
| `saveAppConnectionSecretForUser` | Checks one secret for its kind, seals it (`lib/app-connections/seal.ts`; 503 `app_connection_sealing_unavailable` without a key) and stores it as the connection's one row of that kind. Answers metadata. |
| `requestAppConnectionTestLoginForUser` | Readiness (every required secret saved, and a `workspace` when the kept sign-in steps ask for one) and an audit row, then the queued test. `findSignIn: true` ("Find it again"; API body `{ findSignIn }`, MCP `test_app_login.findSignIn`, CLI `app test --find-sign-in`, Slug `test_app_login.findSignIn`) finds the sign-in steps afresh first. |
| `deleteAppConnectionForUser` | Deletes the sealed secrets, then the connection, and writes the `deleted` audit row. |
| `listAppConnectionsForUser` / `getAppConnectionForUser` | Metadata, which secret kinds are saved, `connectUrl`; one connection adds its newest 20 audit rows. |

**Sign-in steps (BL-2623, `app-sign-in-recipe.ts` + `lib/sign-in-recipe/`).** A form sign-in follows a recipe: the sign-in page, the ONE form each step names, each field's role and label, the submit, reveal clicks, a cookie button. The test worker reads the newest kept version (`AppConnectionSignInRecipe`, last five kept, never on the connection row so the pass's `updatedAt` CAS holds); with none (or `findSignIn`), the scout surveys the SIGNED-OUT app on a runner (`/v1/sign-in-surveys`: a preview's headers are the only secret, opened `headersOnly`), and the `app_sign_in_scout` row's model (GPT-6.1 Sol, strict structured output, booked under PRODUCT, never counted) answers a flat draft that `toSignInRecipe` maps and `checkSignInRecipe` checks. `signInRecipeNeeds` checks it against the saved secret kinds before the login opens: a workspace field (`needs_field`, steps kept so every read asks for `workspace`), a field Sleeper cannot fill, single sign-on only (`sso_only`), a CAPTCHA or an emailed link stop with one sentence. A step the runner could not follow answers `recipe_failed` with the masked page; the `app_sign_in_repair` row mends the steps once, the login is opened again (audited) and the test dialled once more; a pass keeps the mended version (`source: repair`). Recordings and the signed-in walk call `ensureSignInRecipe` (scout first when none) and keep found steps once they signed in. Every read answers `connection.signInRecipe` (summary, steps' roles and labels, never a locator). Runners older than the steps (`capabilities.signInRecipe` missing) are sent nothing.

`app-connection-retention.ts` (`runAppConnectionRetention`, from the daily `request-log-retention-tick`): deletes audit rows past 90 days and connections past their own `retentionDays`.

The ChatGPT profile (`/mcp/chatgpt`) offers none of these yet: `list_app_connections` and `connect_app` (a link) join it in H5, with hosted capture's quote, record and status (decision 7). The two settings tools never join it: that profile is never granted `app:connect`.

## app-connection-test-preview.ts

`getAppConnectionTestPreviewForUser({ userId, projectId, connectionId, testId })` reads safe metadata for one exact current Test sign-in, without capturing a picture or starting another test. The Story API GET is `/story-projects/{projectId}/app-connections/{connectionId}/test-login/{testId}/preview` (`source:read`, project owner only, including owner OAuth). The CLI `sleeperhit app preview <connectionId> <testId> --project <projectId>`, MCP and Slug `get_app_login_test_preview` use the same shared metadata allowlist: step, state, sequence, freshness, picture availability and the canonical owner `viewUrl`. They never return an image, image URL, code or runner address; a replacement test is refused. CLI preview is metadata only and has no image-saving option.

The separate `/preview/frame` GET is for an owner session or personal API key; OAuth is refused. It returns a private no-store JPEG or 204 when no current safe frame exists. The open, visible Test sign-in dialog uses this channel and the existing exact-test secure code field. MCP and Slug do not expose the picture route. An arbitrary use of the owner's personal key cannot be identified as human or model use.

## tour-drafts.ts / tour-drafts-fns.ts

Tour drafts (app-to-tour round 2 S8) — one drafting run per product feature, written from the feature's Bible entry, its evidence pages and the app connection's address, checked by the recorder's own rule and saved as an editable P-095 tour marked `drafted`. The app is never opened and no frame is recorded. One core (`startTourDraftForUser`, `listTourDraftsForUser`, `getTourDraftForUser` in `tour-drafts.ts`) for the Story API (`GET|POST /story-projects/{id}/tours/drafts`, `GET …/tours/drafts/{runId}`; the static `drafts` segment sits beside the static `check`), the CLI (`sleeperhit tour draft|drafts|draft-status`), MCP (`draft_tour`, `list_tour_drafts`, `get_tour_draft`), Slug's tools of the same names (the shared reads on every writer chat) and the project page's Tours panel (`tour-drafts-fns.ts`, workspace access required). The work runs on the `tour-draft` queue (`tour-draft-worker.ts`); free, at most 50 runs a day per account. An expected refusal (a story project, a feature the Bible does not list, a planned feature, no app connection or an ambiguous one, the daily cap, an unknown runId) is answered as data by these fns and as a value by Slug's tools — `project_not_product`, `validation_failed`, `usage_limit_reached`, `tour_draft_not_found` — never as a thrown request failure.

| Function | Method | Auth | Description |
|----------|--------|------|-------------|
| `startProjectTourDraftFn` | POST | Writing Lab access | Starts one draft run for a feature (`featureId`, optional `connectionId`) and enqueues its drafting job; answers `{ started, refusal }`, where a refusal carries its code, sentence, and the features or connections to repair against. |
| `listProjectTourDraftsFn` | GET | Writing Lab access | The project's newest 50 draft runs, or one feature's (`featureId`), each with its status, its check errors and the tour it staged. |
| `getProjectTourDraftFn` | GET | Writing Lab access | One run (`runId`) with its staged tour once ready (`tour_draft_not_found` for an unknown run). |

## Bible reference reads

`getSeriesBibleForProject` returns a read-only `references` index beside canonical `content`, shared by the Writing Lab, Story API, CLI, MCP and Slug's `get_series_bible`. After project access is checked, it reads only that project's source labels and safe web addresses, inventory page names, claim statements and feature names. The index maps existing keys to human labels and links; `available` means the target resolves, not that a claim is confirmed or a feature is shipped. Deleted or missing evidence reads “Source unavailable”. No foreign row is looked up to resolve a cited key.

The Bible reader resolves exact known citation tokens in prose and keeps evidence names in closed disclosures. Unknown text, literal code and addresses are retained. Neither the read nor its presentation changes `content`, feature IDs, `evidenceIds`, the claims ledger or the saved Bible version. The index is not part of PATCH content. Product writers keep human names in prose and canonical keys in structured fields; the locked save still enforces availability and rejected claims.

## series-bible-generation.ts and series-bible-generate-worker.ts

Writing a project's Series Bible from its sources runs in the background (#1509 L5): ChatGPT drops a tool call after about 60 s, and a Bible takes a minute or two (110–140 s measured). The contract every surface shares lives in `@sleeperhit/shared` `series-bible-generation.ts` (`generateSeriesBibleRequest`, `getSeriesBibleRequest`, `SERIES_BIBLE_GENERATION_SENTENCE`).

| Function | Description |
|----------|-------------|
| `startSeriesBibleGeneration` | Records a `SeriesBibleGeneration` run and sends it to the `series-bible-generate` queue (`stately`, keyed by the run, so a re-send never doubles it), answering at once `{ status: 'generating', projectId, generation, alreadyRunning }`. Under a transaction-scoped advisory lock on the project, a second ask while a run is `generating` returns that run (`alreadyRunning: true`), never a second; a run past its stale mark (45 min) does not block a new one. A failed send marks the run failed with a reason for the writer. Called by `POST /api/v1/story-projects/{projectId}/series-bible`, the `generateProjectSeriesBible` server fn and Slug's `generate_series_bible`. |
| `readSeriesBibleGeneration` | The project's newest run as every Bible read reports it (`generation`: `generating`, `ready` with its `gateChange`, or `failed` with the writer-facing `error`), or null. A run past the stale mark reads as `failed` ("interrupted"). Read-only. |
| `getProjectSeriesBibleGeneration` (story-project-documents.ts) | The Bible page's poll while a Bible is being written: `{ document, generation }`. |
| `processSeriesBibleGenerateJob` | The worker: `generateSeriesBibleForProject` (one `generateObject` on `story_api_series_bible`, `httpTimeoutMs` 600 s, `maxRetries: 0`, booked on the ledger as `SERIES_BIBLE`; held to the content guard when the run's `contentGuard` says so, which the ChatGPT connector's header sets; an installment written without a status is `planned`) saved through `saveSeriesBibleForProject` (the same gate and episode-map rule as every save), then the run marked `ready` with the save's `gateChange` and version. A setup draft's saved Bible is then scored, as after every Slug save (`tryCompleteSetupAfterSave`, server/series-bible-setup-completion.ts). A failure with a retry left keeps the run `generating` and throws for pg-boss; the last attempt (or a refusal, never retried) marks it `failed` with a writer-facing reason, the provider's words kept in `failureDetail`. Every write is conditional on the run still `generating`; every state change is published on the live channel (`series_bible`). Budget: `series-bible-generate-budget.ts` (expiry 900 s, retryLimit 1). |

## render-quote-preparation.ts and render-quote-prepare-worker.ts

A deck's or trailer's render price is prepared in the background (#1509 L5). The plan approval compiles and voices every chapter or beat before it can price (measured 70 s to 7 minutes; the shot-list compiler alone 18 s a call at the median, 54 s at worst). `priceOrApprovePitchDeckPlanForUser` / `priceOrApproveTrailerPlanForUser` — what the Story API, the deck and trailer pages and Slug call — price only from a stored compile (`approve*PlanForUser` with `reuseOnly`, which throws `RenderCompileUnprepared` before anything is written); otherwise they start a preparation and answer `{ quotedOnly: true, status: 'preparing', progress, checkAgainSeconds, note }`, reserving nothing, confirmed or not. The same call asked again answers at once while it runs (before the gate and the screen run again), then with the price.

| Function | Description |
|----------|-------------|
| `startRenderQuotePreparation` | One `RenderQuotePreparation` row per job, under an advisory lock: the live preparation of this plan (`planKey`, a hash of what the compile reads plus `RENDER_COMPILE_VERSION`) is returned; a changed plan or one silent past the stale mark (30 min since its last heartbeat) is replaced, and the job sent to `render-quote-prepare`. |
| `processRenderQuotePrepareJob` | The worker: `preparePitchDeckRenderQuote` / `prepareTrailerRenderQuote` compile and store every unit's pending compile (the inline quote's own compile), writing progress as the row's heartbeat, then record `compiled`, `blocked` (the compile's block) or `refused`. A newer preparation supersedes a run: every write carries the run's `startedAt`. Budget: `render-quote-prepare-budget.ts` (expiry 9,000 s, retryLimit 1). |
| `unreportedRenderQuotePreparation` / `markRenderQuotePreparationReported` | A finished block, refusal or failure of this plan is answered once, as the inline quote answered it; the ask after it prepares again (as a re-asked quote always re-rolled a blocked chapter). |

## story-sources.ts

Business logic for story sources. Inline `TEXT` and `MARKDOWN` extract synchronously; `URL` and `PDF` enqueue a pg-boss extract job after synchronous SSRF + URL validation.

| Function | Description |
|----------|-------------|
| `createStorySource` | Validates type/content/uri/label/retention. Inline types hash (SHA-256), estimate tokens, and store preview (≤4000 chars). URL/PDF persist as `PENDING` and enqueue extraction. Honors `METADATA_ONLY` retention by skipping preview persistence. Best-effort enqueue of the digest pass after inline READY. Returns `{ source, deduplicated }`: with `producer` + `externalId` (both or neither; `integration:` reserved for curation), a live source already holding the item is returned with `deduplicated: true` before anything is stored, and a concurrent insert that loses on the `[projectId, producer, externalId]` unique index re-reads the winner. Dedupe never lands on a deleted or `FAILED` row: when the conflict has nothing alive behind it (the winner went `FAILED`, or a dead row kept the identity), the identity is freed and the insert retried once. A full-context producer item is stored with `digestStatus: SKIPPED` ("Used in full by its episode plan.") and never enqueued for a digest. `metadata.sourceProducer` is refused. `originatedAt` (ISO 8601, when the material was made; at most a day ahead) is stored as `metadata.originatedAt` and serialized as `originatedAt`. |
| `listStorySources` | Cursor-paginated tenant + project-scoped list. |
| `getStorySource` | Single read; tenant + project + soft-delete checks. |
| `deleteStorySource` | Soft delete; clears `extractedTextPreview` on the way out. |

`story-source-digest-worker.ts` resolves `story_api_source_digest` through the shared provider circuit. When every configured route is capacity- or policy-blocked, it returns the digest to `PENDING/provider_capacity_backoff` and schedules a bounded delayed retry, preserving transient `METADATA_ONLY` preview text in the queue payload. Only retry exhaustion becomes `FAILED/provider_capacity_blocked`.

A digest gets at most one immediate re-ask, for one of two reasons: the reply does not fit the schema, or its `summary`/`thesis` adds "AI", "artificial intelligence", "LLM", "language model" or "machine learning" wording that the source (label, URL and text) never uses (`addedMachineWording`). The second re-ask is told which words, and its answer replaces the first only when it is clean. A failed re-ask keeps the first answer. The row says the same thing in words (Rule 9: the summary is shown in the project's Sources panel). Existing digests keep their text until the source is digested again.

## story-plans.ts

Business logic for the StoryPlan abstraction.

| Function | Description |
|----------|-------------|
| `createStoryPlan` | Validates target + artifactRequests + sourceIds + autoApprove, rejecting auto-approval for every plan containing a table read. Computes a preliminary quote from the requested artifacts and source count. Persists as `PENDING` and enqueues the planner. Marks `FAILED/internal_error` if the queue is unreachable. |
| `listStoryPlans` | Cursor-paginated tenant + project-scoped list. |
| `getStoryPlan` | Single read; tenant + soft-delete checks; throws `story_plan_not_found`. |
| `resumeStoryPlan` | Replay-safe recovery under the same plan id. Repairs ordinary missing dispatch for `PENDING`, requeues stale `RUNNING`, and resets a pre-output `FAILED` plan to `PENDING`. A `PENDING/provider_capacity_backoff` plan already has a delayed core-owned retry and remains a no-op; completed/reviewable states are also no-ops and rejected/superseded plans stay terminal. |
| `approveStoryPlan` | Transitions `REQUIRES_APPROVAL` or `READY` → `APPROVED`. No-op on already-`APPROVED`. Throws `story_plan_state_invalid` for other states. |
| `rejectStoryPlan` | Transitions `REQUIRES_APPROVAL` or `READY` → `REJECTED`. Accepts an optional `reason` ≤2000 chars. |

## story-plan-worker.ts

Async planning worker — registered as `story-plan-generate`.

| Function | Description |
|----------|-------------|
| `processStoryPlanGenerateJob` | Loads the plan + source context (digest + preview by default; complete hash-verified retained text for sources with `metadata.sourceContextMode="full"`). Resolves DB sidecar `story_api_plan` + provider. Calls `generateObject` with the `storyPlanGenerationSchema` (the envelope minus `projectId`). Stamps `projectId` server-side and deterministically rescales generated table-read scene page budgets onto the page target (the brief's, else the blueprint's) before full-envelope validation: whole pages when every scene fits one, quarter pages when the outline has more scenes than pages. Only an outline with more scenes than quarter pages still fails closed. Computes a plan-aware quote and writes the plan plus a `StoryPlanRevision` row atomically. Eligible non-table plans transition directly to `APPROVED` when `autoApprove` is true; table reads and ordinary plans transition to `REQUIRES_APPROVAL`. Provider policy/capacity blocks traverse configured fallbacks, then park as nonterminal `PENDING/provider_capacity_backoff` with a bounded delayed retry; only exhaustion becomes `FAILED/provider_capacity_blocked`. |
| `enqueueStoryPlanGenerate` | Resets an ordinary plan to `PENDING` and sends a singleton queue job. Provider recovery can preserve PENDING state, carry an attempt number, and set `startAfter` for a unique delayed wake-up. |

## Quote balance (lib/credits-quote-balance.ts, story-plan-quote.ts)

Every priced quote says what it leaves. Beside its price it carries `balanceAfter` (the balance available now minus the quote; negative means the writer is that many credits short) and `sufficient` (the balance covers the whole quote, so a confirmed call can reserve it). The shape is `QuoteBalance` in `@sleeperhit/shared`, so the web app, the CLI and the MCP server read one type. The balance is `getCreditBalance(userId).balance`, floored at zero: the number Billing shows and `GET /api/v1/credits` returns. The line-item reserve refuses on the same number, so `sufficient: false` is the refusal a confirmed call would hit. Quoting reserves nothing, so the balance can still move before the confirmed call.

| Helper | Description |
|--------|-------------|
| `balanceAfterSpend(balance, credits)` | The arithmetic, with no I/O. A free quote (0 credits) is always sufficient. |
| `readQuoteBalance(userId, credits)` / `readQuoteBalancer(userId)` | One balance read. The balancer prices any number of quotes against that one reading. |
| `withQuoteBalance(userId, quote)` | Attaches the balance to a line-item quote (`totalCredits`). Used by `quotePitchDeckStageForUser` (behind `quotePitchDeckBuild`, `quotePitchDeckDraftQuote` and `quotePitchDeckFinalQuote`), the per-scene deck and trailer render quotes (`quotePitchDeckFrameRegenForUser`, `quotePitchDeckSceneClipForUser`, `quotePitchDeckSceneTextForUser`, `quoteTrailerFrameRegenForUser`, `quoteTrailerSceneClipForUser`, `quoteTrailerSceneTextForUser`) and the mood-board frame batch (`priceOrRenderMoodBoardFrames`). |
| `CREDIT_TOP_UP_PATH` / `creditTopUpUrl()` | Where a writer who is short tops up: Billing (`WRITING_LAB_UPGRADE_URL`, declared once). `creditTopUpUrl()` is the absolute link for MCP and CLI callers. |
| `affordabilityClause` / `priceSentence` / `quotedSpend` / `quotedSpendForHeadlessCaller` (lib/ai/spend-confirmation.ts) | The wording, said once: "42 credits — you'd have 4,955 left", or "42 credits, but you have 12 — 30 short; top up at <Billing> first." The chat (`quotedSpend`) and API/MCP/CLI (`quotedSpendForHeadlessCaller`) forms differ only in how the writer says go. |
| `quoteEpisodeCast` (cast-jobs.ts) | The episode cast quote, with the balance and the price sentence ahead of its note. The project cast (`generateCastForProject`) is free; its quote still carries the balance, for one shape. |
| `withStoryPlanQuoteBalances(reader, plans)` / `withStoryPlanQuoteBalance(reader, plan)` | `reader` is `{userId, scopes}`. Attaches the balance, and the price sentence as the first of `quote.notes`, against one balance read, only to a plan that is still a price before a spend: `READY` or `REQUIRES_APPROVAL`, or `APPROVED` with no live StoryJob (one batched `storyJob.findMany`; `FAILED` and `CANCELED` jobs hold nothing). An `APPROVED` plan with a job is returned unchanged, because `create_job` already reserved its whole quote. `PENDING` and `RUNNING` plans (still preliminary, and polled) and `REJECTED`, `SUPERSEDED` and `FAILED` plans skip the balance read. A caller without `credits:read` gets the stored price only, since `balanceAfter + total` is the balance. Every plan route (GET item, GET list, POST create, approve, reject, resume) calls it with its request context. |

A plan quote with no `balanceAfter` / `sufficient` means "not applicable" (the plan has a job, is not awaiting its spend, or the caller lacks `credits:read`), never "short". A job's own quote (`StoryJob.creditQuoteJson`) never carries the balance: it is already reserved.

## publishing.ts

Business logic for project-owned Publishing. Publishing promotes finalized durable assets from existing artifact flows; it does not create MP3/MP4 media.

| Function | Description |
|----------|-------------|
| `createPublishingSeries` / `listPublishingSeries` / `getPublishingSeries` / `updatePublishingSeries` | Manage project-owned series setup, cadence, feed metadata, prompt direction, and default RSS destination for audio series. |
| `listPublishingDestinations` / `upsertPublishingDestination` | Manage per-series destinations. RSS is first-party and connected without OAuth; later providers can use encrypted config and setup status. |
| `listPublishingProviderConfigs` / `upsertPublishingProviderConfig` | Manage user-level provider config and encrypted credential references for future OAuth/API-key-backed destinations. |
| `createPublishingRelease` | Promotes an already-finalized source `StoryArtifact` into a release, links back to StoryPlan/StoryJob/StoryArtifact, and binds the finalized MP3 into `PublishingReleaseAsset`. Without a `seasonNumber` or a `description` the release takes the season and notes of the episode Script the artifact performs (`readArtifactEpisodeDefaults`), so a release made by the Story API, MCP or the CLI carries the same episode notes in the feed as one made by the app; an explicit one wins. |
| `updatePublishingRelease` / `reconcilePublishingReleaseReadiness` | Update metadata and mark assets/targets stale when the source artifact fingerprint changes after promotion. |
| `schedulePublishingRelease` / `publishPublishingRelease` / `cancelPublishingRelease` | Require a ready durable asset for schedule/publish, enqueue scheduled or immediate publish work, and let the user cancel an unpublished release. |
| `completePublishingReleasePublish` | Worker entry point that marks RSS targets published, records unsupported provider targets as skipped/needs setup, and updates release/series status. |
| `renderPublishingRssFeed` | Builds the public podcast RSS XML for published RSS releases and ready MP3 assets. |

Synthetic-media disclosures (legal gap G8a). Every series has a `syntheticDisclosure` line (default "Voices and music in this show are synthetic.", `@sleeperhit/shared` `DEFAULT_SYNTHETIC_DISCLOSURE`). The writer can reword it on every surface (feed settings, Story API, CLI `--synthetic-disclosure`, MCP) but never empty it: `normalizeSyntheticDisclosure` refuses empty, blank or null with a 400. It is added where the words are shown, never stored into them, so nothing in a release payload can remove it: the end of the feed's description and of every episode's notes (`withDisclosure`), the feed's `<podcast:txt purpose="synthetic-media">`, the public show and episode pages, and the end of the YouTube description. YouTube uploads carry `status.containsSyntheticMedia: true` and TikTok direct posts `post_info.is_aigc: true`. The Slug composer always says "You're talking with an automated assistant."; the table-read toolbar says "The voices in this read are synthetic." until the read is live, and so do the public table-read share pages (theater, fullscreen and audio, playing or still rendering). HNR publishes through the same API, so its shows carry the default line with no change on its side (`every-feed-and-episode-says-the-show-is-synthetic`). A deck or trailer whose plan shows a long-dead historical figure records it (`historicalFigures` on `PitchDeckJob` / `TrailerJob`: set when the plan is approved, from what `screenLikenessOf` let through, and added to when a chapter or beat is rendered again), and the deck's shared page, the in-app deck video and the trailer review say "This is a synthetic depiction of a historical figure, not an archival record."

Create and update also hold the cadence rules (recurring series are audio-only; `cadenceArmProblem` on create, update and at fire time) and write the show's standing approval (`standingApprovalKeyId`; arming a cadence grants, revoking disarms). Over the Story API granting takes `userConfirmed: true`; the routes pass `caller: seriesWriteCallerFromApi(ctx, body)`.

## standing-approval.ts

The one place a per-call `userConfirmed` may be stood in for (owner decision 2026-09-29).

| Function | Description |
|----------|-------------|
| `standingApprovalCoversPlan` / `standingApprovalCoversRelease` | The use (`{ seriesId, keyId }`) of the show's grant that covers this caller (the bound API key, or the built-in runner for its own armed series) for a table-read plan / audio release created after the grant; never season runs, paused/archived shows, or OAuth connections. |
| `requireStandingApprovalForPlan` | The plan-approval boundary without `userConfirmed`: the covering use, or a 400 with a typed `details.reason`. `approveStoryPlan` records the result on the plan (`approvedVia`, `approvedUnderSeriesId`, `approvedByKeyId`). |
| `assertReleasePublishAuthorized` | The publish gate used by `POST /publishing-releases/{id}/publish` and the cadence worker: 404 for a release the caller does not own; `userConfirmed: true` or the release's own show's standing approval; a typed `details.reason` otherwise. Returns the `PublishCrossing` that `publishPublishingRelease` (whose `crossing` is required) records as `publishedVia` / `publishedByKeyId`. |
| `resolveStandingApprovalKey` / `planStandingApprovalWrite` | Validate a key to bind (`"self"` = the calling key) and decide what a series write does to the grant — a live grant keeps its date on re-arm or re-bind; a schedule-only grant ends with the schedule. |
| `seriesWriteCallerFromApi` | The Story API writer of a series (`{ keyId, userConfirmed }`), for the grant checks. |
| `releaseStandingApprovalsForKey` | Called by `revokeCustomerApiKey` after the ownership-checked revoke: ends the key's grants on unarmed shows, unbinds it (date intact) on armed ones. |

## credit-topup-sheet.ts

The Billing page's top-up sheet, save-card sheet and spending card. Buying credits and saving a card are app-only by design (the API, MCP, the CLI and Slug
hand out `billing.topupUrl` instead); the cap and auto top-up are the same setter every surface uses
(`updateCreditBillingSettings`, PATCH `/api/v1/credits/billing`). The charging core is `lib/stripe/topup-payments.ts`;
credits are granted only by the `payment_intent.succeeded` webhook. See `docs/credit-billing.md` and
`docs/stripe.md`, "Studio credit top-ups". The expected refusals come back as values, never thrown.

| Function | Method | Auth | Description |
|----------|--------|------|-------------|
| `getTopupSheetConfig` | GET | Required | `{ publishableKey, email, billing, allowsTopup, topupsHeld }`: the browser's Stripe key (null when the environment has none), the account's email (prefilled for Stripe Link), and the billing read (saved card, this month's spend against the cap, auto top-up). The Billing loader reads it. |
| `startTopupPayment` | POST | Required | `{ packKey, confirmationTokenId, raiseCapToUsd?, resumeAutoTopup? }`, after the browser's `elements.submit()` and `createConfirmationToken`. Reads the token from Stripe and takes only a card (Link's bank option: `refused`, `card_only`), writes the attempt under the cap and creates a card PaymentIntent with `setup_future_usage: 'off_session'`, which the browser confirms with the same token. Answers `{ status: 'requires_confirmation', topupPaymentId, clientSecret }`, or `spend_cap_reached` with `cap: { capUsd, spentUsd, packUsd, resetsAt, raiseToUsd }`, or `refused` (`topups_held`, `plan_without_topups`, `card_only`, `invalid`), or `card_error`. TEST_MODE settles it at once (`succeeded`). |
| `payTopupWithSavedCard` | POST | Required | `{ packKey, raiseCapToUsd?, resumeAutoTopup? }`: the saved card, confirmed on-session now; a `succeeded` answer is granted from it at once. The cap raise in `raiseCapToUsd` is written only when the payment is granted; `resumeAutoTopup` (sent only when the sheet showed the consent line) lets a granted payment turn a stopped auto top-up back on. `requires_action` carries the client secret for `stripe.handleNextAction`; a decline answers `card_error` with a writer's sentence; no saved card answers `refused` / `no_card`. |
| `cancelTopupPayment` | POST | Required | `{ topupPaymentId }`: the sheet closed (or a card failed) before paying; cancels the attempt's PaymentIntent so it stops counting against the cap. The caller's own attempts only. |
| `getTopupPaymentStatus` | GET | Required | `{ topupPaymentId }` → `{ status, granted, credits, failureCode }` or null. While the attempt is unsettled it asks Stripe (at most every 3 s) and grants a paid one from Stripe's own record, so no webhook is needed; the browser's word is never taken. The sheet polls it every second for up to 30 seconds. |
| `removeTopupCard` | POST | Required | Turns auto top-up off as the writer's choice (no reason, so no later top-up resumes it without the consent line), then detaches the saved card in Stripe and forgets it; answers `{ removed, billing }`. |
| `saveTopupCard` | POST | Required | `{ confirmationTokenId }`, after the save-card sheet's `elements.submit()` and `createConfirmationToken` (Payment Element in setup mode). Saves the top-up card for $0 (`lib/stripe/topup-card-setup.ts`): reads the token from Stripe and takes only a card, then creates and confirms a SetupIntent (`usage: 'off_session'`, cards only). Answers `{ status: 'saved', billing }`, `requires_action` (`setupIntentId`, `clientSecret` for `stripe.handleNextAction`), `processing`, `refused` (`topups_held`, `plan_without_topups`, `card_only`) or `card_error`. Never charges. TEST_MODE saves a stand-in Visa •4242. |
| `finishTopupCardSave` | POST | Required | `{ setupIntentId }`: after the bank's check in the sheet, reads the SetupIntent from Stripe (the caller's own only) and saves the card if it succeeded. Same answers as `saveTopupCard`. |
| `saveCreditBillingSettings` | POST | Required | `{ autoTopup?: { enabled, thresholdCredits?, packKey? }, spendCapUsd?: number \| null }`: Billing's Save, confirmed in one call. `{ ok: true, billing, note }`, or `{ ok: false, problem, message, details }` for a setting the account cannot honor (`credit_billing_invalid`). |

## credit-outage-notice.ts

`noteCreditsExhausted` — one `credits_exhausted` notification (bell + email) the first time credits run out in a billing cycle, deduped by its deterministic primary key (`credits_exhausted:<userId>:<cycle>`). Called, never awaited, through `lib/credit-outage-report.ts` from every refusal site — and only when the balance is below `CREDIT_OUTAGE_FLOOR` (one recurring audio episode), so a big one-off refusal from a funded account stays a plain 402.

## payment-failed-notice.ts

`notePaymentFailed` — one `payment_failed` notification (bell + email) per failed Stripe invoice, deduped by its deterministic primary key (`payment_failed:<invoiceId>`), so Stripe's retries of the same invoice and webhook redeliveries send nothing more. Called (awaited) by the `invoice.payment_failed` webhook handler in `lib/stripe/webhooks.ts`; a write failure throws so Stripe redelivers. The bell opens `/dashboard/billing`. The words are in code (`paymentFailedNoticeText`).

## coverage domains

Screenplay (`coverage.ts`), Series Bible (`series-bible-coverage.ts`), and
brand (`brand-coverage.ts`) operations persist through the same
`CoverageReport` lifecycle. Their public UI, API, CLI, and MCP entry points
remain rubric-specific because their inputs, payload schemas, and workflow
gates differ. `gold-standard.ts` exposes the shared curated exemplar corpus to
the admin calibration surface; `CoverageReportExemplar` records the ordered
exemplars used for each generated report.

Video coverage (`video-coverage.ts`, worker `video-coverage-worker.ts` on
`video-coverage-generate`) watches a deck's or trailer's stitched master and
gates the paid finish. One watch is too noisy to gate on near the bar, so the
rules in `lib/video-coverage-judging.ts` apply: a first watch within 1.5 of the
bar (its composed overall or a veto dimension) is followed by a second, two
watches on opposite sides of the bar on any gating score by a third, and the bar
reads the MEDIAN of each score. Each watch is its own queue execution (recorded
on the generating row under `reportJson.watching`, compare-and-set on its count,
then the next job is sent), so the expiry still nests one watch and a retry never
pays for a recorded watch. Watches beyond each report's first share a $0.50 cap
per master (a model with no price on file counts $0.25 a watch). An automatic
report first fingerprints the master (`video-coverage-fingerprint.ts`: MD5 of
every fifth decoded frame plus what each part was asked; SHA-256 of the decoded
sound on that picture) and carries the picture's five scores from the newest
earlier report on a frame-identical picture, judging only lip sync and audio sync
again — nothing at all when the sound is identical too. `report.judging` records
every watch, each score's distribution and median, what was carried from which
report and why it stopped; every surface shows it.

Script coverage (`coverage-worker.ts` on `coverage-generate`) gets the same
variance fix. One judgment is noisy near the bar (an HNRadio episode scored 8,
then 7, on identical pages), so the rules in `lib/script-coverage-judging.ts`
apply: a first judgment within `nearGateBand` (1) of the report's bar
(`gateThreshold`: the coverage gate's target, a season's minimum, else 8) is
judged again, up to `maxJudgments` (3), stopping once a majority sit on one side
of the bar; the score is the MEDIAN (an even count reads the lower middle, so it
is always a score one judgment gave, and the report shown is that judgment's).
A clear pass or fail stays one judgment. The numbers are data: `SiteSetting`
`script_coverage_judging` (`nearGateBand`, `maxJudgments`, `extraSpendCapUsd`
$0.50 per script, `unpricedJudgmentUsd` $0.25). Each judgment is its own queue
execution (recorded under `reportJson.judgingInProgress`, compare-and-set on its
count), ledgered like the first; one beyond the first carries
`step: coverage_extra_judgment`, which the monthly report count leaves out.
Before its first judgment a report fingerprints what it would ask
(`server/script-coverage-judging.ts`: the composed rubric, the script, the
Series Bible and plan, any focus, the schema and the model). A report an
orchestrator queued (`subjectMetadata.trigger: auto`: the story-job gate, its
first-pass report, a season run) starts from the newest earlier report with that
fingerprint, so the gate's report and the first-pass report on the same pages
share one set of judgments; a writer's request (the app, the Story API, MCP, the
CLI) is judged afresh, as video coverage re-watches a manual request.
`reportJson.judging` records every judgment, the median, which judgment gave it
and why, and why judging stopped; each judgment's whole report is kept under
`subjectMetadata.coverageJudgments`. The Story API read (`overallScore`,
`gateThreshold`, `gatePassed`, `judging`, `summary`), MCP `get_coverage`, the
CLI, the Coverage tab and Slug's `get_coverage` all say "the median of N
judgments"; the story-job gate's progress and `coverageDrafts` carry it too.
Near the bar a script is never scored on one judgment (the owner's rule,
2026-10-04). A further judgment that fails for good is queued again as a fresh
job (`judgingInProgress.requeues`, at most
`SCRIPT_COVERAGE_FAILED_JUDGMENT_REQUEUES` (2), within the extra-judgment spend
cap). A script, rubric or model that changed between judgments near the bar
can't be judged further. When the median still cannot be completed, the report
lands `failed` with `subjectMetadata.judgingIncomplete` (`judgments`, `scores`,
`maxJudgments`, `passMark`): neither a pass nor a fail. Every surface says
"Coverage couldn't finish judging — Score again": the read's
`judgingIncomplete` and `summary`, MCP `get_coverage`, the CLI, the app's toast
and Slug's `failureReason`. The story-job gate scores such a draft again, once
(orchestration key `…:rescore`), before reading it as a null score. A null score
sends the draft to a paid rewrite, and a good draft must not be rewritten
because judging could not finish.

Plan coverage (`storyboard-coverage-worker.ts`) is never decided on one judgment
near the bar (the owner's rule, 2026-10-04). A FURTHER judgment that fails for
good is queued again as a fresh job (`judgingInProgress.requeues`, at most
`STORYBOARD_COVERAGE_FAILED_JUDGMENT_REQUEUES` (2), within the revision's
extra-judgment spend cap). If it still cannot run, the report lands `failed`
with `subjectMetadata.judgingIncomplete` and `judging.stoppedBecause:
judging_incomplete`: neither a pass nor a veto. Every surface says "Plan
coverage couldn't finish judging — Score again" (`judgingIncomplete` on the
read). A judgment's blind first-viewer read is kept on `judgingInProgress.blindRead`
(bound to that judgment and to the blind rubric's hash), so a queue retry of
the judgment never asks for it again. Its shape failures land on
`subjectMetadata.viewerTakeawayFailures` beside the judge's
`structuredOutputFailure`. Karamazov v2's report cmuvkcruq (2026-10-05) showed
both: claude-sonnet-5 failed the blind read's shape on 14 of 22 calls, those
failures spent judgment 2's attempts, and a Central-question veto was decided
on one judgment.

## publishing-ui.ts

CreateServerFn wrappers for the Writers Lab Publishing tab.

| Function | Method | Auth | Description |
|----------|--------|------|-------------|
| `getPublishingWorkspace` | GET | Required | Loads series, destinations, releases, source artifact readiness, feed URL, and the owner's API keys a standing approval can be granted to (`standingApprovalKeys`, never the secret) for the active project. `feed` is the Releases tab's "In your feed" list: audio releases, newest first, with `total` (the whole feed, not the page), `matching`, `query` and `nextCursor`. Pass `feedQuery` / `feedRows` to reload it at the filter and depth the writer left it. |
| `listProjectPublishingFeed` | GET | Required | Reads the "In your feed" list from a `cursor` (Show older episodes, 100 at a time) or for a `query` (a title fragment, or an episode number as `13`, `#13`, `ep 13`). Pages through `listPublishingReleases`, the same cursor the Story API, CLI and MCP use. |
| `createProjectPublishingSeries` / `updateProjectPublishingSeries` | POST | Required | Create or update a publishing series from Writers Lab; update also grants (`standingApprovalKeyId`) or revokes (`null`) the show's standing approval. |
| `upsertProjectPublishingDestination` | POST | Required | Connect or update a destination for the active series. |
| `promotePublishingArtifact` | POST | Required | Promotes the finalized Table Read source artifact into a Publishing release. |
| `scheduleProjectPublishingRelease` / `publishProjectPublishingRelease` / `cancelProjectPublishingRelease` | POST | Required | Schedule, publish, or cancel a release. |

## show-ui.ts

CreateServerFn wrappers for the Publishing tab's **Show** tab (`components/writing-lab/show-panel.tsx`): a read-out of a show that is set up by talking to Slug (BL-1083), plus its direct acts. Each calls the same core as the Story API, MCP, the CLI and Slug (`server/show-api.ts`); the signed-in owner pressing the button is the confirmation (no `caller`).

| Function | Method | Auth | Description |
|----------|--------|------|-------------|
| `getProjectShow` | GET | Required | The show read (`getShow`): state, producer, setup lines, readiness, price and suggested caps, next run, last run, preview and the one next step. |
| `listProjectShowRuns` | GET | Required | The show's recent runs with their episodes, and the previews and extras asked for (`listShowRuns`). |
| `setProjectShowState` | POST | Required | On (also resumes), paused or off (`setShowState`). |
| `publishProjectShowEpisode` / `dropProjectShowEpisode` | POST | Required | Publish a held or preview episode, or never air one (`publishShowEpisode`, `dropShowEpisode`). |

## shows-running-fns.ts

| Function | Method | Auth | Description |
|----------|--------|------|-------------|
| `getShowsRunningFn` / `setShowsRunningFn` | GET / POST | Admin | Admin → Settings' "Shows running" row: the platform-wide stop for unattended show spend (`server/shows-running.ts`). |

## story-season-runs-ui.ts

CreateServerFn wrappers for a season run in the app: the Overview's Seasons tab, where a season is produced and its run reviewed inline (`components/writing-lab/seasons/project-seasons-tab.tsx`, `season-production-control.tsx`, `season-run-rail.tsx`, `season-run-next-step.tsx`, `season-episode-brief.tsx`), and a product season's videos on the Publishing page (`publishing-tab.tsx`). Each calls the core the Story API's season-run routes call (`story-season-runs.ts`); the signed-in writer pressing the button is the `userConfirmed: true` an agent collects. Slug's `create_season_run` and `map_season_episode` (`lib/ai/season-run-chat-tools.ts`, the Series Bible chat, paid workspaces) call the same plain functions.

| Function | Method | Auth | Description |
|----------|--------|------|-------------|
| `produceSeason` | POST | Required | "Produce this season" (owner D2, 2026-10-11) through `produceSeasonForUser`: a season already in production answers with its run (`created: false`); a recurring show is refused; the project must be ready for episodes (`canStartEpisode`); then `createStorySeasonRun` with the INTERNAL audience. Free: each episode's plan is still approved, priced, before anything is made. |
| `listProjectStorySeasonRuns` | GET | Required | The project's season runs in full, read by a season's inline review on the Seasons tab and by a product's Publishing page, each with the priced episode plan it waits on or the price of a revision. It shows every season in production. |
| `approveSeasonMap` / `reopenSeasonMap` | POST | Required | "Approve season map" (confirms the open briefs on screen, then approves that map version) and "Ask for changes". |
| `decideSeasonEpisodePlan` / `advanceSeasonRun` | POST | Required | Approve or reject the episode plan the run waits on; continue, plan again, retry coverage, revise or publish. |

A run maps only its season's episodes: the Series Bible entries the season map files under that season (`joinPlannedEpisodes`: a written episode in its script's season, an unwritten one in the season its label names, else 1). A season the Bible maps nothing to is refused, naming the seasons it maps. The plan reads each must-know line as its own `mustKnowBeforeWriting` item (`seasonEpisodeMustKnow`).

## story-sources-ui.ts

CreateServerFn wrappers for a project's Sources in the app: the Sources panel on the project page (`components/writing-lab/project-sources-panel.tsx`, mounted by `routes/dashboard/projects.$projectId.tsx`). Attaching a source was API, CLI and MCP only (dogfood gap, 2026-10-11). Each calls the core the Story API's source routes call (`story-sources.ts`: `createStorySource`, `listStorySources`, `deleteStorySource`), so a link is fetched by the one fetch path (`fetchUrlSource`, with its address checks) in the extract worker. Slug's `attach_source` (`lib/ai/project-source-chat-tools.ts`, the Series Bible and project-setup chats) calls `attachProjectSourceForUser`; the shared read `list_sources` (`lib/ai/shared-read-tools.ts`) calls `listStorySources` / `getStorySource`. The writer's view of a source (kind, state, failure reason in their words, summary) is `lib/project-sources.ts`.

| Function | Method | Auth | Description |
|----------|--------|------|-------------|
| `listProjectSources` | GET | Required | One page (50, newest first, `cursor` for the next) of the project's sources as the panel shows them (`readProjectSourcesForUser`): `processing` while a link is fetched or a summary made, `ready`, or `failed` with the reason. |
| `attachProjectSource` | POST | Required | "Add a source" through `attachProjectSourceForUser`: a link (`url`; `pdf: true`, or a link ending in `.pdf`, is sent as a PDF) or a note (`note`, a text source), with an optional `title`. One at a time; never sends the integrations' `producer` / `externalId`. PDFs are attached by link: the API has no file upload. Free. |
| `removeProjectSource` | POST | Required | Remove a source through `deleteStorySource` (soft delete that clears its text, summary, address and label). |

## publishing-worker.ts

Async publishing worker registered as `publishing-release-publish`.

| Function | Description |
|----------|-------------|
| `enqueuePublishingReleasePublish` | Enqueues an idempotent publish job, optionally delayed until `scheduledAt`. |
| `registerPublishingWorkers` | Registers the worker with pg-boss. |

## /api/v1 routes

| Route | Method | Auth | Description |
|-------|--------|------|-------------|
| `/api/v1/capabilities` | GET | Customer API key, `story:read` | Returns current Story API capabilities, live source/artifact support, planned follow-ons, and docs discovery links. |
| `/api/v1/credits` | GET | Customer API key, `credits:read` | Returns the API key owner's Studio Credit balance summary, plus `costs`: the same price list the Billing page shows (`listStudioCreditCosts`). |
| `/api/v1/account/export` | GET / POST | `account:read` (POST idempotent) | "Download my data": status with a fresh signed link, or start building the archive (`account-export.ts`). |
| `/api/v1/account/deletion` | GET / POST / DELETE | `account:read` / `account:write` (POST idempotent) | Where the account's deletion stands; schedule it (needs `confirmEmail`); cancel it (`account-deletion.ts`). |
| `/api/v1/openapi.json` | GET | Public | OpenAPI 3.1 spec for the live endpoints. |
| `/api/v1/examples` | GET | Public | Machine-readable cURL + Node recipes. |
| `/api/v1/story-projects` | GET / POST | `story:read` / `story:write` | List or create story project workspaces. |
| `/api/v1/story-projects/from-script` | POST | `story:write`, idempotent | Put a script that belongs to no project into a new one (`script-project.ts`). Body: exactly one of `scriptId` / `scriptUploadId`. A script already in a project returns that project with `created: false`. Free. |
| `/api/v1/story-projects/{projectId}` | GET / PATCH / DELETE | `story:read` / `story:write` | Read, update, delete a single project. PATCH merges `metadata` (null removes a key; `metadata.seasons` is kept and refused). DELETE hides it, keeps it restorable for 30 days, then purges it with its episodes (`project-deletion.ts`). |
| `/api/v1/story-projects/deleted` | GET | `story:read` | Deleted projects that can still be restored. |
| `/api/v1/story-projects/{projectId}/restore` | POST | `story:write`, idempotent | Restore a deleted project within its window (409 `project_not_restorable` after). |
| `/api/v1/story-projects/{projectId}/episodes` | GET | `story:read` | A project's seasons, episodes (made in the app included), versions, planned episodes and videos (`listProjectEpisodesForUser`). `?season=N`. Free. |
| `/api/v1/story-projects/{projectId}/seasons` | POST | `story:write`, idempotent | Name a new season (`createProjectSeasonForUser`). Free. |
| `/api/v1/scripts` | POST | `story:write`, idempotent | Upload a screenplay as a new script in no project (`uploadScriptForUser`). Free. |
| `/api/v1/scripts/{scriptUploadId}` | GET / PATCH / DELETE | `story:read` / `story:write` | Read one script; change its title, logline, genre or season (`updateScriptForUser`; its shape is the project's, so no `mediaType`); delete the whole script (`deleteScriptForUser`, `confirmed: true`). Free. |
| `/api/v1/scripts/{scriptUploadId}/versions` | POST | `story:write`, idempotent | Upload the next version (`uploadScriptVersionForUser`). Free. |
| `/api/v1/scripts/{scriptUploadId}/versions/{versionId}` | DELETE | `story:write` | Delete one version (`deleteScriptVersionForUser`, `confirmed: true`). Free. |
| `/api/v1/scripts/{scriptUploadId}/export` | GET | `story:read` | Export Fountain, Final Draft or PDF (`exportScriptForUser`). Free. |
| `/api/v1/scripts/{scriptUploadId}/overrides` | GET / PATCH | `story:read` / `story:write` | Read or partially change the five episode overrides on a script version (`script-overrides.ts`); null or blank text clears one. Free, no Idempotency-Key. |
| `/api/v1/scripts/{scriptUploadId}/table-read/prepare` | POST | `artifact:publish`, idempotent | Prepare a cast episode's table read (`table-read-prepare.ts`): without `confirmed` the price; with `confirmed: true` the TABLE_READ charge, once per screenplay version, and its sound effects and music. `prepared` / `preparing` cost nothing. |
| `/api/v1/story-projects/{projectId}/season-runs` | GET / POST | `story:read` / `story:write` + `publishing:write` | List runs or create a persistent conversational season map from the current Series Bible. |
| `/api/v1/season-runs/{runId}` | GET | `story:read` | Resume a run with its episode states, coverage audit, progress, and next action. |
| `/api/v1/season-runs/{runId}/episodes/{episodeId}` | PATCH | `story:write` | Save one discussed episode brief while the map remains unlocked. |
| `/api/v1/season-runs/{runId}/approve` | POST | `story:write` | Human approval that locks the exact fully confirmed `expectedMapVersion`; stale reviewed maps are rejected. |
| `/api/v1/season-runs/{runId}/advance` | POST | `story:write` + `artifact:publish` + `publishing:write` + `publishing:publish` | Advance one durable step; stops for plan approval, replacement planning approval, same-report failed coverage retry approval, coverage revision approval, and RSS publication approval. |
| `/api/v1/story-projects/{projectId}/sources` | GET / POST | `source:read` / `source:write` | List or attach text/markdown/url/pdf sources. |
| `/api/v1/story-projects/{projectId}/sources/{sourceId}` | GET / DELETE | `source:read` / `source:write` | Read or soft-delete a single source. |
| `/api/v1/story-projects/{projectId}/story-plans` | GET / POST | `story:read` / `story:write` | List or create StoryPlans. |
| `/api/v1/story-plans/{planId}` | GET | `story:read` | Read a single plan. Its `quote` carries `balanceAfter` / `sufficient` only while the plan awaits its spend and the caller also holds `credits:read`; the same applies to every plan route (see "Quote balance"). |
| `/api/v1/story-plans/{planId}/resume` | POST | `story:write` | Replay-safe same-plan recovery for missing dispatch, stale workers, or failures before generated output exists. |
| `/api/v1/story-plans/{planId}/approve` | POST | `story:write` | Transition `REQUIRES_APPROVAL` or `READY` → `APPROVED`; requires `{ userConfirmed: true }` after explicit user review — except for the one API key holding a show's standing approval, for a table-read plan in that show's project created after the grant (`server/standing-approval.ts`). |
| `/api/v1/story-plans/{planId}/reject` | POST | `story:write` | Transition `REQUIRES_APPROVAL` or `READY` → `REJECTED`. |
| `/api/v1/story-jobs/{jobId}/resume` | POST | `story:write` | Resume terminal legacy/pre-worker generation failures or retry failed artifact finalization from the last durable boundary. Active provider backoff is already scheduled by the worker and is a no-op; other unsafe started failures remain fail-closed. |
| `/api/v1/artifacts/{artifactId}/finalize/retry` | POST | `artifact:publish` | Retry a failed table-read audio finalize without regenerating the performance. |
| `/api/v1/story-projects/{projectId}/publishing-series` | GET / POST | `publishing:read` / `publishing:write` | List or create project-owned publishing series. |
| `/api/v1/publishing-series/{seriesId}` | GET / PATCH | `publishing:read` / `publishing:write` | Read or update series setup and cadence. |
| `/api/v1/publishing-series/{seriesId}/destinations` | GET / POST | `publishing:read` / `publishing:connect` | List or upsert series destinations. |
| `/api/v1/publishing-provider-configs` | GET / POST | `publishing:connect` | List or upsert provider config and encrypted credential references. |
| `/api/v1/publishing-series/{seriesId}/releases` | GET / POST | `publishing:read` / `publishing:write` | List releases or promote a finalized source artifact into a release. |
| `/api/v1/publishing-releases/{releaseId}` | GET / PATCH | `publishing:read` / `publishing:write` | Read or update release metadata. |
| `/api/v1/publishing-releases/{releaseId}/schedule` | POST | `publishing:publish` | Schedule a ready release. |
| `/api/v1/publishing-releases/{releaseId}/publish` | POST | `publishing:publish` | Queue immediate publishing for a ready release. |
| `/api/v1/publishing-releases/{releaseId}/cancel` | POST | `publishing:publish` | Cancel an unpublished release. |
| `/api/v1/publishing-releases/{releaseId}/targets` | GET | `publishing:read` | Read provider target status. |
| `/api/v1/publishing-series/{seriesId}/analytics` | GET | `publishing:analytics` | Read normalized publishing analytics snapshots. |
| `/api/v1/publishing-feeds/{seriesSlug}.xml` | GET | Public | Public first-party podcast RSS feed. |
| `/llms.txt` and `/llms-full.txt` | GET | Public | Agent-readable doc surfaces. |

## writer-profile.ts

| Function | Method | Auth | Description |
|----------|--------|------|-------------|
| `getWriterProfileEditorData` | GET | Required | Lists the current user’s scripts and latest published pitch-deck metadata for public-profile curation. |
| `getPublicWriterProfileBySlug` | GET | Public | Loads a published writer profile with featured work and public pitch-deck links for a root-level slug. |

## contact.ts

| Function | Method | Auth | Description |
|----------|--------|------|-------------|
| `submitContactForm` | POST | Public | Saves to `ContactSubmission` (`name` = "First Last"; a `subject` is the message's bracketed first line, e.g. `[Studio Project]`), emails `CONTACT_NOTIFY_EMAIL` via Resend with first and last name. Input: `{firstName, lastName, email, message, subject?: 'studio-project'}`. |
| `submitSupportRequest` | POST | Public | Saves to `ContactSubmission`, emails `help@sleeperhit.studio` via Resend, and prefixes the stored message with `[Support Request]`. Input: `{name, email, message}`. |

## newsletter.ts

| Function | Method | Auth | Description |
|----------|--------|------|-------------|
| `subscribeNewsletter` | POST | Public | Creates `NewsletterSubscriber`. Silently ignores duplicate emails (catches Prisma P2002). |

## comments.ts

| Function | Method | Auth | Description |
|----------|--------|------|-------------|
| `getComments` | GET | Public | Returns all comments for a course with user info. Input: `{courseId}`. |
| `createComment` | POST | Required | Creates a comment. Input: `{courseId, content}` (max 2000 chars). |

## settings.ts

### Public Functions

| Function | Description |
|----------|-------------|
| `getSocialLinks` | Returns social media URLs from SiteSetting (facebook, youtube, instagram, tiktok). Used in footer. |

### Admin-Only Functions

All require `role === ADMIN` (enforced by `requireAdmin()`).

| Function | Description |
|----------|-------------|
| `getAdminSettings` | Returns all SiteSetting records as key-value map. |
| `updateAdminSettings` | Upserts/deletes SiteSetting records. |
| `getAdminUsers` | Lists all users with role, planTier, planStatus, and `paysThroughStripe` (a subscription Stripe still bills). |
| `updateUserRole` | Toggles user between MEMBER and ADMIN. |
| `resetUserPassword` | Hashes and updates credential password. |
| `createAdminUser` | `mode: 'password'` creates the user with a hashed password; `mode: 'invite'` creates it with no password and emails a link to set one, naming the admin (an invite that could not be sent makes no account and returns why). Optional planTier; the address is stored lower-case. |
| `deleteAdminUser` | Deletes user (cannot delete own account). |
| `getNewsletterSubscribers` | Returns all newsletter subscribers. |
| `getContactSubmissions` | Returns all contact/support submissions. |
| `getAdminQuotes` | Returns all RotatingQuote records. |
| `toggleQuoteActive` | Sets RotatingQuote active to true/false. Input: `{id, active}`. |
| `getStripeCoupons` | Lists all Stripe promotion codes (via Stripe API). |
| `createStripeCoupon` | Creates Stripe coupon + promotion code. |
| `deactivateStripeCoupon` | Deactivates a Stripe promotion code. |
| `savePricingOverrides` | Saves admin-managed Stripe price overrides. Plan capability copy is read from the shared pricing registry. |
| `getBetaInvites` | The beta invites (`{inviteCode, couponId, planSlug, emails, credits}`) and `grants`: per invite code, each listed email's grant status (`granted` with `credits` and `grantedAt`, `next-sign-in` for an account not yet granted, `no-account`), read from the ledger by key `beta-invite:<code>:<userId>` (`readBetaInviteGrantStatus`, `server/beta-invite-credits.ts`). |
| `saveBetaInvites` | `{invites: [{inviteCode, couponId, planSlug, emails, credits (whole number 0–10,000, default 0)}]}`: writes the `stripe_beta_invites` setting (deleted when no list has an email). `credits` is granted once per listed writer at sign-in; a raise never tops up a past grant. |
| `setUserPlan` | Sets or clears a writer's app-managed plan: `{userId, tier \| null}` writes `planTier` with `planStatus` ACTIVE, or both null (`appManagedPlanColumns`). Refused with "This writer pays through Stripe; change the plan in Stripe." for a subscription Stripe still bills (ACTIVE, TRIALING, PAST_DUE, PAUSED). The next balance read mints the plan's bucket. Core: `server/settings-plan-credits.ts`. |
| `getAdminUserCredits` | `{userId}`: the writer's plan in effect, whether Stripe bills them, whether a payment dispute holds their top-ups (`topupHold: {since, reason} \| null`), their balance by bucket (`getCreditBalance`, which mints a due app-managed bucket) and their latest 50 ledger rows with the Billing page's labels (`studioCreditTransactionLabel`), an adjustment's reason and granting admin. |
| `grantUserCredits` | `{userId, amount (1–10,000), reason (required, ≤200), requestId (uuid)}`: a never-expiring `ADJUSTMENT` through `grantCredits` with metadata `{reason, actorUserId, requestId}`, idempotent on `requestId` (ledger row key `admin-grant:<requestId>`, also the Stripe idempotency key). Mirrored to Stripe as promotional when the writer has a Stripe customer; otherwise ledger-only under `admin-adjustment:<requestId>`. Returns `{alreadyGranted, amount, balance}`. No debits. |
| `clearUserTopupHold` | `{userId}`: lets a writer buy top-ups again after a payment dispute held them (`User.creditTopupHoldAt`/`creditTopupHoldReason` back to null, `clearCreditTopupHold`). Returns `{cleared}`: false when nothing was held. The clearing admin is logged. |

## admin-integrations.ts

Registry for the products an organization can connect. Admin-only, enforced by a local `requireAdmin()` copy (same shape as `settings.ts` / `ai-config.ts`).

| Function | Method | Auth | Description |
|----------|--------|------|-------------|
| `listAdminIntegrationProviders` | GET | Admin | Every catalogue product (registered or not) plus any row added outside the catalogue: enablement, client id, masked client secret, endpoint/scope overrides and their defaults, connected-organization count, request count, the owner setup guide (`ownerGuide`), owner fields with their values, `lastVerifiedAt`, and the reasons a product is not yet connectable. Also returns `redirectUri`, both `redirectUris` and `otherRequests`. Never returns the client secret, its ciphertext, or `credentialId`. |
| `beginIntegrationOwnerTest` | POST | Admin | "Test this app": an owner-test `state` and the vendor URL for one OAuth round trip; the callback keeps nothing and stamps `settings.lastVerifiedAt`. Input: `{slug}`. |
| `saveAdminIntegrationProvider` | POST | Admin | Upserts one `IntegrationProvider` by slug. Every field optional; omitted fields are unchanged, so it serves both the edit form and the enable/disable toggle. A blank `clientSecret` keeps the stored one; a non-blank one is encrypted into the row's `EncryptedAPIKey` (provider namespace `integration_oauth:<slug>`) and resets `lastUsed`. Blank `clientId` / `authorizeUrl` / `tokenUrl` / `scopes` clear the override back to the catalogue default. Enabling is refused while anything would block a connect. Input: `{slug, label?, clientId?, clientSecret?, authorizeUrl?, tokenUrl?, scopes?, isActive?, sortOrder?, ownerFields?}` (`ownerFields` keys must be the product's own). |

## ai-config.ts: operator controls

Admin-only (`requireAdmin()`). Operator controls, not writer surfaces, so they have no Story API, MCP or CLI counterpart.

| Function | Method | Auth | Description |
|----------|--------|------|-------------|
| `listAIProviders` | GET | Admin | Every `AIProvider` row with its key (never the secret), `trainsOnContent` as the no-training rule reads it, `retiresAt` (ISO or null), and `boundSidecars` / `boundSystemPrompts`, the bindings a delete is refused over. |
| `setAIProviderActive` | POST | Admin | Switches one row on or off. Input: `{id, isActive}`. "Provider not found" for a missing row. |
| `setAIProviderRetirement` | POST | Admin | The voice retirement switch. Input: `{id, retiresAt}`, where `retiresAt` is an ISO instant with an offset, `'now'` (the server's clock), or `null` (un-retire). Refused for a provider type outside `TTS_PROVIDERS`. Writes the instant to every row of the row's provider type (matched case-insensitively), since retirement is per type. Returns `{providerType, providerLabel, retiresAt, rowsUpdated}`. |
| `updateMonthlyUsageCaps` | POST | Admin | Writes `monthlyLimitPerUser` (paid, 1 to 100,000), `freeMonthlyLimitPerUser` (free, 0 to 100,000), or both on the `AIFeatureConfig` singleton. Every problem is named in one readable error and nothing is written. Returns both caps as stored. |

## organizations.ts

Teams for the BUSINESS track. `creatorContext === 'company'` is the discriminator, **not** the workspace track — business and content creators both sit on `STORYTELLER_TRACK`, so `hiddenForTracks: [STORYTELLER_TRACK]` would hide these surfaces from the very users they are for.

Three exported assertions the rest of the feature is built on:

- `requireCompanyCreator()` — `requireUser()` + a `UserProfile.creatorContext` read. Throws the same `'Organization workspace not found.'` for "no profile", "self creator" and "professional writer", so nothing about another track leaks. Every organization- and integration-scoped server fn opens with it.
- `requireOrganizationMember(organizationId, userId, minimumRole?)` — the ownership guard, shaped like `requireOwnedInstall` in `all-hands.ts`: re-reads the row and refuses with one sentence for both "does not exist" and "not yours".
- `requireOrganizationAccess({organizationId, minimumRole?})` — the one-call rail: composes the two above and returns `capabilities` from `lib/organization-access.ts` so a handler never re-derives a rule from a role string.

One organization per person (`assertNoExistingMembership`, enforced on create **and** accept): `getMyOrganization` is singular and an integration's candidates need one unambiguous workspace.

| Function | Method | Auth | Description |
|----------|--------|------|-------------|
| `createOrganization` | POST | Company creator | Creates the organization + an OWNER membership in one transaction, allocating a unique slug the way `allocatePublishingSeriesSlug` does. Input: `{name}`. |
| `getMyOrganization` | GET | Company creator | The caller's organization: role, capability booleans, members, and — only for someone who `canManageMembers` — pending invitations. Invitation **tokens are never included**. |
| `inviteMember` | POST | + ADMIN | Upserts an invitation on `(organizationId, email)`, rotating the token and extending the 14-day window. Refuses granting above your own role and refuses an existing member. Returns the token **once**, to the inviter, the way a raw API key is shown once. Input: `{organizationId, email, role}`. |

`Organization.ownerId` and the founder's OWNER membership are protected by the FK (`onDelete: Restrict`) and by there being no transfer surface. The last OWNER can never be demoted or removed.

## integrations.ts

Connected products for the business track: plain `*ForUser` cores taking an explicit `userId` (the Story API, MCP and CLI reach the same ones), with `.inputValidator()` server fns that resolve the session. Cores throw `StoryApiError` (`organization_required`, `integration_not_found`, `integration_forbidden`, `integration_unavailable`). Nothing returned carries a client secret, a token or a `credentialId`. See `docs/integrations.md`.

| Function | Method | Access | Description |
|----------|--------|--------|-------------|
| `listIntegrationCatalog` | GET | Company creator | All ten products (always), each with its card state (`READY`, `COMING_SOON`, `PENDING`, `CONNECTED`, `NEEDS_ATTENTION`, `RATE_LIMITED`) and the organization's connection (account, chosen origins, last/next check, last run's items, health line). A creator with no organization yet still gets the catalogue. |
| `getIntegrationWalkthrough` | GET | + org member | One product's connect walkthrough (`guide.connect`), its state and connection, and the admin approval link (Microsoft `adminconsent` built from the client id). |
| `beginIntegrationConnect` | POST | + owner/admin | Arms the `Integration` row to `PENDING` and returns `{ integrationId, authorizeUrl, instructions }`. Members may connect only a switched-on, readable product; a site admin may preview one that is set up but not switched on. Input: `{organizationId?, slug, returnTo?}`. |
| `reconnectIntegration` | POST | + owner/admin | Re-authorizes an existing connection (back to `PENDING`). |
| `disconnectIntegration` | POST | + owner/admin | Calls the product's `revoke` best effort, soft-deletes the row, hard-deletes the token `EncryptedAPIKey`, and answers `vendorAccessRemoved`. |
| `listIntegrationOrigins` | GET | + owner/admin | The places the connection can read, live from the product (`ready`, `readyHint`, `chosen`); `available: false` without a fetcher; GitHub's `installUrl`. |
| `setIntegrationOrigins` | POST | + owner/admin | Writes `config.origins` (+ labels) and queues the first check when readable. |
| `requestIntegrationSync` | POST | + editor | Queues one `integration-sync` job (singleton per connection). |
| `recordIntegrationInterest` | POST | + org member | "Request another product" (upsert per member). |
| `withdrawIntegrationInterest` | POST | + org member | Withdraws this member's request (a checked "Notify me" chip tapped again). |

## integration-project-feed.ts

| Function | Method | Access | Description |
|----------|--------|--------|-------------|
| `getProjectIntegrationFeeds` | GET | + org member, own project | The products feeding the project's seasons, each connection's status line, live seasons reached and items held for the next season. |
| `setProjectIntegrationFeed` | POST | + owner/admin, own project | Feeds (`feeding: true`) or withdraws a project from a product in `StoryProject.integrationFeeds`; no season has to be live. |

## integration-season-feed.ts

| Function | Method | Access | Description |
|----------|--------|--------|-------------|
| `getSeasonIntegrationFeed` | GET | + org member, own season | The season's Sources panel: connected products, their chosen origins, the season's rule for each, the NEW count and the review-queue link. |
| `setSeasonIntegrationFeed` | POST | + owner/admin, own season | Writes ONE `audienceOriginRuleSchema` rule for a product into `StorySeasonRun.audiencePolicy` (the explicit per-season opt-in), re-validated by `audiencePolicySchema`. |

## integration-sync.ts (worker)

`runIntegrationSyncTick` (queue `integration-sync-tick`, hourly at :07) queues one `integration-sync` job per CONNECTED connection with a fetcher, chosen origins and a due `nextSyncAt`. `runIntegrationSync` reads only for opted-in seasons (a season's own rule, or its project's feed) and for fed projects with no live season (their items are held for the next season), through the product's paced fetch, files NEW / WITHHELD candidates per season (`evaluateCandidate`, `createMany skipDuplicates`), saves the cursor per origin after every page, records an `IntegrationSyncRun`, and maps errors to `healthCode` (a rate limit never flips the connection to reconnect).

## integration-curation.ts

Source curation — the human half of the leak guard. Business track only: every function opens with `requireOrganizationAccess` (which composes `requireCompanyCreator` + membership + role floor). Acting also requires `canCurateSources` (EDITOR and above); reading is open to VIEWER.

`acceptSeasonSourceCandidate` re-runs `evaluateCandidate` from `lib/integrations/audience-policy.ts` against the season's **current** `audienceIntent` + `audiencePolicy`. The state a sync worker stored at pull time is a display hint, never authorization. A withhold is a returned outcome (with its reason), not a thrown error, so a reviewer sees why and a bulk review is not aborted by one refusal.

Season ownership is asserted alongside org membership: the `StorySeasonRun` must be the caller's own, because the whole story/publishing tree is keyed on `userId` and carries no `organizationId`.

| Function | Method | Auth | Description |
|----------|--------|------|-------------|
| `listSeasonSourceCandidates` | GET | Company creator + org member | Curation queue for one season: the season's audience intent, a plain-language `summarisePolicy` read-back, per-state counts, `canCurate`, and the candidates. Each candidate carries `currentDecision` — the policy's verdict recomputed on read, so a tightened policy shows an item as held back before anyone can keep it. Only the three audience signals are lifted out of `metadata`; the raw blob never leaves the server. Input: `{organizationId, seasonRunId, state?, integrationId?, limit?, cursor?}`. |
| `acceptSeasonSourceCandidate` | POST | + `canCurateSources` | Keeps an item. Re-runs the audience policy; on withhold, persists `WITHHELD` + reason and returns `{outcome:'withheld', reason, ruleCode}` without writing a source. On allow, claims the row with a guarded `updateMany`, creates the `StorySource` on the season owner via `createStorySource` with `producer: "integration:<integrationId>"` + the candidate's `externalId`, and links `sourceId`. When another season of the project already kept the same upstream item, the keep links to that source and returns `sharedSource: true`. Compensating restore on failure so an `ACCEPTED` row can never exist without its source; a source is retired on a link failure only when this keep created it. Idempotent once linked. |
| `rejectSeasonSourceCandidate` | POST | + `canCurateSources` | Turns an item down. The row is kept so the next sync does not re-surface it. Refuses an item that already became a source — removing that source is a separate, explicit act. When another season's candidate points at the same source (one upstream item is one source per project), the refusal says removing it removes it from both seasons. |
| `bulkReviewSeasonSourceCandidates` | POST | + `canCurateSources` | Reviews up to 100 items through the same per-item functions, so a bulk keep re-runs the policy on every one. Not all-or-nothing: returns `{accepted, rejected, withheld, failed, results[]}`. |

## outline-map.ts (episode outlines)

An episode of a season map keeps its outline in ONE place: the project's `outline_map` document,
keyed by the Series Bible's episode id. `saveEpisodeOutline` is the one writer, behind the
episode's Outline tab (`saveScriptOutline`), Slug's `save_episode_outline` (Series Bible chat,
general chat, and an episode's drafting chat), and `PATCH /story-projects/{projectId}/outline-map`
(CLI `outline save`, MCP `save_episode_outline`). `loadProjectOutlines` is the one reader: the
EFFECTIVE map, where an episode with nothing written takes its season-map note as its outline when
that note lays the episode out act by act or scene by scene (`seasonMapNoteIsOutline`). Outline
coverage (readiness, payload, staleness), the Outline tab, Slug's context and the season planner all
read it. A written screenplay is not an outline. A script that covers no season-map episode keeps
its outline on the script (`creativeAnalysis.outline`). Outline coverage staleness compares the
scored episodes to the current ones by content, because a season-map note changes without the
outline map's revision moving.

## scripts.ts

| Function | Method | Auth | Description |
|----------|--------|------|-------------|
| `getScriptOutline` | GET | Required | The Outline tab's outline from its one home (`readScriptOutline`): `{ outline, home }`, where `home` is `{ kind: 'episode', projectId, episodeId, label, source }` for a script covering a season-map episode, else `{ kind: 'script' }`. Input: `{scriptUploadId}`. |
| `saveScriptOutline` | POST | Required | Saves the Outline tab: through `saveEpisodeOutline` for a script covering a season-map episode, on the script otherwise. Input: `{scriptUploadId, outline}`. |
| `readScriptOutlineForUser` | — | Caller's user id | The Story API, MCP and the CLI's read (BL-4048): `readScriptOutlineForUser({ userId, scriptId })` lifts the script's own id, its route id or any version's id to the newest version (`resolveScriptOutlineForUser`) and reads through `readScriptOutline`. Returns the tab's `{ outline, home }`; another account's script is 404 `script_not_found`. |
| `saveScriptOutlineForUser` | — | Caller's user id | The same writer, keyed by the script: `{ outline }` is parsed (`parseScriptOutlineBody`, a section left out is emptied) and saved through `saveOutlineForScript` — the episode's outline map when the script covers a season-map episode, the script otherwise — and answered as `{ outline, home }`. |
| `uploadScript` | POST | Required | Creates a new script and first version through `uploadScriptForUser`. Enforces the current user's page allowance before insert. Input: `{title, content, format?}` (the shared parser's fields; `isCalibration` is not one). |
| `uploadScriptVersion` | POST | Required | Adds a new version through `uploadScriptVersionForUser`. Enforces page allowance by replacing the previous latest version's page usage with the incoming version. Input: `{scriptId, content, format?}`. |
| `updateScriptMetadata` | POST | Required | The edit dialog, through `updateScriptForUser`. Input: `{scriptId, title?, logline?, genre?}` (no `slug`, no `mediaType`: an episode's shape is its project's); the logline is written to `Script.description`. |
| `deleteScriptVersion` | POST | Required | "Only version N", through `deleteScriptVersionForUser` (refused while something depends on the version). Input: `{scriptUploadId}`. |

## pitch-deck.ts

| Function | Method | Auth | Description |
|----------|--------|------|-------------|
| `listPitchDeckJobs` | GET | Required | Lists pitch deck jobs for a script. Input: `{scriptId}`. |
| `getPitchDeckJobById` | GET | Required | Returns one pitch deck job with `journeyPlan`, generated asset manifest, video/PDF render state, progress, and publishing state. Its `videoModelPreflight` is read-only (`readOnly: true`): a missing location plate is reported, never generated. Input: `{jobId}`. |
| `quotePitchDeckBuild` | POST | Required | Returns an itemized Studio Credit quote for the pitch deck production plan, chapter stills, motion clips, and narration, excluding already reserved or settled asset keys. The quote carries `balanceAfter` / `sufficient` (see "Quote balance" below). Input: `{jobId}`. |
| `approvePitchDeckPlan` | POST | Required | Validates a draft `journeyPlan`, reserves itemized pitch deck credit line items, clears legacy compatibility arrays, and enqueues `pitch-deck-generate`. The worker settles completed continuity stills, Seedance clips, and narration assets, then releases ungenerated assets. Input: `{jobId}`. |
| `getPitchDeckQuoteProgress` | GET | Required | What the plan approval's quote is doing while it prices ("Preparing chapter 9 of 17; 8 of 17 done…"), polled by the deck page only while its Approve is pricing. The approval writes each chapter's start and finish on the draft deck's `progressDetail` (a compare-and-swap, prefixed `Pricing the render: `) and puts the draft's own line back when the compile is over. Reads one column; `{ progress: null }` when nothing is being prepared, the deck is not a draft, or it is not the caller's. Input: `{jobId}`. Core: `readPitchDeckQuoteProgressForUser` (`pitch-deck-quote-progress.ts`). |
| `listPitchDeckConversations` | GET | Required | Lists pitch-deck conversations linked to a script. Input: `{scriptUploadId}`. |
| `ensurePitchDeckConversation` (`pitch-deck-conversation.ts`) | POST | Required | The open deck's one conversation (its Slug pane opens it by itself, on the Edit and Video tabs): the deck's own conversation, bound to it (`PitchDeckJob.conversationId`) on first use and scoped like the deck (its upload, or its season/project with the upload its assets come from). A deck the Story API, MCP or the CLI made has none until then; once bound, Slug's deck tools find the deck through the link and edit its plan in place through the same cores the Story API calls. A deck that has its conversation gets it back. Input: `{jobId}`. Core: `ensurePitchDeckConversationForUser`. |
| `deletePitchDeckJob` | POST | Required | Deletes a pitch deck job owned by the current user. Input: `{jobId}`. |
| `duplicatePitchDeckJob` | POST | Required | A new draft deck carrying this deck's current plan (chapters, edits, intent, interludes) and its narrator, delivery class and strategic target; the source is untouched and nothing rendered is copied. Free. Input: `{jobId, title?}`. Core: `duplicateVideoForUser` (`server/video-duplicate.ts`, a trailer's duplicate too), shared with `POST /api/v1/pitch-decks/{jobId}/duplicate`, MCP `duplicate_pitch_deck` and CLI `pitch-deck duplicate`. |
| `publishPitchDeck` | POST | Required | Publishes a completed pitch deck, returns/creates a `shareToken`, and queues master MP4/PDF artifacts when needed. Input: `{jobId}`. |
| `requestPitchDeckVideoExport` | POST | Required | Exports a completed pitch deck for where it is going (owner D7). `target: 'site'` (the default) queues the Remotion master re-render; `'youtube'` / `'x'` make a copy of the finished master levelled to -14 LUFS (true peak at or under -1 dBTP), the master unchanged and rebuilt first when out of date (`server/video-platform-export.ts`). Free. Input: `{jobId, target?}`. Core: `requestPitchDeckVideoExportForUser`; the trailer's `requestTrailerVideoExport` is the same with its own gate. |
| `unpublishPitchDeck` | POST | Required | Unpublishes a pitch deck. Input: `{jobId}`. |
| `getPitchDeckByShareToken` | GET | Public | Loads a published pitch deck for the public share route, including `journeyPlan`, asset manifest, public MP4 metadata, and PDF metadata. Returns the discriminated result, not a flat deck: `{ ok: true, deck }` with the read deck in its `deck` member, or `{ ok: false, kind: 'not_found' }` as a value for the links that must 404. Input: `{shareToken}`. |

## video-platform-export.ts (a deck's or trailer's copy for YouTube or X)

Owner decision D7 (2026-10-12): an export takes a `target`. `site` is the master re-stitch it always was; `youtube` and `x` make a COPY of the finished master levelled to -14 LUFS integrated with its true peak at or under -1 dBTP, by a final two-pass ffmpeg `loudnorm` on the copy only (`lib/platform-loudness.ts`): the master is measured with the EBU R128 meter (`ebur128`), loudnorm is fed those readings with `linear=true` and its widest range target (50 LU, so a wide range alone never forces its dynamic mode), the picture is copied (`-c:v copy`) and the sound delivered as every master is (48 kHz stereo AAC 192k). The copy is measured as delivered and made again, aimed by what it measured, until it lands within 0.2 LU (at most three passes, each from the master); one outside ±0.5 LU or over -1 dBTP is refused, never delivered. A master whose peaks sit at the site's -1.5 dBTP cap cannot reach -14 with one gain, so loudnorm's dynamic mode limits it (it builds on production's ffmpeg 5.1 because the delivery format is named after it). The copy is made from the FINISHED master the job plays NOW: an out-of-date, failed or missing master is rebuilt first and the copy waits (`waiting`). Waiting copies are settled (`kickWaitingPlatformExports`) by both master stitches the moment a master is published (before the version, bell check and chapter mix) and by any read that finds one waiting: queued from a finished master; failed with the plain reason when the published master is a 480p preview of a cut that went back to review (a deck's `complete` with no `preview` stamp, a trailer's `trailerExportReadiness`); left waiting while the cut is finished again or a stitch is queued, running or retrying; failed for good when its rebuild failed with nothing coming. A finished deck assembling a preview refuses a new copy (409 `video_platform_export_not_finished`). A loudness measurement that fails is never silence: only ffmpeg's "matches no streams" or a silent reading passes the master through; any other failure throws and the job retries. State lives in the job's `platformExports` column (one entry per target, written atomically per target, owned by its `requestId`), worked by the `video-platform-export` queue (stately, one key per surface, job, target and accepted request; a null send succeeds only when that exact request is already backed), and read on the deck's chapters read and the trailer's beats read (`platformExports`), Slug's status tools, and the export dialog (`getVideoExportState` in `server/video-export-state.ts`, `components/writing-lab/render/video-export-dialog.tsx`). Story API `POST /pitch-decks/{jobId}/video-export` / `POST /trailers/{jobId}/video-export` `{ target? }`; MCP `export_pitch_deck_video` / `export_trailer_video` (`target`); CLI `pitch-deck|trailer export <jobId> [--target site|youtube|x]`; Slug's two export tools take `target`. Free.

Publication settlement reports a target as queued only after dispatch succeeds or its exact request has backing work. Dispatch failure records and returns the failed copy, including when the master publishes before the waiting export request answers; a failure from a replaced request cannot classify its successor as failed.

## artifact-titles.ts (renaming a plan, a deck, a trailer or a result)

Owner, 2026-10-11: no surface could rename these. One core per kind answers its Story API PATCH, MCP (`rename_plan`, `rename_pitch_deck`, `rename_trailer`, `rename_artifact`), the CLI (`plans rename`, `pitch-deck rename`, `trailer rename`, `artifacts rename`) and Slug (the project chat's `rename_plan`, the deck and trailer chats' `rename_pitch_deck` / `rename_trailer`, the table-read chat's `rename_table_read`). A deck's and a trailer's name is a display name on its own `title` column (their plan, coverage and master never read it; null gives the automatic name back); a plan's is `StoryPlan.title`; a table read's is the title of the script it performs (`updateScriptForUser`). Free; nothing renders or goes out of date.

| Function | Method | Auth | Description |
|----------|--------|------|-------------|
| `renamePitchDeckJob` | POST | Required | The deck lists' inline Rename (Videos tab, deck tab). Input: `{id, title}` (`title: null` or empty: back to the plan's name). Core: `renamePitchDeckForUser`, shared with `PATCH /api/v1/pitch-decks/{jobId}`. |
| `renameTrailerJob` | POST | Required | The trailer list's inline Rename. Input: `{id, title}` (null or empty: back to the automatic name). Core: `renameTrailerForUser`, shared with `PATCH /api/v1/trailers/{jobId}`. |

A waiting plan's Rename on the project page is `renamePlanFromApp` (`story-workflow-ui.ts`, input `{planId, title}`), on the core `renameStoryPlanForUser` that `PATCH /api/v1/story-plans/{planId}` uses. `renameArtifactForUser` (`PATCH /api/v1/artifacts/{artifactId}`) renames a table read through its script's title and a finished deck or trailer through its job; `renameTableReadScriptForUser` is the table-read chat's door, by the script version it holds. A table read's name is also changed in the script's Edit dialog in the Writers Lab library (`updateScriptMetadata`).

## score-to-picture.ts

One score composed to a deck's or trailer's cut and laid under the whole master in place of the table read's scene beds (`lib/marketing/score-to-picture.ts`; the queued composer is `score-to-picture-worker.ts` on the `score-to-picture` queue; the cue sheet is planned by the `score_to_picture_cue_sheet` sidecar). The writer's own music is never replaced. No dead air (score-no-dead-air): after composing and conforming, the worker scans the score against the cut's length (100 ms RMS windows under −50 dBFS for longer than 1.5 s; a quiet head longer than 1 s unless the cue sheet enters on a hit later, `scoreDeliberateEntryMs`; a tail whose digital silence, under −60 dBFS, starts more than 0.5 s before the cut, unless it is a real fade-out falling over the last 3 s); a silent composition is composed again with the silent sections told to sound from their start (on the per-section path, only those sections), what stays silent is filled from the music beside it (a silent tail from its last audible bars, extended to the cut), and the state's `silentGap` (with its writer-visible `note`, also the end of `message`) says what happened on every surface. EARLY MUSIC (owner, 2026-10-05): a score can be asked for on a `draft` plan (and while the first pass renders) with its `direction`; it is priced on the PLANNED cut (`scoreCutFromPitchDeck(..., { planned: true })` lays every chapter at its planned share; a trailer's cut already does), waits for the master (`waiting_for_master`), and a SKETCH to the planned cut is composed right away by the same planner, composer and dead-air guard (the worker's `mode: 'sketch'`, stored as `scoreToPicture.sketch`; never banked, laid or charged). While a score is asked for (`scoreToPictureRequested`: waiting, queued, composing or laid) the deck assembly neither reads nor lays the table read's beds, and the chapters read's `musicPlan` projects none: it leads with the direction and `score.sketchUrl`. Video coverage measures the laid score too: an `audioSync` finding over dead air in it gets the remedy `rescore`, never a retake. Story API: `GET|POST /{pitch-decks|trailers}/{jobId}/score-to-picture`, `POST …/score-to-picture/revert`; MCP `get_/score_/revert_*_score_to_picture`; CLI `<pitch-deck|trailer> score-to-picture <jobId> show|start|revert`; Slug mounts the same three tools.

| Function | Method | Auth | Description |
|----------|--------|------|-------------|
| `getScoreToPictureState` | GET | Required | The score's state: `status`, `canScore` / `blocked` (why not), `runtimeMs`, `fitsCut`, `untouched`, the `cueSheet` (sections by cut with their windows, intensity, hit points, where speech sits), `direction`, `canRevert`, `plannedCut` (no master yet: the planned cut), `sketch` (the sketch to the planned cut while the score waits for its master: `status`, `url`, `fitsCut`) and one sentence. Input: `{surface: 'pitch_deck' \| 'trailer', jobId}`. |
| `quoteScoreToPicture` | POST | Required | Prices a score to picture per second of master at `SCORE_TO_PICTURE_SECOND` (0 today: priced like the app's other music) with `balanceAfter` / `sufficient`. Refused before any price when the cut cannot be scored. Input: `{surface, jobId}`. |
| `startScoreToPicture` | POST | Required | The confirmed start: passes the project spend gate, holds the quoted line (none at today's rate), records the request and queues the composer — or, on a draft plan or while the first render pass runs, waits for its master and queues the sketch to the planned cut. Input: `{surface, jobId, direction?}`. |
| `revertScoreToPicture` | POST | Required | Takes the score off the timeline, puts the beds it replaced back and re-stitches the master through the serialized assembly (`restitch`); cancels a score still waiting for the master. Free. Input: `{surface, jobId}`. |

## bell-lane.ts

BELL ON THE BEAT (owner on Karamazov v2, 2026-10-06: the composed score held no regular 2 s strike, so a real bell is laid): one bell sound — a My Library sound effect, or one made from words with the sound-effect generator and banked — struck on every beat of a deck's bell beat grid (its title sequence's, #1116), frame-exact, as ONE lane on its audio timeline (`audioTimeline.bell`, `lib/marketing/bell-lane.ts`). Every beat or every 2nd; `fromSeconds`/`toSeconds` (default the whole deck); `skipSceneIndexes` (black interludes, chapters, the title sequence); the intros and the title accented; its level (default 0.12) on a sound levelled to one peak — when the lane's sound is set, a copy peaking at -1 dBFS is made once with ffmpeg's `volume` (`server/bell-sound-level.ts`, `sound.normalized`) and struck, so a level is the same loudness for every bell; ducked under the spoken lines with the score's duck. The sketch mixes the bell against the score at the level the master lays it (0.25), and both the sketch and the master check weigh each strike against the score (`audibility`, a note when the bell sits more than 12 dB under it). The strikes are derived wherever the lane plays: the master mix (`resolveTimelinePlacements` with the deck's `pitchDeckMasterBellGrid`, one `<Audio>` per strike), the sketch (`lib/marketing/bell-lane-mix.ts`, the score-to-picture worker's sketch and `bell-mix` modes: the score alone kept as `sketch.scoreUrl`, the sketch mixed again whenever the lane changes) and the check after assembly (`server/bell-lane-check.ts`, run where every deck master is published: each strike's onset measured in the master's audio, kept as `assetManifest.bellCheck`, bookkeeping). A score to picture composed with a lane leaves room for it (no bell descriptors, bells and downbeat percussion among the composer's negatives, the planner handed a `Bell lane:` line; the `score_to_picture_cue_sheet` row's THE BELL LANE section, rollout `bell-lane`). Free: a deck in review or finished re-stitches its master for its sound. Story API: `GET|POST /pitch-decks/{jobId}/bell-lane`, `POST …/bell-lane/remove`; MCP `get_pitch_deck_bell_lane`, `strike_pitch_deck_bell_on_the_beat`, `remove_pitch_deck_bell_lane`; CLI `pitch-deck bell <jobId> show|strike|remove`; Slug mounts the same three tools. The UI reads the bell (one read-only line on the deck page, `components/audio/bell-lane-line.tsx`, and the timeline's Bell lane) and changes it through Slug: its three tools, and a painted bell wizard's Apply (`server/surfaces.ts`). The bespoke Bell on the beat panel and its `setBellLane` / `removeBellLane` server fns are gone (owner, 2026-10-06).

| Function | Method | Auth | Description |
|----------|--------|------|-------------|
| `getBellLane` | GET | Required | The deck's bell lane: `available` (or `unavailable` with how to turn the grid on), the `grid`, the `lane`, every strike on the cut (the planned cut before any master), the `parts` it can skip, start/end `points`, the `check` on the master (`checkCurrent`) and whether the laid score made room for it. Input: `{jobId}`. |

## video-versions.ts, video-versions-review.ts

A DECK'S AND A TRAILER'S VERSIONS (owner, 2026-10-09: "the raw individual clips are saved off and organized pre-stitching so we can always repurpose them"; owner D9, 2026-10-11: trailers too, so a trailer re-assembly never overwrites its master without keeping it). One core serves both surfaces through a surface adapter. Every published master — preview, early look or finished — is recorded as a numbered `PitchDeckVersion` or `TrailerVersion` (docs/database.md) by `recordPitchDeckVersionSafely` / `recordTrailerVersionSafely`, called where the master is published (`pitch-deck-video-export.ts`, `trailer-video-export.ts`; never fails the stitch). Its clips are copied server side (R2 CopyObject, never re-encoded) into `pitch-decks/<jobId>/versions/<n>-<YYYYMMDD-HHMM>/chapters/<NN>-<slug>/` or `trailers/<jobId>/versions/<n>-<YYYYMMDD-HHMM>/beats/<NN>-<slug>/` with a `manifest.json` by the `video-version-archive` queue (`video-version-archive.ts`, `{ surface, versionId, task: 'folder' }`; a copy already there with the source's ETag is kept; at most 400 objects), queued only through the queue the stitching worker already runs. Its zip is packed on request (task `zip`: stored, streamed from R2 into a multipart upload, at most 16 GB) and read back as a signed link. Story API `GET /pitch-decks/{jobId}/versions` / `GET /trailers/{jobId}/versions`, `GET …/versions/{number}`, `POST …/versions/{number}/zip`, `POST …/versions/{number}/restore` (BL-4114: the takes it played chosen again and a master assembled from them); MCP `list_pitch_deck_versions` / `list_trailer_versions`, `get_pitch_deck_version` / `get_trailer_version`, `request_pitch_deck_version_zip` / `request_trailer_version_zip`, `restore_pitch_deck_version` / `restore_trailer_version`; CLI `pitch-deck|trailer versions <jobId>`, `pitch-deck|trailer version-clips <jobId> <number> [--download <dir>] [--zip]`, `pitch-deck|trailer restore-version <jobId> <number>`; Slug mounts the same four tools in each chat (the reads work with a chapter or beat focused). The reviews' Versions list (`components/writing-lab/render/video-versions-list.tsx`) reads them through:

| Function | Method | Auth | Description |
|----------|--------|------|-------------|
| `listVideoVersions` | GET | Required | The deck's or trailer's versions, newest first: number, stage, date, master, chapter or beat count and take count, folder and copy status, zip. Input: `{surface, jobId}`. |
| `getVideoVersion` | GET | Required | One version: every chapter's or beat's cut before the finish and the take each shot played (trim, finished copy), each with its path in the folder and its copied URL; the sound and finish; every file; the zip (signed link while ready). Input: `{surface, jobId, number}`. |
| `requestVideoVersionZip` | POST | Required | Asks for the version's zip (free): a ready one answers its link; otherwise it is queued. Input: `{surface, jobId, number}`. |
| `restoreVideoVersion` | POST | Required | Puts an earlier version back (BL-4114, free): `restoreVideoVersionForUser` reads the version's own snapshot (`videoVersionSelectionParts`) and hands the takes it played to the surface's selection core (`restoreTrailerVersionSelectionForUser` / `restorePitchDeckVersionSelectionForUser`), which chooses them in ONE manifest write and asks for a new master; a take no longer held is named in `missing` instead of stopping the undo. Review or complete only. Input: `{surface, jobId, number}`. |

## video-interludes.ts, video-closing-sequence.ts, master-interludes.ts

A DECK'S AND A TRAILER'S BLACK INTERLUDES AND CLOSING SEQUENCE (owner, 2026-10-02 and 2026-10-06 for decks; owner D9, 2026-10-11: trailers too). One core each serves both surfaces through a surface adapter (which table, which plan column, which key a unit goes by: a deck's `afterChapterIndex`, a trailer's `afterBeatIndex`, a beat's place, re-keyed when beats are inserted, moved or removed). The rules are one module each (`lib/video-interludes.ts`, `lib/video-closing-sequence.ts`): both masters lay the blacks and the closing as segments of their one segment list (`pitchDeckMasterLayout`, `getTrailerRenderPlan`), the score's cut, video coverage and plan coverage read them, and the voices check counts a line over black. `updateVideoInterludesForUser({surface,…})` replaces the whole list (validated whole; a cast speaker needs a voice): a draft's plan is queued for coverage again, a rendered video's master is stitched again (free). `updateVideoClosingSequenceForUser({surface,…})` sets the closing whole; `directClosingTextMotionForUser` / `clearClosingTextMotionForUser` direct one card through the motion-text director (every save a compare-and-swap on the plan). Each assembly voices a line over black and makes its sound through one loop (`voiceInterludes`, `server/master-interludes.ts`; our cost): the narrator's lines in the video's narrator (a deck's narrator rule; a trailer's `resolveTrailerNarratorVoice`), a cast line in that person's voice, a sound banked and never laid when someone talks in it. Story API `POST /pitch-decks|trailers/{jobId}/interludes`, `POST …/closing-sequence`, `POST …/storyboard/direct-text-motion` with a `closingElement`; MCP `update_pitch_deck_interludes` / `update_trailer_interludes`, `update_pitch_deck_closing_sequence` / `update_trailer_closing_sequence`; CLI `pitch-deck|trailer update-interludes`, `pitch-deck|trailer closing-sequence`, `… direct-text-motion --closing-element`; Slug mounts the same tools in each chat. The editors carry no transition control between two units and no Closing form (owner, 2026-10-18: the knobs are Slug's): the black between units and the closing are told to Slug, and the page only reads them. The server functions that remain:

| Function | Method | Auth | Description |
|----------|--------|------|-------------|
| `updatePitchDeckClosingSequence` / `updateTrailerClosingSequence` | POST | Required | Sets the video's closing sequence whole, or removes it with null (free; its directions are kept). Input: `{jobId, closingSequence}`. Core: `updateVideoClosingSequenceForUser`. |

## project-brand-logo.ts

The deck and trailer Closing cards read, upload, replace and remove their project's logo through the same project update as the Story API's `brandLogo`, CLI `projects update`, and MCP `set_project_brand_logo`. Uploads accept PNG, JPEG or WebP up to 2 MB and 4096 pixels on a side. The server decodes the image and stores its own PNG or WebP copy without metadata or trailing content. Removal clears the project field; saved closings retain their copied logo until saved again. The site's "Remove logo" also saves this video's closing without the logo card when it was on.

| Function | Method | Auth | Description |
|----------|--------|------|-------------|
| `readVideoProjectBrandLogo` | GET | Writing Lab access | Reads the logo for an owned video's project. Input: `{surface, jobId}`. |
| `setVideoProjectBrandLogo` | POST | Writing Lab access | Uploads a logo for an owned video's project, fully decoded and encoded before storage. Input: `{surface, jobId, dataUrl}`. |
| `removeVideoProjectBrandLogo` | POST | Writing Lab access | Removes the logo for an owned video's project. Input: `{surface, jobId}`. Core: `removeVideoProjectBrandLogoForUser`. |

## project-access.ts / project-members.ts / project-members-fns.ts

THE ACCESS RULE FOR A PROJECT (`server/project-access.ts`). `requireProjectAccess({ userId, projectId, need: 'read'|'edit'|'manage', match?, operation?, via? })` returns `{ projectId, routeId, name, kind, ownerUserId, actorUserId, role }`: 404 `project_not_found` for anyone who is neither the owner nor a member (as before sharing), 403 `project_read_only` for a viewer asking to change it, 403 `project_owner_only` for a member asking to `manage` (delete, members). Admins get no content access from it. A member's `edit` is recorded as a `member_write` ProjectMemberEvent. Rows inside the project are read and stamped with `ownerUserId`; credits, caps, My Library, voices, conversations and the audit stay `actorUserId`. `requireProjectOwner` is the owner-only check for every surface not yet opened to members (`assertProjectOwned` in `story-project-ownership.ts` calls it); `requireProjectJobAccess` checks a trailer or deck through its project (a standalone job stays its owner's); `accessibleProjectsWhere(userId)` is the list filter.

PROJECT MEMBERS (`packages/shared/src/project-members.ts`). Core: `listProjectMembersForUser` (anyone who can open the project, or an admin), `addProjectMemberForUser` (by email — an address with no account is invited to set a password and joins in the same step, `invited: true`; owner or admin; product projects only, 409 `project_members_product_only`), `updateProjectMemberForUser`, `removeProjectMemberForUser` (a member may remove themselves), each with a ProjectMemberEvent in the same transaction and a live update to the member; `listUserProjectsForAdmin` (admin Users tab). A shared project's kind cannot change while it has members. Server fns (`project-members-fns.ts`): `listProjectMembersFn`, `addProjectMemberFn`, `updateProjectMemberFn`, `removeProjectMemberFn` (the project ⋯ menu's "Share project"; `admin: true` from the admin Users tab records `admin_ui`; an admin impersonating the owner is recorded as the actor), `listUserProjectsForAdminFn` (never while impersonating). Story API `GET|POST /story-projects/{projectId}/members`, `PATCH|DELETE /story-projects/{projectId}/members/{userId}`; MCP `list_project_members`, `add_project_member`, `update_project_member`, `remove_project_member`; CLI `projects members list|add|update|remove`; Slug `list_project_members`, `manage_project_members` (proposes, then acts on `confirmed: true`). Live updates of a shared project reach its members (`server/live-events.ts`).

ORGANIZATION MEMBERS (`packages/shared/src/organization-members.ts`). Core (`server/organization-members.ts`): `listOrganizationMembersForUser` (anyone in the caller's business organization), `addOrganizationMemberForUser` (by email as `admin`, `editor` or `viewer` — an address with no account is invited to set a password and joins in the same step, `invited: true`; the owner and admins, never above their own role; one organization per person: 409 `organization_member_elsewhere`), `updateOrganizationMemberForUser`, `removeOrganizationMemberForUser` (anyone may leave; the owner never changes or leaves: 403 `organization_forbidden`). Server fns (`organization-members-fns.ts`): the Integrations page's Team card. Story API `GET|POST /organization/members`, `PATCH|DELETE /organization/members/{userId}`; MCP `list_organization_members`, `add_organization_member`, `update_organization_member`, `remove_organization_member`; CLI `integrations team list|add|update|remove`; Slug `list_organization_members`, `manage_organization_members` (proposes, then acts on `confirmed: true`).

## workspace-role.ts / workspace-role-fns.ts

UNLIMITED STUDIO CREDITS (`server/unlimited-credits.ts`, docs/credit-billing.md): `setUnlimitedCredits` (admins only, read from the database; only an admin or an allow-listed demo account, else 403 `unlimited_credits_not_allowed`; sets `User.unlimitedCredits` + `internalAccount` and writes an `UnlimitedCreditsEvent`; idempotent). Server fn (`unlimited-credits-fns.ts`): `setUnlimitedCreditsFn` (POST `{userId, unlimited}`; admin Users tab, never while impersonating). Story API `PUT|DELETE /admin/users/{userId}/unlimited-credits`; MCP `set_unlimited_credits`; CLI `admin unlimited-credits on|off <userId>`. Every limit reads the flag through `isUnlimitedCreditAccount` (`lib/unlimited-credits.ts`) or `balanceCovers` (`lib/credits.ts`).

THE WORKSPACE ROLE (Screenwriter `writer`, Content creator `creator`, Business `business`; `packages/shared/src/workspace-role.ts`). Read from the profile's track + creator context, never stored twice. Core: `readWorkspaceRoleForUser`, `setWorkspaceRoleForUser` (refused 403 `role_switch_not_enabled` unless `User.workspaceRoleSwitchEnabled`, admins included; writes the pair + a `WorkspaceRoleEvent` in one transaction; the same role is a no-op), `setWorkspaceRoleSwitchForUser` (admins only, read from the database; idempotent; audited), `defaultProjectKindForUser` (Business starts a project with no kind as `product`), `assertFirstRunRoleWrite` (onboarding's own track/context writes refuse once onboarding is done). Server fns (`workspace-role-fns.ts`): `getMyWorkspaceRoleFn` (GET), `setMyWorkspaceRoleFn` (POST `{role}`; the account menu's role picker, `components/auth/workspace-role-picker.tsx`, and the profile page; neither UI says who can turn switching on), `setWorkspaceRoleSwitchFn` (POST `{userId, enabled}`; admin Users tab, never while impersonating). Story API `GET|PUT /account/workspace-role`, `PUT|DELETE /admin/users/{userId}/workspace-role-switch`; MCP `get_workspace_role`, `set_workspace_role`, `set_workspace_role_switch` (and `whoami.workspaceRole`); CLI `role`, `role set <writer|creator|business>`, `admin role-switch enable|disable <userId>`; Slug `set_workspace_role`. Business adds the `BUSINESS_PROMPT` snippet on top of `STORYTELLER_PROMPT` (`server/writer-guidance.ts`). `updateProfile` no longer takes `workspaceTrack`.

THE CRAFT FOCUS (the kind of work Slug coaches toward; `packages/shared/src/craft-focus.ts` holds the one list, stored on `UserProfile.storytellerSubset`). Core: `setCraftFocusForUser` (`server/craft-focus.ts`): a key or plain words matched by `normalizeStorytellerSubset`, null/empty clears, a value naming no focus refused 400 `validation_failed` with `details.options`; the same focus twice writes nothing (`changed: false`). Server fn `saveCraftFocus` (POST `{craftFocus}`; Profile → Craft focus, `components/profile/craft-focus-control.tsx`). Story API `PUT /account/craft-focus` (read on `GET /account/workspace-role` as `craftFocus`); MCP `set_craft_focus`; CLI `craft-focus`, `craft-focus set <focus>`, `craft-focus clear`; Slug `set_craft_focus`.

## demo-accounts.ts / demo-accounts-fns.ts

DEMO ACCOUNTS (admins only; docs/auth.md "Demo accounts"). Core: `readDemoAccountIds`, `setDemoAccount` (audited; an admin account is refused; removal revokes every minted session), `listDemoAccounts`, `mintDemoSession` (via the server-only Better Auth plugin; refused for non-admins, non-listed or admin targets, OAuth grants and TTLs outside 5 min–8 h), `revokeDemoSession`, `checkDemoSession` (the per-use check), `demoImpersonationActor` / `demoSessionActor` (who may act for a demo account), `recordDemoAccountEvent`. Server fns for the Users tab (`demo-accounts-fns.ts`, signed-in admin, never while impersonating): `getDemoAccountsFn` (GET), `setDemoAccountFn` (POST `{userId, demo}`), `revokeDemoSessionFn` (POST `{sessionId}`). No mint from the browser. Story API `/admin/demo-accounts[/{userId}[/sessions]]`, `/admin/demo-sessions/{sessionId}`; MCP `list_/add_/remove_demo_account`, `mint_/revoke_demo_session`; CLI `admin demo-accounts|demo-account|demo-session`.

## video-destinations.ts

VIDEO DESTINATIONS (destination videos PV1). `listVideoDestinations()` reads every switched-on `VideoDestination` row in order and checks each one's `spec` (and `family`) against `@sleeperhit/shared/video-destination-spec`: the usable ones as `destinations`, the failing ones as `unavailable` with the reason, never guessed. `getVideoDestination(key)` reads one: 404 `video_destination_not_found` without a switched-on row, 409 `video_destination_unavailable` when its spec fails. `readVideoDestinationRow(row, now)` is the pure check, and sets `specMayBeStale` when `verifiedAt` is more than 90 days old (or missing on a row with `sourceUrls`). The same catalog for every account; free. Story API `GET /video-destinations` and `/video-destinations/{key}` (`source:read`); MCP `list_video_destinations` / `get_video_destination`; CLI `destination list|get <key>`; Slug's `list_video_destinations` in the project's chats, painted as a Summary card (`components/chat/video-destinations-card.tsx`). No server fn: the UI is that painted read.

## capture-depth.ts / capture-depth-setting.ts / capture-depth-review.ts

A CAPTURE BEAT'S DEPTH (app-to-trailer PR 1.6). `captureDepthComposite(input, deps?)` makes the composite a capture beat with a depth preset plays in the master's card slot: it reads the `CaptureDepthPreset` rows (clamped), plans the shot from the capture's `events.json` (`lib/capture-depth/planner.ts`), publishes the stage plate once, renders the `CaptureShot` composition through `renderRemotionMediaToBuffer` (label `trailer-capture-depth`; anything above flat `farmPolicy: 'farm-only'`, and when no box takes it the pod renders subtle at 720p), stores it beside the trailer's media by a content key, and checks it against a flat twin (rendered text size, a sub-pixel exact text-crop SSIM via `measureCaptureTextSsim`, the camera's speed), stepping down one preset at a time while it got harder to read. Called by the assembly (`server/trailer-capture-picture.ts`), which hands it the edit's raw master and edit map when it has one: an edit with its cursor burned in then plays from the clean master under the one drawn cursor, planned from the edit's own `events.json`, decided per preset planned (`captureCursorSource`, `lib/capture-depth/edit-map.ts`; S3a). The raw master is private: the renderer gets a presigned GET (≤1 h, `signTrailerRawCapture`), never a public copy, and nothing stored names it. Free. `getTrailerCaptureDepthForUser` / `setTrailerCaptureDepthForUser` read and set `TrailerJob.captureDepth` (free, any state; a rendered trailer's master is assembled or stitched again); the server fn `setTrailerCaptureDepth` (`server/trailer.ts`, `requireAIAccess`) is the review's picker. A beat's own preset and stage caption are `refineTrailerBeat`'s `depthPreset` / `stageCaption`. `applyCaptureLegibilityVerdicts` (the video coverage worker, after a report) sets a step-down ceiling on each capture beat the Gemini watch found harder to read and asks for the master again. Story API `GET|POST /trailers/{jobId}/capture-depth`; MCP `get_/set_trailer_capture_depth`; CLI `trailer capture-depth <jobId> [--preset …]`; Slug mounts the same two tools.

## quiet-track-setting.ts

A DECK'S AND A TRAILER'S QUIET TRACK (Karamazov v5, 2026-10-08 for decks; owner D9, 2026-10-11: trailers too). One core reads and sets `PitchDeckJob.quietTrack` / `TrailerJob.quietTrack` (`silence`, `room_tone`, or null: none): `getQuietTrackForUser({surface,…})` counts the renders it reaches, the quiet renders too long for one and the takes already rendered with each track, over the master's units (a deck's chapters, whose stretches that keep their own sound are left out; a trailer's beats, which keep none); `setQuietTrackForUser({surface,…})` sets it (free, any state; it changes only the takes rendered after it). Each take is built with it by `quietTrackForTake` (`server/scene-render/quiet-tracks.ts`), from the deck's call audio and from the trailer's `renderOneTake`, and records what it was sent. No server fn: the UI reaches it through Slug. Story API `GET|POST /pitch-decks|trailers/{jobId}/quiet-track`; MCP `get_/set_pitch_deck_quiet_track`, `get_/set_trailer_quiet_track`; CLI `pitch-deck|trailer quiet-track <jobId> [--track silence|room_tone | --off]`; Slug mounts the same two tools in each chat. A duplicate carries it.

## project-sounds.ts

THE SOUNDS A PROJECT USES (owner on Karamazov v3, 2026-10-06: "use the same bell as v2"): `listProjectSoundsForUser` finds every sound where it was used — each deck's bell lane, every music clip and sound effect on a deck's or trailer's audio timeline — named as used and by its video, numbered in the project by when it was made ("Deck 2 · Karamazov v2"), each with `bellSound` (`{sound: {fromJobId}}` or `{assetId}`) to strike it as a deck's bell. No server fn: the UI reaches it through Slug (`list_project_sounds`, and the bell rework wizard's answers). Story API `GET /story-projects/{projectId}/sounds?kind=&query=`; MCP `list_project_sounds`; CLI `projects sounds <projectId> [--kind bell|music|sfx] [--query <text>]`. Reads only.

## surfaces.ts

PAINTED SURFACES (owner, 2026-10-06; `docs/design/slug-painted-surfaces.md`): Slug paints a short, curated wizard in the deck conversation (`paint_surface`: one question a step, 2–4 answers with one recommended, Listen on a sound, then one line and Apply) or a one-card summary, from a fixed catalog rendered by `components/chat/painted-surface.tsx`. The spec is a json-render `Spec` (`lib/surfaces/spec.ts`), stored as a `ChatSurface` row with the deck the chat resolved when it painted. An apply sends only the chosen answer ids; the arguments come from the stored spec and run through the op's own core (`lib/surfaces/ops.ts`: `pitchDeck.bellLane.set` → `setBellLaneForUser`, `pitchDeck.scoreToPicture.start` → `priceOrScoreToPicture`). A priced op answers a quote first and runs only on a confirm with that quote's token. What was applied reaches Slug on the writer's next turn (`lib/surfaces/history.ts`), with no model turn per apply. Headless callers call the op's own route, MCP tool or CLI command.

| Function | Method | Auth | Description |
|----------|--------|------|-------------|
| `applySurface` | POST | Required | Applies a painted wizard's answers to the conversation's own deck. Refused for another writer's surface, a replaced one, an answer the spec does not offer, or a deck that is no longer the conversation's. Priced ops: `{status: 'quoted', quote, quoteToken}` first, then `confirmed: true` with that token. Input: `{surfaceId, answers: {choiceKey: answerId}, confirmed?, quoteToken?}`. |
| `getSurfaceState` | GET | Required | A painted surface's state (`open`, `applied` with its summary and answers, `replaced`), for a wizard reopened later. Input: `{surfaceId}`. |

## chat-reply-status.ts

| Function | Method | Auth | Description |
|----------|--------|------|-------------|
| `getChatReplyStatus` | POST | Required (owner) | What became of a Slug message the chat panel stopped waiting for (`lib/ai/chat-late-reply.ts`): `received` (the server saved it; null when it has no visible text to match), `replied` (Slug's reply is saved), and, once replied or on the panel's `final` check, the conversation's tail as saved with `olderMessageCount` (the panel shows it without a reload). Read-only; a POST because the message text can be longer than a URL. Input: `{conversationId, pendingText, final?}`. |

## writing-lab-projects.ts (project pages)

`writing-lab-projects.ts` serves the project pages from one index: `getWritingLabProjectOverview` (the project Overview page, `/tools`: project, gate, written episodes; its Next steps tab is the checklist and its Series Bible tab, `?tab=bible`, the whole Bible read-only) and `getWritingLabProjectForUser` (Videos: every version with its job counts and `characterCount`; no version's cast or text).

## conversation-messages.ts

A chat panel shows a conversation's tail (`lib/ai/chat-message-window.ts`: the latest 12 messages, plus anything streaming). `getConversation({ slug, tail: true })` (the project chats, the full-page chat, and the screenplay editor chat outside a surgical pass), the trailer thread reads, the Cast chat (`getOrCreateCastConversation`) and the table-read chats (`getOrCreateTableReadConversation`, and the scene theater, global theater, DJ Studio and SFX Studio chats through `openTableReadModeConversation` in `server/table-read-mode-conversation.ts`) load only that tail and say how many saved messages come before it (`olderMessageCount`); "Show earlier messages" loads older ones a page at a time. While the panel holds only part of the history, each chat turn sends `historyBeforeMessageId` (its oldest saved message), and `routes/api/chat/stream.ts` reads everything saved before it, so the model still reads the whole conversation. One read, `readConversationMessageWindow`, serves all three, ordered by `(createdAt, id)`. A conversation select takes the tail with `...CHAT_TAIL_SELECT` and `chatTailOf` returns `{ messages, olderMessageCount }`. `getConversation` never returns `systemPromptSnapshot`.

| Function | Method | Auth | Description |
|----------|--------|------|-------------|
| `getEarlierConversationMessages` | GET | Required (owner) | The page of up to 24 saved messages before `beforeMessageId` in the writer's own conversation (any chat surface), oldest first, with `olderMessageCount`. A message that is not this conversation's has nothing before it. Read-only. Input: `{conversationId, beforeMessageId}`. |

## chat-tool-worker.ts: saving an approved preview

`detect_sound_effects` and `audit_scene` (the table-read setup chat and the Live Read manage pane) work in two steps. `apply=false` queues a preview: a `ChatToolJob` whose `result.preview` is true, which the chat card shows and Slug reads with `get_tool_job_result`. When the writer approves, Slug calls the same tool with `apply=true` and that preview's `chatToolJobId`, plus any subset the writer kept: `onlyEntryIndices` / `excludeEntryIndices` (sound effects, and a single-scene audit's emotion and timing changes), `onlySceneIndices` / `excludeSceneIndices` (the all-scenes soundscape audit), or `keepCurrentSoundscape` (a single-scene audit). The tool queues the apply job with `previewChatToolJobId`, so the chat card and its polling are unchanged. The worker saves exactly the previewed list: it does not call `generateObject`, resolve a provider or log usage, so there is no second provider cost. The `table_read_manage_director` prompt row teaches this apply.

| Helper | Description |
|--------|-------------|
| `loadApprovedPreview({userId, scriptId, chatToolJobId, toolName})` | Owner-checked through `readChatToolJobForUser`, so someone else's job reads as not found. The job must also be on this script, come from the same tool, have status `completed`, and still be a preview. Anything else returns a writer-readable error, and nothing is queued, detected or saved. The worker runs the check again when it applies. |
| `resolveApprovedPreview({userId, scriptId, conversationId, toolName, chatToolJobId?, sceneIndex?})` | Picks the preview `apply=true` saves. A named `chatToolJobId` must pass `loadApprovedPreview`; it never falls back to a fresh run. With no id, it takes the newest preview in this conversation that is still waiting; for `audit_scene` it must have the same scope (the same `sceneIndex`, or the all-scenes audit). When an apply is newer than every preview, or there is no preview, it returns `none`, and only then does `apply=true` run fresh. Passing a subset when there is no preview is an error. |

A previewed sound-effect cue is skipped, with its reason in `skipped`, when its line text changed since the preview, the line is not an action line, or the line already has a sound effect. `buildSoundEffectCues` holds that one-cue-per-line rule for both a fresh run and a preview save. An audit proposal is skipped when its scene or line changed. Indices the writer named that were not in the preview come back in `notInPreview`. An audit save returns `applied: true`, plus `scenesUpdated` for the all-scenes audit.

## Slug read cores

The Slug read-access work (`docs/plans/slug-read-gaps.md`) lets every chat surface read what the writer can see. Wherever a read lived only inside a `createServerFn` handler, which needs a request session, it was split into an owner-checked core that takes an explicit `userId`. The server fn calls that core, and so does the chat tool, so the page, Story API, MCP and Slug all go through the same ownership check. A read never dispatches, recovers or bills work. Where a load path used to generate a missing asset, it now takes `readOnly`, and every read passes it: the chat tools, the Videos panel server fn (`getVideoWorkspaceAssets`), the pitch-deck status poll (`/api/pitch-deck/status`) and `getPitchDeckJobById`, and both storyboard reads on every surface (the `getPitchDeckStoryboardBoards` / `getTrailerStoryboardBoards` server fns in the UI, and `GET /api/v1/pitch-decks/{jobId}/storyboard` / `GET /api/v1/trailers/{jobId}/storyboard` behind MCP and the CLI). Missing location plates are made by the paths that quote and reserve credits: the approval quote (`requirePitchDeckVideoModelAssets`) and the render workers.

The reads Slug has on every surface (background activity, Studio Credits, the Series Bible with its coverage and mood board) are chat tools, not server functions. See "Shared reads on every Slug surface" in `docs/prompting-and-streaming.md`.

### Coverage add-on reports (blacklist.ts, production.ts)

| Helper | Description |
|--------|-------------|
| `getBlackListReportForUser` | `{userId, coverageReportId, isAdmin?}`. Returns the Black List report for one coverage version, exactly as the Coverage tab shows it: owner-scoped (admins see any), and `null` when the script is not eligible for professional coverage add-ons. `errorMessage` comes back in writer-facing words (`writerFacingFailureMessage`): operator wording such as "schema not found — please configure via Admin → AI Config" becomes the shared setup-problem reason, while a reason the writer can act on ("Retry this analysis.") passes through. Everyone sees the same sanitized sentence; an admin caller also gets the worker's raw line in `errorDetail` beside it (the `AdminTechnicalDetail` disclosure on the tab), and a writer's payload carries `errorDetail: null`. Called by `getBlackListReport` and by the chat's `get_blacklist_analysis`. |
| `getProductionReportForUser` | The same for the Production report, including the writer-facing `errorMessage` and the admin-only `errorDetail`. Called by `getProductionReport` and by `get_production_analysis`. |

The script page's status poll (`/api/script/status`) applies the same writer-facing relays to the `errorMessage` of `coverage`, `tableRead`, `blackList` and `production` (`writerFacingFailureMessage` on the last two, which read the whole failure in one plain sentence), so a failed job reaches the writer's toast and inline error in writer-facing words. `characterAnalysis.errorMessage` stays raw on purpose: `script-job-status.tsx` reads it to detect a missing provider (`isMissingAiProviderMessage`).

### table-read.ts

| Helper | Description |
|--------|-------------|
| `readTableReadReviewItemsForUser` | `(userId, scriptUploadId)`. Returns the lines the writer flagged on the script's latest table read, with the notes they typed, and that job. Owner-checked; throws `Script not found` for a script the user does not own. Called by `getReviewItems` and by `get_table_read_notes`. |

### table-read-chat.ts

| Function / helper | Method | Auth | Description |
|-------------------|--------|------|-------------|
| `saveDjStudioViewState` | POST | Required | Stores the DJ console's unsaved music and the bar's entry range on the DJ Studio conversation, so Slug critiques what the writer is hearing. It never writes the scene's saved music. Input: `{conversationId, draftMusic (or null), startEntry?, endEntry?}`. The core is `saveDjStudioViewStateForUser`. |
| `readDjStudioMusicStateForUser` | helper | Owner | `{userId, conversationId, scriptUploadId}`. Returns the bar-aware music state: the beat's own cue on a beat bar (with the scene bed under it), otherwise the scene bed, plus the synced draft when it differs from what is saved. Behind the DJ Studio `get_current_music_state` tool. |
| `resolveDjStudioMusicState` | helper | none | Pure resolver shared by that reader and `buildDjStudioSystemPrompt`, so the tool and the per-turn brief cannot disagree. |
| `readChatToolJobForUser` | helper | Owner | `{userId, jobId}`. Returns one `ChatToolJob`'s status, result and error. Ownership goes through the job's conversation, or through its script for jobs started from the page (they have no conversation). A job that belongs to someone else returns `{status: 'not_found'}`, the same as one that does not exist. Behind `getChatToolJobStatus` (the page's poll) and Slug's `get_tool_job_result`. |
| `listChatToolJobsForScript` | helper | Owner | `{userId, scriptUploadId, toolName?, limit? (1-25, default 10)}`. Returns recent tool jobs on one of the user's scripts, newest first. A script the user does not own returns no jobs. Also behind `get_tool_job_result`. |

### table-read-manage-chat.ts

| Function / helper | Method | Auth | Description |
|-------------------|--------|------|-------------|
| `saveTableReadPlaybackPosition` | POST | Required | Stores the Live Read line on screen in `TableReadJob.resumePosition`, so the manage chat's prompt can show the current line and scene. Input: `{jobId, dialogueIndex (or null to clear)}`. The core, `saveTableReadPlaybackPositionForUser`, uses a raw single-column `UPDATE` whose `WHERE` carries the owner check. A Prisma `update` would bump `updatedAt`, which is the concurrency token for voice-track synthesis. |
| `readTableReadPlaybackPosition` | helper | none | Parses a stored position (`{dialogueIndex, updatedAt?}`); returns `null` when there is none. |

### theater-canvas-chat.ts

| Function / helper | Method | Auth | Description |
|-------------------|--------|------|-------------|
| `recordTheaterCanvasApplyResult` | POST | Required | The canvas dialog reports whether Slug's committed canvas applied, so the next turn knows. Input: `{conversationId, status: applying/applied/failed, beatCount?, shareUrl?, error?}`. Saved as `metadata.lastApply` on the owner's theater-canvas conversation. The core is `recordTheaterCanvasApplyResultForUser`. |
| `formatApplyOutcome` | helper | none | Turns `lastApply` into a line for the canvas prompt. An `applying` status older than `THEATER_CANVAS_APPLY_STALE_MS` (10 minutes) reads as never reported. |

### cast-chat.ts

| Helper | Description |
|--------|-------------|
| `buildEpisodeScreenplayBlock` | `(userId, scriptId, content)`. Builds the `{EPISODE_SCREENPLAY}` block of the Cast prompt. Every line with its entry index when the read's lines exist and fit in `CAST_SCREENPLAY_FULL_TEXT_MAX` (30,000 characters). Otherwise a scene index with each speaker's line count; the lines stay readable by tool. Before parsing, it uses the stored screenplay text. Best effort: a failure leaves the block empty. |

### script-read-voices.ts, cast-jobs.ts: the Cast card's read voice and Redraw

The owner on the Karamazov Cast tab (2026-10-05): the card's voice and the episode's read must never silently disagree, and each picture gets its own Redraw.

| Function / helper | Description |
|-------------------|-------------|
| `readScriptReadVoices` | `{userId, scriptUploadId}`. The screenplay's finished read as the Cast card sees it: `artifactId`, `restorable`, and `behindCanon`: each character the finished read's own voice map (the job behind the artifact's share link, which the re-voice core plans against) still speaks with another voice than their cast canon voice. Behind `readCastReadVoices` (the card's "This episode's table read still uses …" and its priced Re-voice in this read), `GET /story-projects/{projectId}/cast?scriptUploadId=` (`episode.read`, so MCP `get_project_cast` and CLI `cast status`), and Slug's `get_episode_cast_status` (`read`). |
| `voicesBehindCanon(voiceMap, canon)` | Pure. A person is read under the FIRST voice-map key that names them, as `revoiceArtifactCharacter` picks the cue it re-voices, so a listed character is one a re-voice with no voice would change and never one it refuses as "already speaks with". A person with no canon voice, or a cue the canon does not know, is never listed. |
| `readsSpeakingOtherwise` (cast-voice-design.ts) | The choose answer's `reads`. A read belongs to the project by its job's project or by the project screenplay its share link serves, so a Writing Lab read made before its screenplay joined the project is still offered its re-voice. |
| `quoteEpisodeCast` / `startEpisodeCast` with `images` | An episode cast narrowed to pictures (`face`, `body`, `sheet`) and no voices or narrator: with `force` they are drawn again (the card's Redraw on a body shot or sheet), priced per picture (`EPISODE_CAST_CREDIT_RATE`). A body shot or sheet for a person with no headshot is refused before the price (`cast_face_missing`, a 409 `cast_precondition_failed` on the API). Any episode quote with a body shot or sheet in it is refused before the price when no model that keeps a face is bound (`resolveCastIdentityImageModel`), as the project cast is. The job skips the voice pass, completes on its own pictures, and its final `progressDetail` carries each check against the headshot (`onArtCheck`, threaded through `generateCastForScript` to `generateCastFigures` and `generateAllCharacterSheets`). `quoteCast` / `startCast` take the same `images`; `generateCastForProject` sends `images` without a `scriptUploadId` to the project canon's Redraw (below). |
| `readEpisodeCastJob` | Adds `pictures` (`{character, image}` from the job's line items): the card marks each tile a running job is drawing. |


### cast-picture-redraw.ts: the project canon's Redraw

A person on the project cast canon whom no episode names (Karamazov v2's Grushenka) had no body shot or sheet Redraw: the episode Redraw casts only its screenplay's roster, and the project cast's `force` redraws the face too.

| Function | Notes |
|---|---|
| `redrawProjectCastPictures` | `images` (`body` / `sheet`; `face` is a 400, a headshot is redrawn as candidates) for canon people (`characters`, any name they answer to; omitted, everyone but the narrator). Refused before a price: not owned (404), the workspace gate, a person not on the canon (404 `character_not_found`), no headshot (`cast_face_missing`), nothing to draw from (`cast_missing_descriptions`), no model that keeps a face, more than `MAX_REDRAW_PICTURES` (4) pictures (the chat-tool queue reruns a job at its 600 s expiry while the first attempt still draws). Priced per picture at `EPISODE_CAST_CREDIT_RATE`; the quote carries a `quoteId` hashed from the people, their face and their current pictures. Confirmed with that `quoteId` (else `cast_quote_changed`), it refuses a picture already being redrawn (`cast_redraw_running`), checks the spend gate, reserves the pictures and queues `cast_picture_redraw` on the chat-tool queue. Reached by `generateCastForProject` (`images` without `scriptUploadId`: API, MCP, CLI), the `generateProjectCast` server fn (the Cast card, `surface: 'app'`) and Slug's `redraw_cast_pictures` (`surface: 'chat'`). |
| `drawProjectCastPictures` | The worker. Per person, the sheet then the body (wearing the new or current sheet), from the chosen face on the identity model with the person's look, each checked against the face (#1040). A face that changed since the price draws nothing. Each picture is written onto the canon with `rewriteCastCanonForProjectId` (a compare-and-swap on the headshot) and read back; only then is it settled. A failed draw, write or read-back releases it and fails the job (`nonRetryable`). A retried job draws only pictures still queued. |
| `readProjectCastRedraw` | The project's newest redraw, picture by picture (`url` once the canon holds it, `check`, `error`). On `readProjectCast` (`redraw`) and the Cast tab's `getProjectCast`. |
### gold-standard.ts

| Helper | Description |
|--------|-------------|
| `readGoldStandardExemplar` | `(id)`. Returns the exemplar as the Gold Standards editor shows it (title, medium, genre, logline, scores and the source report). No auth of its own: `getGoldStandard` gates on the admin session, and the calibration chat's `get_gold_standard` checks the acting user's admin role. |
| `readGoldStandardCalibrationScript` | `(id, offset = 0)`. Returns the screenplay the exemplar calibrates, in windows of `CALIBRATION_SCRIPT_WINDOW_CHARS` (100,000) with `nextOffset`. Calibration scripts belong to other writers, so callers must be admins (`read_calibration_script` checks the role). |

### opportunity.ts, strategy-card.ts

| Helper | Description |
|--------|-------------|
| `_listOpportunityCards` | `(userId, filters)`. The feed query behind `listOpportunityCards`, with the feed's watch, person, company and pursuit includes. Filters: `status` (new, reviewed, archived, promoted), `watchId`, `kind`, `scriptId`, `query` (case-insensitive on title, summary or source title) and `limit`. Behind the chat's `list_opportunity_cards`. |
| `_getOpportunityCard` | `(userId, isAdmin, id)`. Returns one card with the same includes. Another user's card reads as not found. Behind `get_opportunity_card`. |
| `_getStrategyCardDetail` | `(userId, isAdmin, strategyCardId)`. The pursuit panel's read: the card, its person, company and script links, and every action item with its detail. Behind `getStrategyCardDetail` (the pursuit panel) and the Pursuits chat's `get_pursuit`. |

### videos.ts, video-model-reference-assets.ts

| Helper | Description |
|--------|-------------|
| `getVideoWorkspaceAssetsForUser` | `{userId, scriptUploadId, readOnly?}`. Returns the data for the "Required Series assets" panel, owner-checked, including `pendingLocations`: the script's scene rooms with no plate yet (`{locationKey, label}`). The panel lists each as "made when the video renders" (the flag reads `X ready · Y missing`) and offers no generate action. A pending room does not hold the panel's readiness back, but a script with no plate and no known room is still not ready. Called by `getVideoWorkspaceAssets` (the panel's server fn) and by the chat's `get_series_video_assets`; both pass `readOnly`, so opening the panel never makes a plate. |
| `loadVideoModelReferenceCatalog(scriptUploadId, { readOnly })` / `loadVideoSeriesAssetReadiness(scriptUploadId, { readOnly })` | `VideoModelReferenceCatalogOptions.readOnly` reports the location plates that exist and leaves out a missing one. Without it, the catalog load runs `ensureStoryboardLocationsForScript` and generates every missing plate, which is how the paid paths (the approval quote and the render workers) load it. `loadVideoSeriesAssetReadiness` (videos.ts) also returns `pendingLocations` from `listScriptLocationsAwaitingPlates`. |
| `listScriptLocationsAwaitingPlates(scriptUploadId, plated)` | Lists the scene-heading rooms the render will make plates for, without making them. Reads only the stored `scriptUpload.parsedDialogues` (no parse, no writes), so a script with no stored parse lists nothing. A room counts as plated when it matches a `plated` key exactly or with INT/EXT relaxed, the same match `generateAllLocations` uses. Camera-ladder rungs are not listed: a room's rungs are made with its master plate. |

The same `readOnly` flag was added to existing reads the chat tools reuse: `getPitchDeckStoryboardBoardsForUser` and `inspectPitchDeckVideoModelPreflightSafe` (pitch-deck.ts), `inspectPitchDeckVideoModelAssets` (pitch-deck-video-model-preflight.ts) and `getTrailerStoryboardBoardsForUser` (trailer.ts) report missing plates instead of generating them. `getAudioTimelineStateForUser` (marketing-audio-timeline.ts) shows a legacy timeline migrated without persisting it, since a chat read runs every turn and would race the render workers that write the same manifest.

A read-only `inspectPitchDeckVideoModelAssets` also passes the script's pending rooms to `buildPitchDeckVideoModelAssetPreflight` (`pendingLocations`). A chapter location that resolves only against a room the render will make then reads `canonical_location` status `will_generate` ("Made when the video renders…") instead of `missing`, and does not block plan approval. The approval quote is what makes that plate. The quote and render paths pass no pending set, so a plate they failed to make still reads `missing` and blocks. A chapter with no location still reads `missing`.

## product-video-plans.ts / product-video-plans-fns.ts

PLAN A PRODUCT VIDEO FROM THE FEATURE MAP (P-101). One core (`server/product-video-plans.ts`) and one queued run (`product-video-plan-worker.ts`, budgets in `product-video-plan-budget.ts`): a queued planner writes capture beats for one feature of the project's feature map, or for a named release and its features, into an ordinary capture-only `draft` trailer — each beat names its capture, its kept stretch, a narration line, whether it keeps its own sound, and its depth. Free: nothing is charged, reserved or rendered. The run answers at once (202, or the live run an equal body already started with `reused: true`) and is polled until `ready`, when it names the draft (`trailerJobId`, the review link, the conversation bound to it), `nextStep: approve_trailer_plan` and its live plan check. A strict body: an unknown key is 400 `validation_failed` naming it; an unknown `featureId` is 400 with the feature map in `details.features`; a `planned` feature is 400 (`details.availability`); a story project is 409 `project_not_product`; no shipped feature with a recording of its own yet is 409 `product_video_not_ready` in the readiness step's own sentence (`details.next` its verb: the project's first readiness step not done, `connect_app` first), and a feature with no recording of its own is 409 `product_video_not_ready` naming it (`details.recordedFeatures` lists what can be planned) — both read through `readProductVideoPlanningReadiness` (server/product-readiness.ts, the Overview's readiness read); no eligible capture in the shape is 409 `product_video_no_captures` (`details.byAspect`); a run of another project is 404 `product_video_plan_not_found`; the 31st run in a UTC day is 403 `usage_limit_reached`. Approval is the ordinary `approve_trailer_plan`, gated inside `approveTrailerPlanForUser` (trailer-reference-api.ts) by `assertProductVideoPlanCovered`: a blocked plan check (409 `trailer_state_invalid`, `details.refusal.code: product_video_plan_blocked`) names each blocking finding with its fix and reserves nothing. Story API `POST|GET /story-projects/{projectId}/product-video-plans`, `GET /story-projects/{projectId}/product-video-plans/{runId}`, `GET /trailers/{jobId}/product-plan-coverage` (`story:write` for the plan; `story:read` for the reads); MCP `plan_product_video`, `list_product_video_plans`, `get_product_video_plan`, `get_product_video_plan_coverage`; CLI `product-video plan|runs|run|coverage`, then `trailer approve-plan`; the project Videos page's "Plan a video from your feature map" planner (registered only when the project makes capture videos) and the draft's "Plan check" card above Approve (`components/writing-lab/trailer/product-plan-coverage-card.tsx`); Slug mounts the same four tools in the project's chats. The planner row runs on its own `product_video_planning` sidecar (Feature TRAILER, its own binding, otherwise the chat's provider).

| Function / helper | Description |
|-------------------|-------------|
| `startProductVideoPlanFn` | Calls `startProductVideoPlanForUser`. Starts one run (or joins the live run an equal body already started) and answers `{run, reused, note}`. Input: `{projectId, body}`. |
| `listProductVideoPlansFn` | Calls `listProductVideoPlansForUser`. The project's runs (newest first), the feature map to plan from (each feature's availability) and the eligible capture count per shape. Input: `{projectId}`. |
| `getProductVideoPlanFn` | Calls `getProductVideoPlanForUser`. One run: its status, and once ready its draft trailer, review link, `nextStep` and its live plan check. Input: `{projectId, runId}`. |
| `getProductVideoPlanningFn` | Calls `readProductVideoPlanningReadiness`. What can be planned now, from the project's readiness: `recordedFeatures` (shipped features with a recording of their own) and `block` — while none has one, the first readiness step not done with its sentence, its button and its verb. The Videos page reads it with the page (`getProjectVideoLibrary` carries it as `planning`) and again on window focus. Input: `{projectId}`. Null for a story project. |
| `getProductVideoPlanCoverageFn` | Calls `getProductVideoPlanCoverageForUser`. One planned draft's plan check — the scope's features with the beats that cover each, and every finding with its fix. A trailer not planned from a feature map answers `coverage: null` with a note. Input: `{jobId}`. |

### Exact-run one-time-code routing

`app-connections-fns.ts` keeps the owner's current `connectionId` + `testId` + `askedAt` checks. Recording answers
keep the existing wire `captureId` + `askedAt` fields. Both relay through private `CaptureRunBinding` rows rather than
process memory. Recording discovery lists only active bindings for the owned project. The runner GET embeds the
expected wire run ID in the signed path (`/v1/one-time-code/<encoded run ID>`); another run's pause returns null,
and the old unscoped GET is absent. POST retains the exact run and question timestamp. No API, CLI, MCP or Slug code
entry capability is added. Web needs only its existing runner authentication configuration, never an opening key.

### Running Test sign-in browser preview

`app-connection-test-preview.ts` requires the product's owner and exact current connection/test. `getAppConnectionTestPreviewForUser` reads signed metadata without taking a picture; CLI, MCP and Slug use its explicit metadata allowlist. A replaced test is `app_connection_test_changed` (409). Missing or unreachable browser state does not invent an image or sequence.

`getAppConnectionTestPreviewFrameForUser` is used only by the native owner-session proxy and direct owner-key Story API JPEG adapter; OAuth receives metadata only. It verifies the durable run lease and current configured target before and after the signed read. The private answer authenticates the image together with connection/test, phase, sequence and times. JPEG responses are private/no-store, at most 1 MiB, expire after ten seconds, and never appear in tool results. `getProjectAppConnectionTestPreviewFn` supplies the native dialog's metadata/current test and existing scoped one-time-code question. `listProjectAppConnectionsFn.canPreviewTest` comes from owner/product access, independently of sealing configuration or final-picture permissions.

The runner observes the actual login and session-check pages without creating a browser or controlling app state. Explicit image reads coalesce and capture at most every two seconds. Page changes, learned secret values, test completion and expiry invalidate cached bytes. Native masks cover inspectable editable controls, configured sensitive selectors and DOM echoes of already-available secret/session/header values; known uninspectable sensitive surfaces or uncertainty withhold the preview. No arbitrary image/OCR or unknown closed-shadow discovery is claimed. Existing authentication, final-picture handling, budgets and secure code-entry gates remain separate.
