import { createHash } from 'node:crypto'; import { cinemaCharacterSheetHash, projectCinemaEvidenceState } from './cinema-evidence.js'; import type { CinemaEvidenceLedgerArtifact } from './cinema-evidence-types.js'; import type { CinemaPlanningPackage } from './cinema-project-planner.js'; import { stableCinemaJson } from './cinema-shot-compiler.js'; import type { CinemaCharacterSheetArtifact, CinemaSha256 } from './cinema-types.js'; export interface CinemaPreflightCheck { checkId: 'planning-package' | 'character-sheet' | 'recognition-lock' | 'rights-clearance' | 'voice-release' | 'dialogue-authorship' | 'provider-route' | 'exact-quote' | 'spend-authorization'; status: 'pass' | 'blocked' | 'external-gate'; message: string; evidenceIds: string[]; } export interface CinemaPreflightReportArtifact { schemaVersion: 1; projectSlug: string; generatedAt: string; planningReceiptHash: CinemaSha256; evidenceLedgerHash: CinemaSha256 | null; checks: CinemaPreflightCheck[]; compileReady: boolean; executable: false; blockers: string[]; contentHash: CinemaSha256; } function hash(value: unknown): CinemaSha256 { return `sha256:${createHash('sha256').update(stableCinemaJson(value)).digest('hex')}`; } export function createCinemaPreflightReport(input: { planning: CinemaPlanningPackage; evidenceLedger: CinemaEvidenceLedgerArtifact | null; characterSheets: CinemaCharacterSheetArtifact[]; generatedAt?: string; }): CinemaPreflightReportArtifact { const effective = projectCinemaEvidenceState(input.planning, input.evidenceLedger); const sheetHashes = new Map(input.characterSheets.map((sheet) => [sheet.characterVersionId, cinemaCharacterSheetHash(sheet)])); const approvedSheets = new Map((input.evidenceLedger?.decisions ?? []).filter((decision) => decision.kind === 'character-sheet').map((decision) => [decision.characterVersionId, decision.sheetHash])); const missingSheets = effective.assetEvidence.assets.filter((asset) => { if (asset.kind !== 'hero' && asset.kind !== 'supporting') return false; const storedHash = sheetHashes.get(asset.versionId); return !storedHash || storedHash !== approvedSheets.get(asset.versionId); }); const unlockedAssets = effective.assetEvidence.assets.filter((asset) => asset.lockStatus !== 'locked'); const unclearedRights = effective.rightsRegistry.records.filter((record) => record.status !== 'cleared'); const unlockedVoices = effective.voiceRegistry.characters.filter((voice) => voice.speaking && (voice.lockStatus !== 'locked' || voice.releaseStatus !== 'cleared')); const checks: CinemaPreflightCheck[] = [ { checkId: 'planning-package', status: 'pass', message: 'Current immutable planning receipt validated.', evidenceIds: [input.planning.receipt.contentHash] }, { checkId: 'character-sheet', status: missingSheets.length === 0 ? 'pass' : 'blocked', message: missingSheets.length === 0 ? 'Every hero version has a matching media-verified single-face sheet decision.' : `Missing approved sheet versions: ${missingSheets.map((asset) => asset.versionId).join(', ')}`, evidenceIds: [...new Set(sheetHashes.values())] }, { checkId: 'recognition-lock', status: unlockedAssets.length === 0 ? 'pass' : 'blocked', message: unlockedAssets.length === 0 ? 'Every planned hero recognition condition passed with distinct evidence.' : `Unresolved recognition versions: ${unlockedAssets.map((asset) => `${asset.versionId}:${asset.lockStatus}`).join(', ')}`, evidenceIds: [...new Set(effective.assetEvidence.assets.flatMap((asset) => asset.conditions.flatMap((condition) => condition.evidenceIds)))] }, { checkId: 'rights-clearance', status: unclearedRights.length === 0 ? 'pass' : 'blocked', message: unclearedRights.length === 0 ? 'Every planned asset has evidence-bearing rights clearance.' : `Unresolved rights: ${unclearedRights.map((record) => `${record.assetId}:${record.status}`).join(', ')}`, evidenceIds: [...new Set(effective.rightsRegistry.records.flatMap((record) => record.evidenceIds))] }, { checkId: 'voice-release', status: unlockedVoices.length === 0 ? 'pass' : 'blocked', message: unlockedVoices.length === 0 ? 'Every speaking character has audition and release evidence, or voice is not required.' : `Unresolved voices: ${unlockedVoices.map((voice) => voice.characterId).join(', ')}`, evidenceIds: [...new Set(effective.voiceRegistry.characters.flatMap((voice) => [...voice.auditionEvidenceIds, ...voice.releaseEvidenceIds]))] }, { checkId: 'dialogue-authorship', status: input.planning.story.creativeContract.dialoguePosture === 'dialogue-led' ? 'blocked' : 'pass', message: input.planning.story.creativeContract.dialoguePosture === 'dialogue-led' ? 'Dialogue-led work requires authored text and timing; no line is inferred from a compact premise.' : 'No mandatory dialogue line needs authorship before shot compilation.', evidenceIds: [] }, { checkId: 'provider-route', status: 'external-gate', message: 'Compilation remains provider-neutral; select and discover an explicit route before quote.', evidenceIds: [] }, { checkId: 'exact-quote', status: 'external-gate', message: 'No current content-addressed provider quote exists.', evidenceIds: [] }, { checkId: 'spend-authorization', status: 'external-gate', message: 'No separate exact spend authorization exists.', evidenceIds: [] }, ]; const compileReady = checks.filter((check) => ['character-sheet', 'recognition-lock', 'rights-clearance', 'voice-release', 'dialogue-authorship'].includes(check.checkId)).every((check) => check.status === 'pass'); const blockers = checks.filter((check) => check.status !== 'pass').map((check) => `${check.checkId}: ${check.message}`); const reportInput = { schemaVersion: 1 as const, projectSlug: input.planning.receipt.projectSlug, generatedAt: input.generatedAt ?? new Date().toISOString(), planningReceiptHash: input.planning.receipt.contentHash, evidenceLedgerHash: input.evidenceLedger?.contentHash ?? null, checks, compileReady, executable: false as const, blockers, }; return { ...reportInput, contentHash: hash(reportInput) }; } export function validateCinemaPreflightReport(report: CinemaPreflightReportArtifact): { ok: boolean; issues: Array<{ code: string; path: string; message: string }> } { const issues: Array<{ code: string; path: string; message: string }> = []; const ids = report.checks.map((check) => check.checkId); if (new Set(ids).size !== ids.length) issues.push({ code: 'duplicate-check', path: 'checks', message: 'check IDs must be unique' }); const required = ['planning-package', 'character-sheet', 'recognition-lock', 'rights-clearance', 'voice-release', 'dialogue-authorship', 'provider-route', 'exact-quote', 'spend-authorization']; for (const id of required) if (!ids.includes(id as CinemaPreflightCheck['checkId'])) issues.push({ code: 'missing-check', path: 'checks', message: `missing ${id}` }); const expectedCompileReady = report.checks.filter((check) => ['character-sheet', 'recognition-lock', 'rights-clearance', 'voice-release', 'dialogue-authorship'].includes(check.checkId)).every((check) => check.status === 'pass'); if (report.compileReady !== expectedCompileReady) issues.push({ code: 'compile-readiness', path: 'compileReady', message: 'does not match evidence gates' }); if (report.executable !== false) issues.push({ code: 'fabricated-execution', path: 'executable', message: 'preflight cannot authorize provider execution' }); const { contentHash, ...input } = report; if (contentHash !== hash(input)) issues.push({ code: 'content-hash', path: 'contentHash', message: 'does not match canonical bytes' }); return { ok: issues.length === 0, issues }; }