plans

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)

  1. Story Bible — versioned canon owned by the season; injected into every chapter prompt; chapters can propose deltas that an admin approves.
  2. Chapter-level Takes — mirror of season_script_takes at the episode level, so every prose generation creates a draft and promotion is the only path that mutates story_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.

ColumnTypeNotes
iduuid PKgen_random_uuid()
season_iduuid NOT NULLFK story_seasons(id) on delete cascade, UNIQUE (one current bible per season)
versionint NOT NULL default 1bumps on every commit
charactersjsonb NOT NULL default '[]'::jsonbarray of {key, name, arc, voice_brief, physical, inner_life, current_state, secret_knowledge, first_appears_chapter, status} (see §1.4)
themesjsonb NOT NULL default '[]'::jsonbarray of {key, statement, expression_notes}
settingsjsonb NOT NULL default '[]'::jsonbarray of {key, name, sensory_palette, rules}
motifsjsonb NOT NULL default '[]'::jsonbarray of {key, image_or_phrase, where_planted_chapters[], where_paid_off_chapters[]}
planted_threadsjsonb NOT NULL default '[]'::jsonbarray of `{key, planted_in_chapter, payoff_target_chapter, status: 'planted'
voice_rulesjsonb NOT NULL default '[]'::jsonbarray of strings — narrator-level voice constraints
tone_rulesjsonb NOT NULL default '[]'::jsonbarray of strings — emotional pitch / pacing rules
do_not_usejsonb NOT NULL default '[]'::jsonbarray of strings — banned words/phrases/devices
synopsistextshort paragraph the model can use as overall through-line; capped at ~400 chars in prompt
summary_so_fartextrolling recap of already-written chapters; auto-updated post-promotion of chapter takes (§ Step 2 hook)
bootstrapped_fromtext'manual' | 'plan' | 'retroactive'; provenance only
created_attimestamptz NOT NULL default now()
updated_attimestamptz NOT NULL default now()
created_by_user_iduuidFK profiles(id) on delete set null
updated_by_user_iduuidFK profiles(id) on delete set null

Indexes:

  • UNIQUE (season_id) — partial index, matches season_script_takes style.

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.

ColumnTypeNotes
iduuid PK
season_iduuid NOT NULLFK story_seasons(id) on delete cascade
versionint NOT NULLmatches the snapshotted version number
characters, themes, settings, motifs, planted_threads, voice_rules, tone_rules, do_not_use, synopsis, summary_so_farjsonb / textfull snapshot of the bible at that version
commit_messagetextwhat the editor said about this delta (e.g. "added Mara's secret about the lake")
committed_by_user_iduuidFK profiles(id) on delete set null
committed_attimestamptz NOT NULL default now()
source_take_iduuidnullable 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.

ColumnTypeNotes
iduuid PK
take_iduuid NOT NULLFK story_episode_takes(id) on delete cascade
season_iduuid NOT NULLFK story_seasons(id) on delete cascade
deltajsonb NOT NULLstructured patch — see §1.5
model_notestextfree-text from the model explaining what it changed and why
statustext NOT NULL default 'pending'check in ('pending', 'applied', 'rejected', 'superseded')
applied_to_versionintthe bible version that resulted from accepting this delta; null until accepted
reviewed_by_user_iduuidFK profiles(id) on delete set null
reviewed_attimestamptz
created_attimestamptz 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.

ColumnTypeNotes
iduuid PK
episode_iduuid NOT NULLFK story_episodes(id) on delete cascade
season_iduuid NOT NULLFK story_seasons(id) on delete cascade (denormalized for query speed; matches takes pattern)
episode_numberint NOT NULLdenormalized; reduces joins for the takes list view
labeltext NOT NULL${model_key}_${framework_key}, suffix -2,-3,... on collision (mirror of defaultLabel in season-takes.ts)
framework_keytext NOT NULL
model_keytext NOT NULL
prompt_template_versionint NOT NULL default 1bump when chapter prompt structure changes (e.g. when bible injection lands → bump to 2)
parametersjsonb NOT NULL default '{}'::jsonbactually-used { temperature, max_tokens }
prosetext NOT NULLthe generated chapter body (replaces segments jsonb in season takes — chapter takes are text-native)
prompt_snapshotjsonbfull system+user prompt strings actually sent to GPT, for reproducibility / debugging
bible_version_iduuidnullable FK season_story_bible_versions(id) on delete set null — the bible version this take was generated against
statustext NOT NULL default 'draft'check in ('draft', 'live', 'archived', 'rejected') (mirror)
review_notestext
generated_by_user_iduuidFK profiles(id) on delete set null
cost_centsnumeric
duration_msint
promoted_attimestamptz
archived_attimestamptz
created_attimestamptz 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 uuid referencing story_episode_takes(id) on delete set null (provenance, parallels story_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_critiquesepisode_take_critiques with take_idstory_episode_takes(id), and clone season_critique_decisionsepisode_critique_decisions with take_idstory_episode_takes(id). All other columns identical. Indexes identical. RLS identical.

1.3 Existing-table changes

TableChangeRationale
story_episodesadd active_take_id uuid REFERENCES story_episode_takes(id) ON DELETE SET NULLprovenance pointer; parallels story_seasons.active_take_id
story_seasonsadd bible_id uuid REFERENCES season_story_bibles(id) ON DELETE SET NULLoptional — 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_proseunchanged in schemasemantics 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 key so deltas can address things by id, not by index in an array.
  • current_state on 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.
  • status on 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: string parameter and splices it into the system prompt above the framework directive.
  • Accepts an optional summary_so_far: string parameter 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 (generateChapterTakepromoteChapterTake) 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 STATE block (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's summary_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:

SectionSoft capHard cap
synopsis80 tok120 tok
characters (rendered)600 tok800 tok — round-robin trim with priority for status='active'
themes100 tok150 tok
settings150 tok200 tok
motifs (only those where_planted_chapters includes a chapter ≤ current)150 tok200 tok
planted_threads (only status in 'planted''developing')200 tok
voice_rules + tone_rules + do_not_use (deduped, capped to 12 lines total)100 tok150 tok
Format / instruction overhead80 tok100 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 current_state updates) by a small dedicated GPT call summarizing the live takes' prose. This summary call is cheap ($0.0005 with gpt-4o-mini) and runs async to not block promote.

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

MethodPathBodyResponseNotes
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)

MethodPathBodyResponseMirrors
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:

  1. Current bible — read-only rendered view of the live bible (collapsible sections per category)
  2. 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
  3. 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
  4. History — version timeline with commit_message, committed_at, restore button (which copies a version back into a new commit)
  5. 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_versions tables + RLS
  • New service: story-bible.ts with getCurrentBible, 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); record bible_version_id in a new transient log line
  • story-seasons.ts:generateNovelSeasonPlan: hook to call bootstrapBibleFromPlan after season insert
  • New UI: StoryBibleModal (tabs 1, 2, 5 — current/editor/bootstrap)
  • Hook into EpisodesAdminModal and SuperAdminPanel to 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 references story_episode_takes which doesn't exist yet — see note below)
  • story-bible-deltas.ts service
  • 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_deltas with take_id = null for now (see migration note)
  • UI: StoryBibleModal tabs 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:

  • backfillBibleFromChapters service function + POST /:id/bible/backfill route
  • 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 StoryBibleModal for 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.ts service: generateChapterTake, promoteChapterTake, rejectChapterTake, archiveChapterTake, listChapterTakes, getChapterTake
  • season-novel-chapter.ts: refactor generateChapter to support persist:false, factor out generateChapterRaw
  • Routes: POST /episodes/:episodeId/takes, GET /episodes/:episodeId/takes, GET /episodes/takes/:takeId, PATCH /episodes/takes/:takeId
  • episode_take_bible_deltas.take_id FK tightened (was nullable in PR2)
  • UI: ChapterTakesModal (no critique panel yet — that's PR5). "📚 Takes" button in EpisodesAdminModal
  • 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.ts service (clone of season-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:

  • generateNextChapter rerouted through the takes layer (creates draft → auto-promotes since the user explicitly clicked "next")
  • "✍ Generate prose" button in EpisodesAdminModal redirected to open ChapterTakesModal with "Generate" pre-fired
  • Cost/latency dashboards in StoryBibleModal and ChapterTakesModal
  • Audit: every place that wrote story_episodes.chapter_prose directly is now blocked by a code-side helper that requires via_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_bibles row is absent. getCurrentBible returns null. 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 from season.premise + setting + episode list.
  • Backfill path (PR3): a "Backfill from existing chapters" button that reads story_episodes.chapter_prose for 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-null chapter_prose and season.mode='novel', insert one synthetic story_episode_takes row with status='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_id to 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:

  1. chapter_prose changes → rebuildSegmentsFromCache runs → segment rows rebuilt with new text_content
  2. 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
  3. Per-chapter audio (chapter_audio_url) is not automatically re-rendered; it's stamped stale, exactly as today
  4. The "↻ Re-render audio" button in EpisodesAdminModal re-renders only the changed segments via TTS cache (already implemented, see commit 14e287a)

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_bibles only 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

ConcernResolution
Bible storageOne season_story_bibles row per season + append-only season_story_bible_versions history
Bible bootstrapAuto on novel-mode season create; manual button for legacy seasons; backfill-from-prose for retro
Bible injectionNew token-budgeted block in chapter prompt; defaults to ~1500 tokens; trims by relevance
Bible updatesModel proposes delta JSON alongside prose; admin queue approves/edits/rejects → new version
Chapter takesDirect mirror of season_script_takes at episode level
Chapter critiquesParallel tables (not reuse) — cleaner, isolates churn
Backwards compatEmpty bible = no-op; legacy chapter_prose becomes a synthetic 'legacy_v1' live take
UI surfaceNew StoryBibleModal and ChapterTakesModal; small button additions to existing modals
Ship order6 PRs; PRs 1-3 deliver Step 1 (bible) standalone; PRs 4-6 add Step 2 (takes) on top
Story Bible And Chapter Takes — Docs | HiveJournal