Story Bible + Chapter-Level Takes — Implementation Plan
Status: proposal, awaiting review
Author: planning agent (Opus 4.7)
Scope: novel-mode story engine — two architectural upgrades
Related commits: 1a9eb11 (novel mode), 2946280 (per-chapter gen), 262db32 (per-chapter audio), 14e287a (segment-level TTS cache)
Goals (recap)
- Story Bible — versioned canon owned by the season; injected into every chapter prompt; chapters can propose deltas that an admin approves.
- Chapter-level Takes — mirror of
season_script_takesat the episode level, so every prose generation creates a draft and promotion is the only path that mutatesstory_episodes.chapter_prose.
These ship in that order. Step 1 stands alone — bibles deliver immediate craft quality even without the takes layer.
1. Schema Diff
1.1 New tables (Step 1 — Story Bible)
season_story_bibles
The "current" bible for a season. One row per season. Editing a bible advances version and snapshots the prior content into season_story_bible_versions.
| Column | Type | Notes |
|---|---|---|
id | uuid PK | gen_random_uuid() |
season_id | uuid NOT NULL | FK story_seasons(id) on delete cascade, UNIQUE (one current bible per season) |
version | int NOT NULL default 1 | bumps on every commit |
characters | jsonb NOT NULL default '[]'::jsonb | array of {key, name, arc, voice_brief, physical, inner_life, current_state, secret_knowledge, first_appears_chapter, status} (see §1.4) |
themes | jsonb NOT NULL default '[]'::jsonb | array of {key, statement, expression_notes} |
settings | jsonb NOT NULL default '[]'::jsonb | array of {key, name, sensory_palette, rules} |
motifs | jsonb NOT NULL default '[]'::jsonb | array of {key, image_or_phrase, where_planted_chapters[], where_paid_off_chapters[]} |
planted_threads | jsonb NOT NULL default '[]'::jsonb | array of `{key, planted_in_chapter, payoff_target_chapter, status: 'planted' |
voice_rules | jsonb NOT NULL default '[]'::jsonb | array of strings — narrator-level voice constraints |
tone_rules | jsonb NOT NULL default '[]'::jsonb | array of strings — emotional pitch / pacing rules |
do_not_use | jsonb NOT NULL default '[]'::jsonb | array of strings — banned words/phrases/devices |
synopsis | text | short paragraph the model can use as overall through-line; capped at ~400 chars in prompt |
summary_so_far | text | rolling recap of already-written chapters; auto-updated post-promotion of chapter takes (§ Step 2 hook) |
bootstrapped_from | text | 'manual' | 'plan' | 'retroactive'; provenance only |
created_at | timestamptz NOT NULL default now() | |
updated_at | timestamptz NOT NULL default now() | |
created_by_user_id | uuid | FK profiles(id) on delete set null |
updated_by_user_id | uuid | FK profiles(id) on delete set null |
Indexes:
- UNIQUE
(season_id)— partial index, matchesseason_script_takesstyle.
RLS: mirror season_script_takes (super-admin all; public read only on active/completed seasons — but the bible is admin-only IMO; see Open Question Q1).
season_story_bible_versions
Append-only history. Every commit copies the prior season_story_bibles row contents here before the new version is written.
| Column | Type | Notes |
|---|---|---|
id | uuid PK | |
season_id | uuid NOT NULL | FK story_seasons(id) on delete cascade |
version | int NOT NULL | matches the snapshotted version number |
characters, themes, settings, motifs, planted_threads, voice_rules, tone_rules, do_not_use, synopsis, summary_so_far | jsonb / text | full snapshot of the bible at that version |
commit_message | text | what the editor said about this delta (e.g. "added Mara's secret about the lake") |
committed_by_user_id | uuid | FK profiles(id) on delete set null |
committed_at | timestamptz NOT NULL default now() | |
source_take_id | uuid | nullable FK story_episode_takes(id) on delete set null — set when a take's proposed delta was accepted into this version |
Indexes:
(season_id, version DESC)for "show me the history of this bible"- UNIQUE
(season_id, version)— version is monotonic per season
RLS: same as season_story_bibles.
episode_take_bible_deltas
Proposed bible updates produced alongside a chapter take. Sits in a queue until an admin promotes (apply on top of current bible), edits, or rejects.
| Column | Type | Notes |
|---|---|---|
id | uuid PK | |
take_id | uuid NOT NULL | FK story_episode_takes(id) on delete cascade |
season_id | uuid NOT NULL | FK story_seasons(id) on delete cascade |
delta | jsonb NOT NULL | structured patch — see §1.5 |
model_notes | text | free-text from the model explaining what it changed and why |
status | text NOT NULL default 'pending' | check in ('pending', 'applied', 'rejected', 'superseded') |
applied_to_version | int | the bible version that resulted from accepting this delta; null until accepted |
reviewed_by_user_id | uuid | FK profiles(id) on delete set null |
reviewed_at | timestamptz | |
created_at | timestamptz NOT NULL default now() |
Indexes:
(season_id, status, created_at DESC)— admin queue view(take_id)— one-to-one-ish with take
RLS: super-admin only.
1.2 New tables (Step 2 — Chapter Takes)
story_episode_takes
Mirrors season_script_takes but at the episode (chapter) level. Each call to generateChapter lands here as status='draft'. Promotion writes the take's prose into story_episodes.chapter_prose and rebuilds segments via the existing rebuildSegmentsFromCache flow.
| Column | Type | Notes |
|---|---|---|
id | uuid PK | |
episode_id | uuid NOT NULL | FK story_episodes(id) on delete cascade |
season_id | uuid NOT NULL | FK story_seasons(id) on delete cascade (denormalized for query speed; matches takes pattern) |
episode_number | int NOT NULL | denormalized; reduces joins for the takes list view |
label | text NOT NULL | ${model_key}_${framework_key}, suffix -2,-3,... on collision (mirror of defaultLabel in season-takes.ts) |
framework_key | text NOT NULL | |
model_key | text NOT NULL | |
prompt_template_version | int NOT NULL default 1 | bump when chapter prompt structure changes (e.g. when bible injection lands → bump to 2) |
parameters | jsonb NOT NULL default '{}'::jsonb | actually-used { temperature, max_tokens } |
prose | text NOT NULL | the generated chapter body (replaces segments jsonb in season takes — chapter takes are text-native) |
prompt_snapshot | jsonb | full system+user prompt strings actually sent to GPT, for reproducibility / debugging |
bible_version_id | uuid | nullable FK season_story_bible_versions(id) on delete set null — the bible version this take was generated against |
status | text NOT NULL default 'draft' | check in ('draft', 'live', 'archived', 'rejected') (mirror) |
review_notes | text | |
generated_by_user_id | uuid | FK profiles(id) on delete set null |
cost_cents | numeric | |
duration_ms | int | |
promoted_at | timestamptz | |
archived_at | timestamptz | |
created_at | timestamptz NOT NULL default now() |
Indexes:
(episode_id, status, created_at DESC)— episode takes list(season_id, status)— cross-season "show me all live takes" if useful- UNIQUE
(episode_id, label)— same uniqueness as season takes - UNIQUE PARTIAL
(episode_id) WHERE status='live'— one live per chapter
RLS: super-admin only for non-live; public read on live takes for visible seasons (parallel to season_script_takes policy).
Soft pointer:
- Add
story_episodes.active_take_id uuidreferencingstory_episode_takes(id) on delete set null(provenance, parallelsstory_seasons.active_take_id).
episode_take_critiques and episode_critique_decisions
Recommendation: parallel infrastructure, not reuse of season_take_critiques. See Open Question Q3 — but my call:
season_take_critiques.take_id is a hard FK to season_script_takes(id). Repurposing it for episode takes would either need (a) the FK relaxed and a discriminator column, or (b) a UNION view. Both are messier than a parallel pair of tables. The two tables are small (~10 columns each) and the critic loop is the same shape, so duplication is cheap. Creating new tables also lets us keep the season-level take/critique loop working unchanged while we iterate on the chapter loop.
So: clone season_take_critiques → episode_take_critiques with take_id → story_episode_takes(id), and clone season_critique_decisions → episode_critique_decisions with take_id → story_episode_takes(id). All other columns identical. Indexes identical. RLS identical.
1.3 Existing-table changes
| Table | Change | Rationale |
|---|---|---|
story_episodes | add active_take_id uuid REFERENCES story_episode_takes(id) ON DELETE SET NULL | provenance pointer; parallels story_seasons.active_take_id |
story_seasons | add bible_id uuid REFERENCES season_story_bibles(id) ON DELETE SET NULL | optional — only useful as a "bible was ever bootstrapped" signal. Could be left out and inferred via WHERE EXISTS. Recommend: skip this column, infer presence by a join. Keeps the migration smaller. |
story_episodes.chapter_prose | unchanged in schema | semantics shift: now ALWAYS the prose of the live take. Manual edits to this column become a discouraged anti-pattern (see §7 backwards compatibility). |
1.4 Bible JSONB shapes (proposed)
// characters[]
{
key: 'mara_chen', // stable slug, used by the model to reference
name: 'Mara Chen',
arc: 'Wants to forget the lake. By Ch 4 chooses to return.',
voice_brief: 'flat, observant, sentence fragments under stress',
physical: 'mid-30s, always cold, wears her mother\'s ring on a chain',
inner_life: 'guilt over what she didn\'t say',
current_state: 'as of Ch 2: avoiding her sister\'s calls', // updated as story progresses
secret_knowledge: 'she was the last person to see Theo alive',
first_appears_chapter: 1,
status: 'active' | 'departed' | 'introduced_only',
}
// themes[]
{ key: 'silence_as_violence', statement: 'What you don\'t say leaves marks too', expression_notes: 'never named, only shown' }
// settings[]
{ key: 'lake_macomb', name: 'Lake Macomb', sensory_palette: 'iron taste, slate sky, the dock that creaks', rules: 'Always dawn or dusk in this story; never noon.' }
// motifs[]
{ key: 'the_chain', image_or_phrase: 'the silver chain', where_planted_chapters: [1], where_paid_off_chapters: [4] }
// planted_threads[]
{ key: 'sister_phone_calls', planted_in_chapter: 1, payoff_target_chapter: 4, status: 'planted', summary: 'unanswered calls accumulate; she answers in Ch 4' }
Why this shape:
- Each entity has a stable
keyso deltas can address things by id, not by index in an array. current_stateon characters is what makes the bible living — the model reads it to know "where the character is right now," and the post-chapter delta updates it.statuson threads is the planted/paid-off ledger that prevents the model from forgetting setups.
1.5 Bible delta shape (proposed)
A delta is a structured patch the chapter prompt asks GPT to return alongside the prose. JSON-only, never free-form text:
{
// additions
add_characters?: Character[] // for new characters introduced in this chapter
add_motifs?: Motif[] // new images/phrases planted
add_planted_threads?: PlantedThread[] // new setups
// updates
update_characters?: Array<{ key: string; current_state?: string; arc?: string; status?: string }>
update_planted_threads?: Array<{ key: string; status: 'developing' | 'paid_off' | 'dropped'; summary?: string }>
update_summary_so_far?: string // refresh of the rolling recap
// notes
notes?: string // free text — what changed, what the writer noticed
}
The shape is additive and update-only. No deletes. Deletes happen only via admin edit. This keeps the model from accidentally erasing canon.
2. Service Surface
All paths absolute. New files first, then signatures of changes to existing files.
2.1 New services
apps/backend/src/services/story-bible.ts
export interface StoryBible { /* all jsonb columns + version + ids */ }
export interface BibleDelta { /* shape from §1.5 */ }
/** Read the current bible for a season. Returns null if none exists yet. */
export async function getCurrentBible(seasonId: string): Promise<StoryBible | null>
/** Read a specific historical version. */
export async function getBibleVersion(seasonId: string, version: number): Promise<StoryBible | null>
/** List all versions (id + version + commit_message + committed_at + committed_by) for the version-history UI. */
export async function listBibleVersions(seasonId: string): Promise<Array<{ id: string; version: number; commit_message: string | null; committed_at: string; committed_by_user_id: string | null }>>
/**
* Bootstrap a bible from the season's plan (premise + setting + planned chapters).
* Calls GPT once to produce an initial bible. Idempotent: if a bible already
* exists, returns it untouched (use forceRebootstrap to overwrite).
*/
export async function bootstrapBibleFromPlan(seasonId: string, opts?: { force?: boolean; userId?: string | null }): Promise<StoryBible>
/**
* Build the bible-block string injected into chapter prompts. Token-budgeted:
* trims `summary_so_far`, character entries, and motifs to fit `maxTokens`.
* Returns both the rendered string AND the bible_version_id so the take can record provenance.
*/
export function buildBiblePromptBlock(bible: StoryBible, opts: { maxTokens: number }): { block: string; version_id: string }
/** Manually commit a new bible version (admin-edited contents). Snapshots old to versions table, bumps version, stamps `updated_*`. */
export async function commitBible(seasonId: string, contents: Partial<StoryBible>, opts: { commitMessage: string; userId: string | null }): Promise<StoryBible>
/** Apply an approved delta on top of the current bible — produces a new version. */
export async function applyDelta(seasonId: string, delta: BibleDelta, opts: { commitMessage: string; userId: string | null; sourceTakeId?: string | null }): Promise<StoryBible>
/** Backfill a bible from existing `chapter_prose` (one or more chapters). For migrating seasons that exist before this feature shipped. */
export async function backfillBibleFromChapters(seasonId: string, opts?: { userId?: string | null }): Promise<StoryBible>
apps/backend/src/services/story-bible-deltas.ts
export interface BibleDeltaRow { /* matches episode_take_bible_deltas */ }
/** Save a proposed delta from a chapter take generation. Status starts 'pending'. */
export async function saveProposedDelta(takeId: string, delta: BibleDelta, modelNotes: string | null): Promise<BibleDeltaRow>
/** Admin queue: list pending deltas across a season (or all). */
export async function listPendingDeltas(seasonId: string): Promise<BibleDeltaRow[]>
/** Approve: applies the delta to the current bible, commits a new version, marks delta `applied`. */
export async function approveDelta(deltaId: string, opts: { commitMessage?: string; userId: string | null }): Promise<{ delta: BibleDeltaRow; newVersion: number }>
/** Edit then approve: takes admin-modified delta JSON, applies that. Useful when the admin wants 80% of the model's proposal. */
export async function approveDeltaWithEdits(deltaId: string, edited: BibleDelta, opts: { commitMessage?: string; userId: string | null }): Promise<{ delta: BibleDeltaRow; newVersion: number }>
/** Reject: marks rejected, no bible change. */
export async function rejectDelta(deltaId: string, reason: string | null, userId: string | null): Promise<void>
apps/backend/src/services/episode-takes.ts
Direct mirror of season-takes.ts, but for chapter prose. Many functions take episodeId rather than seasonId.
export interface ChapterTake { /* matches story_episode_takes columns */ }
/**
* Generate a new draft chapter take. Calls into the existing chapter prose
* generator (see §2.2 changes to season-novel-chapter.ts) with persist=false,
* loads the current bible, injects it, captures the resulting prose + the
* proposed delta, persists everything atomically in a take row + a deltas row.
*
* Does NOT touch story_episodes.chapter_prose — promote does that.
*/
export async function generateChapterTake(
episodeId: string,
opts: {
framework_key?: string
model_key?: string
parameters?: { temperature?: number; max_tokens?: number }
label?: string
user_id?: string | null
apply_critiques_from_take_id?: string | null // mirror of season takes
} = {},
): Promise<ChapterTake>
/**
* Promote a draft chapter take to live. Atomically:
* 1. Archive the previous live take for this episode (if any)
* 2. Flip this take's status to 'live', stamp promoted_at
* 3. Copy take.prose into story_episodes.chapter_prose
* 4. Set story_episodes.active_take_id
* 5. Call rebuildSegmentsFromCache(seasonId) — the existing function
* that already does cache-preserving segment rebuild
*/
export async function promoteChapterTake(takeId: string): Promise<void>
export async function rejectChapterTake(takeId: string, reason?: string): Promise<void>
export async function archiveChapterTake(takeId: string): Promise<void>
/** List takes for one episode (no full prose — fetch with getChapterTake). */
export async function listChapterTakes(episodeId: string): Promise<Array<Omit<ChapterTake, 'prose' | 'prompt_snapshot'>>>
export async function getChapterTake(takeId: string): Promise<ChapterTake | null>
apps/backend/src/services/episode-take-critiques.ts
Direct clone of season-critiques.ts, with take_id referencing story_episode_takes and findings tied to paragraph index in the prose rather than segment_position. Same shape otherwise. Reuses script-critics.ts (the critic registry) unchanged.
export async function runChapterCritique(opts: { take_id: string; critic_key: string; model_key?: string }): Promise<EpisodeTakeCritique>
export async function listChapterCritiquesForTake(takeId: string): Promise<EpisodeTakeCritique[]>
export async function listChapterDecisionsForTake(takeId: string): Promise<EpisodeCritiqueDecision[]>
export async function recordChapterCritiqueDecision(opts: { finding_id: string; take_id: string; action: 'apply' | 'ignore' | 'override'; admin_text?: string | null }): Promise<void>
export async function buildChapterCritiqueDecisionBlock(prevTakeId: string): Promise<{ block: string; decisions: EpisodeCritiqueDecision[] }>
export async function markChapterDecisionsApplied(decisionIds: string[], appliedToTakeId: string): Promise<void>
2.2 Existing-file changes
apps/backend/src/services/season-novel-chapter.ts
callChapterPromptGPT becomes the low-level prompt call. New responsibilities:
- Accepts an optional
bible_block: stringparameter and splices it into the system prompt above the framework directive. - Accepts an optional
summary_so_far: stringparameter and includes it in the user prompt as "What has happened so far:". - Asks GPT for a JSON response:
{ "prose": string, "bible_delta": <delta shape> }instead of plain text. - Returns
{ prose, bible_delta, prompt_snapshot, cost_cents, latency_ms }.
generateChapter (the public entry) gets a new opts.persist?: boolean flag (default true, mirrors season script). When persist=false it skips writing chapter_prose and skips rebuildSegmentsFromCache — the takes layer uses this to generate without committing. When persist=true (the legacy path), behavior is unchanged for backward compat.
Add a new exported generateChapterRaw that takes the loaded season + chap rows and returns just the LLM result + delta — used by episode-takes.ts so it doesn't re-fetch.
rebuildSegmentsFromCache — no signature change. Still called after a chapter take is promoted.
generateNextChapter — keep as-is initially (a thin wrapper). In a later PR, redirect it through the takes layer (generateChapterTake → promoteChapterTake) so even the convenience button leaves a take row behind.
apps/backend/src/services/season-novel-script.ts
generateNovelScript (the full-season novel script generator) — same change as callChapterPromptGPT: accepts a bible_block for each chapter, asks for the delta. Bumped less aggressively since the per-chapter flow is the path admins use day-to-day; full-script regen is rare.
apps/backend/src/services/story-seasons.ts
generateNovelSeasonPlan — after the season + episodes insert, before returning, call bootstrapBibleFromPlan(season.id) synchronously. One extra GPT call (~3-5s) at season creation; saves admins from clicking "bootstrap bible" manually.
Question worth asking: should we make this bootstrap step conditional on a bootstrap_bible: true opt-in in the create-season body? See Q2.
3. Prompt Changes
3.1 Current chapter prompt (today)
In callChapterPromptGPT (apps/backend/src/services/season-novel-chapter.ts:49-103):
System prompt injects:
- Genre, narrator voice/persona/tics, narrator label
- Framework directive block (~150-300 tokens)
- 8 numbered rules
User prompt injects:
- Season title / premise / setting
- Chapter title / event_description / hidden_details
Approximate input tokens today: ~700-900 for system + ~150-200 for user = ~850-1100 input per chapter.
3.2 New chapter prompt
System prompt adds:
STORY BIBLE — CURRENT STATEblock (token-budgeted to ~1500 tokens, see below)- Instruction to return JSON
{ "prose": "...", "bible_delta": {...} }instead of plain text - Brief schema reminder for the delta shape
User prompt adds:
WHAT HAS HAPPENED SO FAR:(the bible'ssummary_so_far, capped ~800 chars / ~200 tokens)CRITIC NOTES FROM PRIOR TAKE:block (only when applicable; same shape as season takes today)
System prompt removes: nothing (the framework block stays — the bible complements it).
3.3 Token budget (typical 6-chapter novel)
Bible block budget: 1500 tokens MAX in the system prompt. Allocation when bible is full:
| Section | Soft cap | Hard cap |
|---|---|---|
synopsis | 80 tok | 120 tok |
characters (rendered) | 600 tok | 800 tok — round-robin trim with priority for status='active' |
themes | 100 tok | 150 tok |
settings | 150 tok | 200 tok |
motifs (only those where_planted_chapters includes a chapter ≤ current) | 150 tok | 200 tok |
planted_threads (only status in 'planted' | 'developing') | 200 tok |
voice_rules + tone_rules + do_not_use (deduped, capped to 12 lines total) | 100 tok | 150 tok |
| Format / instruction overhead | 80 tok | 100 tok |
| Total | ~1460 | ~1970 |
buildBiblePromptBlock enforces 1500 hard cap by trimming least-relevant items first (motifs not yet relevant to current chapter, then non-active characters, then themes).
summary_so_far: 300 tokens MAX (1200 chars, hard truncation). Auto-regenerated post-promote (see §1.4 $0.0005 with gpt-4o-mini) and runs async to not block promote.current_state updates) by a small dedicated GPT call summarizing the live takes' prose. This summary call is cheap (
Total chapter prompt budget after changes:
- System: ~900 (today) + 1500 (bible) + 50 (delta schema + JSON instruction) = ~2450 tokens
- User: ~200 (today) + 300 (summary so far) + ~100 (critic block when applicable) = ~600 tokens
- Grand total: ~3050 tokens input, comfortably under the 3K target stated in requirements
Output: prose ~1200 tokens + delta ~400 tokens = ~1600 tokens. Well within max_tokens: 3500.
3.4 Backward-compat for streaming
callChapterPromptGPT switches response_format from 'text' to 'json_object'. Risk: parsing failures. Mitigation: same fallback pattern as season-script.ts:502-509 (try parse, fall back to {}, then to { prose: completion.content } so we never lose the prose entirely).
4. Routes
All under /api/story-seasons/. Auth authenticateUser, requireSuperAdmin unless noted.
4.1 Story Bible routes
| Method | Path | Body | Response | Notes |
|---|---|---|---|---|
| GET | /:id/bible | — | { bible: StoryBible | null } | Current bible for a season |
| GET | /:id/bible/versions | — | { versions: BibleVersionSummary[] } | History list |
| GET | /:id/bible/versions/:version | — | { bible: StoryBible } | Specific historical version |
| POST | /:id/bible/bootstrap | { force?: boolean } | { bible: StoryBible } | Generate-from-plan; idempotent |
| POST | /:id/bible/backfill | — | { bible: StoryBible } | Generate from existing prose (for legacy seasons) |
| PUT | /:id/bible | { contents: Partial<StoryBible>; commit_message: string } | { bible: StoryBible } | Manual commit (creates new version) |
| GET | /:id/bible/deltas | ?status=pending|all | { deltas: BibleDeltaRow[] } | Approval queue |
| PATCH | /bible/deltas/:deltaId | { action: 'approve' | 'approve_with_edits' | 'reject'; edited?: BibleDelta; commit_message?: string; reason?: string } | { ok: true; new_version?: number } | Single endpoint for verdict (mirrors PATCH /takes/:takeId shape) |
4.2 Chapter Takes routes (mirrors season takes)
| Method | Path | Body | Response | Mirrors |
|---|---|---|---|---|
| POST | /episodes/:episodeId/takes | { framework_key?, model_key?, parameters?, label?, apply_critiques_from_take_id? } | { take: ChapterTake } | POST /:id/takes |
| GET | /episodes/:episodeId/takes | — | { takes: ChapterTake[] } (no prose) | GET /:id/takes |
| GET | /episodes/takes/:takeId | — | { take: ChapterTake } (full prose + prompt_snapshot) | GET /takes/:takeId |
| PATCH | /episodes/takes/:takeId | { action: 'promote' | 'reject' | 'archive'; reason? } | { ok: true } | PATCH /takes/:takeId |
| POST | /episodes/takes/:takeId/critique | { critic_keys: string[]; model_key? } | { critiques: EpisodeTakeCritique[] } | POST /takes/:takeId/critique |
| GET | /episodes/takes/:takeId/critiques | — | { critiques, decisions } | GET /takes/:takeId/critiques |
| PATCH | /episodes/critique-decisions/:findingId | { take_id, action, admin_text? } | { ok: true } | PATCH /critique-decisions/:findingId |
Note the path scheme: I avoid colliding with the existing /takes/:takeId season-level routes by namespacing under /episodes/takes/. This is uglier than alternatives but unambiguous; see Q5.
5. UI Changes
5.1 New components
apps/frontend/src/components/seasons/StoryBibleModal.tsx
Mirrors TakesModal structure. Tabs / sections within:
- Current bible — read-only rendered view of the live bible (collapsible sections per category)
- Editor — JSON-editable view per category (textarea per array, with a "structured" view for characters since they're load-bearing). Save → commits a new version
- Pending deltas — list of
episode_take_bible_deltas WHERE status='pending', with diff preview (delta on left, would-result-in-this on right) and Approve / Approve with edits / Reject buttons - History — version timeline with
commit_message,committed_at, restore button (which copies a version back into a new commit) - Bootstrap / Backfill controls — visible only when no bible exists yet
Initial scope (PR1 — see §6): tabs 1, 2, 5. Tabs 3, 4 land in PR2/PR3.
apps/frontend/src/components/seasons/ChapterTakesModal.tsx
Mirrors TakesModal.tsx (apps/frontend/src/components/seasons/TakesModal.tsx) almost line-for-line. Differences:
- Diff view is prose diff (paragraph-level), not segment list
- "Generate" form scoped to one episode (header shows "Chapter N: <title>")
- Critique panel works on prose paragraphs not script segments
- Promote button confirms with "This will replace Chapter N's live prose. Audio is stale until re-render."
Reuse: STATUS_TONE, formatCost, formatDuration, all critique sub-components — extract these into a shared apps/frontend/src/components/seasons/takes-shared.tsx to avoid duplication.
5.2 Modified components
apps/frontend/src/components/seasons/EpisodesAdminModal.tsx
Add per-chapter "📚 Takes (N)" button next to the existing "✍ Generate prose" / "↻ Re-render audio" buttons. Clicking it opens ChapterTakesModal for that episode. The badge N is the count of non-archived takes for the episode.
Add a one-line "📖 Story Bible" action in the modal header (next to 📋 Episodes · admin title row) that opens StoryBibleModal. Important UX decision: the bible button lives in the episodes admin modal because that's where novel-mode admins work, not in the parent SuperAdminPanel's tools row — keeps the bible adjacent to chapter actions.
Counter-proposal worth considering: also add it to SuperAdminPanel.tsx tools row (near "📋 Episodes" button) so admins can access it without going through the episodes modal first. Probably do both. See Q6.
Behavior change for existing "✍ Generate prose" button: in the takes-enabled world, this should redirect to "create a draft take" (not directly mutate chapter_prose). But for backwards compatibility on existing UI flow in PR1, leave it pointing at the legacy POST /:id/episodes/:n/generate-chapter until PR4 (chapter takes) ships. After PR4, swap it to open the chapter takes modal pre-filled with a "Generate" trigger.
apps/frontend/src/components/seasons/SuperAdminPanel.tsx
Add an "📖 Story Bible" button in the tools row that opens StoryBibleModal. Visible only when season.mode === 'novel' — the bible is novel-mode-only in v1 (see Q4 about journal mode).
apps/frontend/src/components/seasons/StageStrip.tsx
No changes needed in v1. Future: a "bible" stage chip could indicate "bibles last edited 3 days ago / 2 deltas pending."
6. Dependency Order — Six Independently-Shippable PRs
The user wants Step 1 to deliver value alone. PRs 1-3 ship the bible end-to-end before any take work. PRs 4-6 layer takes on top.
PR 1 — Bible schema + read injection (Step 1, MVP value)
Ships:
- Migration 116:
season_story_bibles+season_story_bible_versionstables + RLS - New service:
story-bible.tswithgetCurrentBible,bootstrapBibleFromPlan,buildBiblePromptBlock,commitBible,listBibleVersions,getBibleVersion - Routes:
GET /:id/bible,GET /:id/bible/versions,GET /:id/bible/versions/:v,POST /:id/bible/bootstrap,PUT /:id/bible season-novel-chapter.ts: inject bible block into chapter prompt (READ-only — no delta yet); recordbible_version_idin a new transient log linestory-seasons.ts:generateNovelSeasonPlan: hook to callbootstrapBibleFromPlanafter season insert- New UI:
StoryBibleModal(tabs 1, 2, 5 — current/editor/bootstrap) - Hook into
EpisodesAdminModalandSuperAdminPanelto open the modal
Unlocks: Immediate craft uplift. Existing seasons get a "Bootstrap" button; new seasons auto-bootstrap.
Does NOT ship: delta proposals, version restore, takes layer.
PR 2 — Bible deltas (proposal + approval queue)
Ships:
- Migration 117:
episode_take_bible_deltas(yes, the table referencesstory_episode_takeswhich doesn't exist yet — see note below) story-bible-deltas.tsservice- Routes:
GET /:id/bible/deltas,PATCH /bible/deltas/:deltaId - Chapter prompt now requests
{ prose, bible_delta }JSON (with parse fallback to bare prose) - Delta is saved to
episode_take_bible_deltaswithtake_id = nullfor now (see migration note) - UI:
StoryBibleModaltabs 3 + 4 (deltas queue + history)
Migration note: since chapter takes don't exist yet, in PR2 the table FK take_id references nothing real — we either (a) make take_id nullable in 117 and tighten in PR4, or (b) wait. Recommend (a): nullable take_id so PR2 can ship pre-takes-layer. The delta is associated with the episode + chapter generation in PR2, then with the take once PR4 lands.
Unlocks: Bibles stop being write-once. Story can evolve; admins keep canon clean.
PR 3 — Bible backfill + summary_so_far auto-update
Ships:
backfillBibleFromChaptersservice function +POST /:id/bible/backfillroute- A small post-chapter summary updater: when a chapter generates (PR1) or a take promotes (PR4), kick off a fire-and-forget GPT call to update
summary_so_far - UI button "Backfill from existing chapters" in
StoryBibleModalfor legacy seasons
Unlocks: Existing novel-mode seasons (commit 1a9eb11+) can opt into the bible. Bibles stay current as the story progresses.
PR 4 — Chapter takes core (Step 2 MVP)
Ships:
- Migration 118:
story_episode_takes+story_episodes.active_take_id episode-takes.tsservice:generateChapterTake,promoteChapterTake,rejectChapterTake,archiveChapterTake,listChapterTakes,getChapterTakeseason-novel-chapter.ts: refactorgenerateChapterto supportpersist:false, factor outgenerateChapterRaw- Routes:
POST /episodes/:episodeId/takes,GET /episodes/:episodeId/takes,GET /episodes/takes/:takeId,PATCH /episodes/takes/:takeId episode_take_bible_deltas.take_idFK tightened (was nullable in PR2)- UI:
ChapterTakesModal(no critique panel yet — that's PR5). "📚 Takes" button inEpisodesAdminModal - Extract shared bits to
takes-shared.tsx
Unlocks: Multi-take chapter iteration. chapter_prose becomes a derived value of "live take's prose," writes go through promote.
PR 5 — Chapter critiques
Ships:
- Migration 119:
episode_take_critiques+episode_critique_decisions episode-take-critiques.tsservice (clone ofseason-critiques.ts)- Routes:
POST /episodes/takes/:takeId/critique,GET /episodes/takes/:takeId/critiques,PATCH /episodes/critique-decisions/:findingId - Critique panel + iteration loop in
ChapterTakesModal
Unlocks: Same iteration loop the season-level takes have, applied to chapters. Critic findings → admin decisions → fold into next take's prompt.
PR 6 — Polish + redirects
Ships:
generateNextChapterrerouted through the takes layer (creates draft → auto-promotes since the user explicitly clicked "next")- "✍ Generate prose" button in
EpisodesAdminModalredirected to openChapterTakesModalwith "Generate" pre-fired - Cost/latency dashboards in
StoryBibleModalandChapterTakesModal - Audit: every place that wrote
story_episodes.chapter_prosedirectly is now blocked by a code-side helper that requiresvia_take_id
Unlocks: Single source of truth — chapter_prose is always traceable to a take.
7. Risks + Open Questions
The user should weigh in on these before we ship PR1.
Q1. Should the bible be public-readable? Public seasons have public chapter audio + prose. Should listeners with a magic URL be able to inspect the bible for a season they're hearing? My instinct says no — the bible includes "secret_knowledge" fields that explicitly leak character secrets. Recommend bible is super-admin-only RLS in v1. Flag for revisit if there's a "creator's commentary" feature later.
Q2. Auto-bootstrap on season creation, or opt-in?
Auto-bootstrap costs ~$0.001 + 3-5s of latency on the create-season call. Worth it for the default-on experience, but there's a case for bootstrap_bible: false opt-out (e.g. for tests or rapid prototype seasons). Recommend: auto-bootstrap by default, add opt-out flag in body.
Q3. Critique tables: parallel infrastructure vs. reuse with discriminator.
Picked parallel infrastructure (separate episode_take_critiques). The trade-off is double the table count for the critic loop. Reuse would require dropping the FK on season_take_critiques.take_id and adding a take_kind column, which feels cross-cutting and risky. I'd rather keep the season-level loop frozen than touch it. Worth a sanity check.
Q4. Should the bible feature work for journal-mode seasons too?
Today it's novel-only. But: journal-mode chapters could also benefit from a bible (character voices, motifs, settings). The bible block injected into season-script.ts:generateSeasonScript would tighten that prompt similarly. Not in this plan's scope, but the design should be journal-mode-compatible from day one — season_story_bibles.season_id doesn't care about mode. Recommend: build the schema mode-agnostic, only wire UI buttons for novel mode in v1.
Q5. Route path scheme for chapter takes.
I proposed /episodes/takes/:takeId to avoid colliding with /takes/:takeId (season-level). Alternatives: /chapter-takes/:takeId (clean but adds another resource name), or a shared route with a ?kind=episode|season query string (clever, fragile). My pick is /episodes/takes/:takeId — it's explicit and uses hierarchy that maps to the URL surface. Worth confirming.
Q6. Bible button location: episodes modal, super-admin tools row, or both?
Both adds the button in two places — nice for discoverability, slightly redundant. One-of weakens discoverability. Recommend: tools row in SuperAdminPanel only (don't crowd EpisodesAdminModal further), but the chapter takes modal cross-links to the bible.
Q7. summary_so_far update strategy.
Three options: (a) regenerate from scratch every promote (token-expensive but simple); (b) incremental — give the LLM the old summary + the new chapter and ask for an update (cheaper, drift risk); (c) take the model's update_summary_so_far field from the delta verbatim. Recommend (c) with (a) as a "rebuild summary" admin button for drift correction.
Q8. What is current_state for a new character introduced by a chapter delta?
The model adds a character with delta. It includes current_state: "as of Ch 3: just arrived in town." Should the bible store that string verbatim, or normalize? Recommend: verbatim, with a guardrail in the delta validator that rejects deltas missing current_state on add_characters entries.
8. Backwards Compatibility
8.1 Existing novel-mode seasons (post-1a9eb11, pre-bible)
A handful of seasons exist in production today with mode='novel' and chapter_prose already populated. They have no bible. The plan handles this:
- PR1 ship: their
season_story_biblesrow is absent.getCurrentBiblereturnsnull.buildBiblePromptBlock(null, …)returns an empty string — chapter generation prompt is unchanged from today's behavior. The "✍ Generate prose" button still works. - Bootstrap path: in
StoryBibleModal, an empty-state shows a "Bootstrap from plan" button. One click →POST /:id/bible/bootstrap→ bible is generated fromseason.premise + setting + episode list. - Backfill path (PR3): a "Backfill from existing chapters" button that reads
story_episodes.chapter_prosefor every triggered chapter and asks GPT to derive a bible from the actual prose (better fidelity than from-plan since the writing has already happened and locked in details).
No data migration is required. No existing chapter_prose values are mutated.
8.2 Existing journal-mode seasons
Untouched. Bible button hidden in v1 (Q4). All journal pipelines run unchanged.
8.3 Existing chapter_prose after chapter takes ship (PR4)
Today: chapter_prose is the canonical text; written by generateChapter.
After PR4: chapter_prose is the live take's prose, written only by promoteChapterTake.
Migration approach for existing prose:
- For every
(season_id, episode_number)with non-nullchapter_proseandseason.mode='novel', insert one syntheticstory_episode_takesrow withstatus='live',label='legacy_v1',framework_key='unknown',model_key='legacy',prose=existing chapter_prose,bible_version_id=null,prompt_snapshot=null,promoted_at=now(),created_at=story_episodes.updated_at(or a sensible default). - Set
story_episodes.active_take_idto that take. - Migration runs in PR4 as part of 118. Idempotent: skip rows where
active_take_id IS NOT NULL.
This keeps every chapter pointed at a live take — the takes UI shows "1 take (legacy)" rather than the disorienting "0 takes" for chapters that were generated pre-feature.
8.4 Audio invalidation
After a chapter take is promoted:
chapter_prosechanges →rebuildSegmentsFromCacheruns → segment rows rebuilt with newtext_content- The cache key in
rebuildSegmentsFromCache(episode_number|segment_type|voice_id|voice_direction|text_content) — new prose ≠ old prose — produces cache misses on the changed segments only - Per-chapter audio (
chapter_audio_url) is not automatically re-rendered; it's stamped stale, exactly as today - The "↻ Re-render audio" button in
EpisodesAdminModalre-renders only the changed segments via TTS cache (already implemented, see commit14e287a)
No edge case found. The takes layer integrates cleanly with the existing per-segment cache.
8.5 RLS and policies
Bible tables RLS should mirror season_script_takes:
- Super-admin all-access policies on
season_story_bibles,season_story_bible_versions,episode_take_bible_deltas - Public read on
season_story_biblesonly if we decide bibles are public (Q1) — if not, only super-admin
Chapter takes follow the existing season_script_takes pattern exactly:
- Super-admin all-access
- Public read on live takes when season is
active/completed - Partial unique index on
WHERE status='live'per episode
8.6 Cost impact
Per chapter generation:
- Today: ~1100 input + ~1200 output tokens with gpt-4o-mini ≈ $0.0009
- After PR1 (bible read): ~3050 input + ~1200 output ≈ $0.0014 (+55%, still trivial absolute)
- After PR2 (delta): ~3050 input + ~1600 output ≈ $0.0017
- summary_so_far updater: ~500 input + ~200 output ≈ $0.0003 per chapter promote
A 6-chapter season costs ~$0.01 in LLM calls today; ~$0.02 after this plan. Within noise.
Summary Table
| Concern | Resolution |
|---|---|
| Bible storage | One season_story_bibles row per season + append-only season_story_bible_versions history |
| Bible bootstrap | Auto on novel-mode season create; manual button for legacy seasons; backfill-from-prose for retro |
| Bible injection | New token-budgeted block in chapter prompt; defaults to ~1500 tokens; trims by relevance |
| Bible updates | Model proposes delta JSON alongside prose; admin queue approves/edits/rejects → new version |
| Chapter takes | Direct mirror of season_script_takes at episode level |
| Chapter critiques | Parallel tables (not reuse) — cleaner, isolates churn |
| Backwards compat | Empty bible = no-op; legacy chapter_prose becomes a synthetic 'legacy_v1' live take |
| UI surface | New StoryBibleModal and ChapterTakesModal; small button additions to existing modals |
| Ship order | 6 PRs; PRs 1-3 deliver Step 1 (bible) standalone; PRs 4-6 add Step 2 (takes) on top |