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_idstory_episodes:episode_number, title, chapter_prose, triggered, dramatic_roleseason_character_voices:character_key, character_name, voice_id, voice_directionprofiles: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_idstory_cast:persona_id, voice_id, voice_name, ai_personas(display_name)season_character_voices:character_key, character_name, voice_id, voice_directionseason_audio_segments:position, episode_number, segment_type, text_content, voice_id, voice_directionprofiles:full_name
- screenplay (uses the existing
getScreenplayProjectloader, 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
- Pick a novel-mode season that has ≥1 triggered chapter with prose (one you've exported before, ideally, so you can compare).
- 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 foundfor a season that exists → loader query issue.- Wrong/blank author → the
profiles.full_namelookup 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
- Pick a journal entry with real content.
- Create a reel from it; let it draft (status →
drafting→rendering_audio→rendering_video→ready).
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'sjournal_entriesquery failed.- An empty/garbage script, or a draft that ignores the entry → the section
title/prosemapping is wrong (prompt lost the source text).
Note: the reel's audio render now also goes through the IR (
reelScriptToSegments→ 2 dialogue segments + cast;renderReelAudioresolves 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
- Pick a season that has a screenplay project with at least one scene that has
a
fountain_body. - 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 projectfor a season that has one → unrelated to the IR (samegetScreenplayProjectas before), but worth noting.- Scenes out of order, a dropped non-blank scene, or a missing/duplicated title
page → the
assembleFountainFromIRprojection 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
- Pick a journal-mode season that already has a generated script
(
season_audio_segmentspopulated) and a cast with assigned voices. - Re-render the audio (force a fresh render so TTS actually runs, not the cache).
- 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_castreverse-mapping /coverRowVoicesfailed). This is the specific regression this design guards against. - A
500mentioning a column → a column inbuildAudiobookIRis wrong (it logsaudiobook IR build failed; using raw voice idsand 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
renderSeasonAudioAND novel-moderenderChapterAudio(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/errorstriage 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.
- Column-not-found → fix the column name in
apps/backend/src/services/render-kit/ir/build.ts(orscreenplay-ir.tsinput mapping) and redeploy. The pure assemblers/golden tests don't change. - Output diverged → compare against the old logic in the PR diff (#829 /
#831 / #832). The old
fetchSeasonForPrint/fetchEntry/assembleFountainbodies are in the PR's "before" side. - 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/errorsshows 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.