product

Production IR — prod test guide

What this is for. The Production IR adoptions (#829 print, #831 reel-source, #832 screenplay) are behavior-preserving by construction and golden-tested — but the golden tests only cover the pure assemblers/projections. The new DB loaders (buildProductionIR) run real queries whose column names were inferred from the code, and the Supabase MCP points at a different project than prod (see CLAUDE.md), so the loaders have never run against the prod schema. This guide is the prod smoke test to confirm they resolve real columns and produce identical output.

If all three tests pass, the IR is validated end-to-end on prod and the adoption is safe. If one fails, it's isolated to a single re-pointed fetch — see § If a test fails.


The risk surface — columns the loaders SELECT

A failure here looks like a column ... does not exist / 400 / 500. The columns each loader reads (from apps/backend/src/services/render-kit/ir/build.ts and screenplay-ir.ts):

  • season loader (buildSeasonIR):
    • story_seasons: id, title, premise, genre, mode, poster_url, narrator_voice_id, owner_user_id
    • story_episodes: episode_number, title, chapter_prose, triggered, dramatic_role
    • season_character_voices: character_key, character_name, voice_id, voice_direction
    • profiles: full_name
  • journal_entry loader (buildEntryIR):
    • journal_entries: id, title, content
  • audiobook loader (buildAudiobookIR):
    • story_seasons: id, title, premise, genre, mode, poster_url, narrator_voice_id, owner_user_id
    • story_cast: persona_id, voice_id, voice_name, ai_personas(display_name)
    • season_character_voices: character_key, character_name, voice_id, voice_direction
    • season_audio_segments: position, episode_number, segment_type, text_content, voice_id, voice_direction
    • profiles: full_name
  • screenplay (uses the existing getScreenplayProject loader, unchanged — so lower risk; the IR projection is pure):
    • screenplay_projects / screenplay_scenes (already in prod use)

All of these are columns the old code already read, so they should exist — the test is to confirm.


Test 1 — Print export (exercises the season loader)

Route: GET /api/story-seasons/:id/print-export (super-admin) — also the "Print" / "Export interior PDF" button on the season's admin page.

Steps

  1. Pick a novel-mode season that has ≥1 triggered chapter with prose (one you've exported before, ideally, so you can compare).
  2. Trigger the print export.

Expect (pass): the interior PDF downloads as before — same chapters (only triggered + non-empty prose), same title, same author (the season owner's profiles.full_name, or Graphene Author if none), same chapter order + word count.

Fail signals:

  • 500 / error mentioning a column → a column name in the season loader is wrong.
  • Season not found for a season that exists → loader query issue.
  • Wrong/blank author → the profiles.full_name lookup changed behavior.
  • Missing/extra chapters → the triggered + prose filter changed.

Test 2 — Reel from entry (exercises the journal_entry loader)

Route: POST /api/reels/from-entry (authenticated) — or the admin reels UI's "create from entry" action. Body includes the source entry_id (+ optional template_key, cta_target).

Steps

  1. Pick a journal entry with real content.
  2. Create a reel from it; let it draft (status → draftingrendering_audiorendering_videoready).

Expect (pass): the reel drafts a two-voice script (script_json populates with user_line + odessa_line) referencing the entry, then renders audio + video — exactly as before. The draft quality should be unchanged (the prompt is byte-identical; only the entry now loads via the IR).

Fail signals:

  • Entry not found: <id> for an entry that exists → the journal_entry loader's journal_entries query failed.
  • An empty/garbage script, or a draft that ignores the entry → the section title/prose mapping is wrong (prompt lost the source text).

Note: the reel's audio render now also goes through the IR (reelScriptToSegments → 2 dialogue segments + cast; renderReelAudio resolves voices from the cast). It's byte-equivalent by construction + golden-tested, but this prod test is also the chance to eyeball a rendered reel (audio cadence + burned-in captions) to confirm the render-half is sound. The video render is unchanged.


Test 3 — Screenplay export (exercises the screenplay IR projection)

Routes: GET /api/screenplay/projects/:seasonId/fountain (the assembled Fountain doc), GET /api/screenplay/projects/:seasonId/fdx, and GET /api/screenplay/projects/:seasonId/print (super-admin). Or the screenplay admin page's export buttons.

Steps

  1. Pick a season that has a screenplay project with at least one scene that has a fountain_body.
  2. Export the Fountain (and FDX/print) for it.

Expect (pass): the Fountain document is identical to before — title page (Title: / Credit: Adapted from the novella / optional Notes: from the logline / Draft date:), then ===, then the scenes' Fountain bodies in idx order (trimmed, blanks dropped). The FDX/print exports (which just parse the assembled Fountain) should be byte-identical.

Fail signals:

  • No screenplay project for a season that has one → unrelated to the IR (same getScreenplayProject as before), but worth noting.
  • Scenes out of order, a dropped non-blank scene, or a missing/duplicated title page → the assembleFountainFromIR projection diverged (shouldn't happen — it's golden-tested, but this is the prod confirmation).

Test 4 — Audiobook render (exercises the audiobook loader + render adoption)

Route: the season's Render audio action (Stage 4) — renderSeasonAudio. Use a journal-mode season with a multi-persona cast (so the persona-voice routing is actually exercised, not just the narrator).

Steps

  1. Pick a journal-mode season that already has a generated script (season_audio_segments populated) and a cast with assigned voices.
  2. Re-render the audio (force a fresh render so TTS actually runs, not the cache).
  3. Listen to the result.

Expect (pass): the audiobook sounds exactly as before — each persona's lines in that persona's voice, narration in the narrator voice, same order, same chapters. The voice id for each segment is now resolved through the IR's cast, but the cast is built to cover every row voice, so the resolved id equals the row's own voice — byte-equivalent.

Fail signals:

  • Every persona line suddenly in the narrator voice → the IR cast didn't cover the persona voices (the story_cast reverse-mapping / coverRowVoices failed). This is the specific regression this design guards against.
  • A 500 mentioning a column → a column in buildAudiobookIR is wrong (it logs audiobook IR build failed; using raw voice ids and falls back, so the render should still succeed with the old behavior — check Railway logs for the warn).
  • Wrong segment order / missing chapters → unrelated to the IR (the raw rows still drive iteration + cache + per-chapter byproduct).

Note: both audiobook render paths now read the IR — journal-mode renderSeasonAudio AND novel-mode renderChapterAudio (season-novel-chapter-audio.ts). For novel-mode, also render a chapter of a novel-mode season with character-voice assignments and confirm dialogue lines stay in their assigned character voices (not collapsed to the narrator).


Where errors show up

  • /dashboard/admin/errors — backend catches write to service_errors. Run /errors triage after testing.
  • The HTTP response body of the export/generate call (the route returns the error message).
  • Railway logs for the backend service (full stack traces).

If a test fails

Each adoption is one re-pointed fetch — the blast radius is a single feature, and the previous logic is preserved in git history.

  1. Column-not-found → fix the column name in apps/backend/src/services/render-kit/ir/build.ts (or screenplay-ir.ts input mapping) and redeploy. The pure assemblers/golden tests don't change.
  2. Output diverged → compare against the old logic in the PR diff (#829 / #831 / #832). The old fetchSeasonForPrint / fetchEntry / assembleFountain bodies are in the PR's "before" side.
  3. Full revert (worst case) → revert the specific PR; the surfaces are independent, so reverting one doesn't touch the others or the render kit.

Sign-off checklist

  • Print export of a known novel season → identical PDF, no errors
  • Reel from a journal entry → drafts + renders, script references the entry
  • Screenplay Fountain/FDX export → identical document
  • Audiobook render of a journal-mode season → personas in their own voices (no narrator takeover)
  • /dashboard/admin/errors shows no new IR-related groups after testing

When all four are checked, the IR is validated on prod. The remaining work is the behavioral render-halves + audiobook — see EMBERKILN_PRODUCTION_IR_DESIGN.md § Next steps.

EMBERKILN PRODUCTION IR TEST GUIDE — Docs | HiveJournal