import { createHash } from 'node:crypto'; import { readFile } from 'node:fs/promises'; import { resolve } from 'node:path'; import { createCinemaAssetEvidence, createCinemaRightsRegistry, createCinemaVoiceRegistry, validateCinemaAssetEvidence, validateCinemaRightsRegistry, validateCinemaVoiceRegistry, type CinemaAssetEvidenceArtifact, type CinemaRightsRegistryArtifact, type CinemaVoiceRegistryArtifact, } from './cinema-assurance.js'; import type { CinemaEvidenceDecision, CinemaEvidenceDecisionInput, CinemaEvidenceFile, CinemaEvidenceIssue, CinemaEvidenceLedgerArtifact, CinemaEvidenceValidation } from './cinema-evidence-types.js'; import type { CinemaPlanningPackage } from './cinema-project-planner.js'; import { stableCinemaJson } from './cinema-shot-compiler.js'; import type { CinemaSha256 } from './cinema-types.js'; import type { CinemaCharacterSheetArtifact } from './cinema-types.js'; function hash(value: unknown): CinemaSha256 { return `sha256:${createHash('sha256').update(stableCinemaJson(value)).digest('hex')}`; } export async function captureCinemaEvidenceFile(path: string): Promise { const bytes = await readFile(path); return { sourcePath: resolve(path), contentHash: `sha256:${createHash('sha256').update(bytes).digest('hex')}`, bytes: bytes.length, }; } export function cinemaEvidenceDecisionHash(decision: CinemaEvidenceDecisionInput): CinemaSha256 { return hash(decision); } export function createCinemaEvidenceDecision(decision: CinemaEvidenceDecisionInput): CinemaEvidenceDecision { return { ...structuredClone(decision), contentHash: cinemaEvidenceDecisionHash(decision) } as CinemaEvidenceDecision; } export function cinemaEvidenceLedgerHash(ledger: Omit): CinemaSha256 { return hash(ledger); } export function cinemaCharacterSheetHash(sheet: CinemaCharacterSheetArtifact): CinemaSha256 { return hash(sheet); } function add(issues: CinemaEvidenceIssue[], code: string, path: string, message: string): void { issues.push({ code, path, message }); } function text(value: string | null): boolean { return typeof value === 'string' && value.trim().length > 0; } function evidence(issues: CinemaEvidenceIssue[], files: CinemaEvidenceFile[], path: string): void { if (files.length === 0) add(issues, 'missing-evidence', path, 'requires at least one evidence file'); const hashes = files.map((file) => file.contentHash); if (new Set(hashes).size !== hashes.length) add(issues, 'duplicate-evidence', path, 'evidence content hashes must be unique'); files.forEach((file, index) => { if (!text(file.sourcePath) || !/^sha256:[a-f0-9]{64}$/.test(file.contentHash) || !Number.isInteger(file.bytes) || file.bytes <= 0) add(issues, 'invalid-evidence', `${path}[${index}]`, 'requires path, SHA-256 and positive byte count'); }); } export function validateCinemaEvidenceLedger( ledger: CinemaEvidenceLedgerArtifact, planning?: CinemaPlanningPackage, ): CinemaEvidenceValidation { const issues: CinemaEvidenceIssue[] = []; if (!text(ledger.projectSlug)) add(issues, 'project', 'projectSlug', 'is required'); if (!text(ledger.generatedAt) || Number.isNaN(Date.parse(ledger.generatedAt))) add(issues, 'timestamp', 'generatedAt', 'must be an ISO timestamp'); if (planning && (planning.receipt.projectSlug !== ledger.projectSlug || planning.receipt.contentHash !== ledger.planningReceiptHash)) add(issues, 'planning-link', 'planningReceiptHash', 'must bind the current planning receipt'); const ids = new Set(); const targets = new Set(); ledger.decisions.forEach((decision, index) => { const path = `decisions[${index}]`; if (!text(decision.decisionId) || ids.has(decision.decisionId)) add(issues, 'decision-id', `${path}.decisionId`, 'must be present and unique'); ids.add(decision.decisionId); if (!text(decision.reviewerId) || !text(decision.reason)) add(issues, 'decision-authority', path, 'requires reviewer and reason'); if (!text(decision.decidedAt) || Number.isNaN(Date.parse(decision.decidedAt))) add(issues, 'timestamp', `${path}.decidedAt`, 'must be an ISO timestamp'); const target = decision.kind === 'character-sheet' ? `character-sheet:${decision.characterVersionId}` : decision.kind === 'recognition' ? `recognition:${decision.assetId}:${decision.versionId}:${decision.conditionId}` : decision.kind === 'rights' ? `rights:${decision.assetId}` : `voice:${decision.characterId}`; if (targets.has(target)) add(issues, 'terminal-target', path, 'an evidence target can be decided only once; create a new asset/voice version for new evidence'); targets.add(target); if (decision.kind === 'character-sheet') { evidence(issues, decision.evidence, `${path}.evidence`); if (!/^sha256:[a-f0-9]{64}$/.test(decision.sheetHash) || decision.verdict !== 'approved') add(issues, 'character-sheet-decision', path, 'requires approved verdict and sheet hash'); if (planning && !planning.assetEvidence.assets.some((asset) => asset.versionId === decision.characterVersionId)) add(issues, 'unknown-character-sheet-target', path, 'must name a planned character version'); } else if (decision.kind === 'recognition') { evidence(issues, decision.evidence, `${path}.evidence`); const asset = planning?.assetEvidence.assets.find((item) => item.assetId === decision.assetId && item.versionId === decision.versionId); if (planning && !asset?.conditions.some((condition) => condition.conditionId === decision.conditionId)) add(issues, 'unknown-recognition-target', path, 'must name a planned asset version and condition'); } else if (decision.kind === 'rights') { evidence(issues, decision.evidence, `${path}.evidence`); if (decision.verdict === 'cleared' && (!text(decision.owner) || !text(decision.permittedScope))) add(issues, 'rights-scope', path, 'cleared rights require owner and permitted scope'); if (decision.verdict === 'rejected' && (decision.owner !== null || decision.permittedScope !== null)) add(issues, 'rejected-rights-scope', path, 'rejected rights cannot claim owner/scope clearance'); if (planning && !planning.rightsRegistry.records.some((record) => record.assetId === decision.assetId)) add(issues, 'unknown-rights-target', path, 'must name a planned rights record'); } else { if (decision.verdict === 'locked') { if (!text(decision.voiceId)) add(issues, 'voice-id', `${path}.voiceId`, 'locked voice requires voiceId'); evidence(issues, decision.auditionEvidence, `${path}.auditionEvidence`); evidence(issues, decision.releaseEvidence, `${path}.releaseEvidence`); } else if (decision.voiceId !== null || decision.auditionEvidence.length > 0 || decision.releaseEvidence.length > 0) add(issues, 'rejected-voice-evidence', path, 'rejected voice cannot claim a lock or release evidence'); const voice = planning?.voiceRegistry.characters.find((character) => character.characterId === decision.characterId); if (planning && (!voice || !voice.speaking)) add(issues, 'unknown-voice-target', path, 'must name a planned speaking character'); } const { contentHash, ...decisionInput } = decision; if (contentHash !== cinemaEvidenceDecisionHash(decisionInput as CinemaEvidenceDecisionInput)) add(issues, 'content-hash', `${path}.contentHash`, 'does not match canonical decision bytes'); }); const { contentHash, ...ledgerInput } = ledger; if (contentHash !== cinemaEvidenceLedgerHash(ledgerInput)) add(issues, 'content-hash', 'contentHash', 'does not match canonical ledger bytes'); return { ok: issues.length === 0, issues }; } export interface CinemaEffectiveEvidenceState { assetEvidence: CinemaAssetEvidenceArtifact; rightsRegistry: CinemaRightsRegistryArtifact; voiceRegistry: CinemaVoiceRegistryArtifact; } export function projectCinemaEvidenceState(planning: CinemaPlanningPackage, ledger: CinemaEvidenceLedgerArtifact | null): CinemaEffectiveEvidenceState { const assetInput = structuredClone(planning.assetEvidence); const rightsInput = structuredClone(planning.rightsRegistry); const voiceInput = structuredClone(planning.voiceRegistry); for (const decision of ledger?.decisions ?? []) { if (decision.kind === 'character-sheet') { continue; } else if (decision.kind === 'recognition') { const asset = assetInput.assets.find((item) => item.assetId === decision.assetId && item.versionId === decision.versionId); const condition = asset?.conditions.find((item) => item.conditionId === decision.conditionId); if (!asset || !condition) continue; condition.status = decision.verdict; condition.reviewerId = decision.reviewerId; condition.reviewedAt = decision.decidedAt; condition.evidenceIds = decision.evidence.map((file) => file.contentHash); asset.lockStatus = asset.conditions.some((item) => item.status === 'fail') ? 'rejected' : asset.conditions.every((item) => item.status === 'pass') ? 'locked' : 'pending'; } else if (decision.kind === 'rights') { const record = rightsInput.records.find((item) => item.assetId === decision.assetId); if (!record) continue; record.status = decision.verdict; record.owner = decision.owner; record.permittedScope = decision.permittedScope; record.evidenceIds = decision.evidence.map((file) => file.contentHash); } else { const voice = voiceInput.characters.find((item) => item.characterId === decision.characterId); if (!voice) continue; voice.lockStatus = decision.verdict; voice.voiceId = decision.voiceId; voice.auditionEvidenceIds = decision.auditionEvidence.map((file) => file.contentHash); voice.releaseStatus = decision.verdict === 'locked' ? 'cleared' : 'rejected'; voice.releaseEvidenceIds = decision.releaseEvidence.map((file) => file.contentHash); } } const { schemaVersion: _assetSchema, contentHash: _assetHash, ...assetRest } = assetInput; const { schemaVersion: _rightsSchema, contentHash: _rightsHash, ...rightsRest } = rightsInput; const { schemaVersion: _voiceSchema, contentHash: _voiceHash, ...voiceRest } = voiceInput; const state = { assetEvidence: createCinemaAssetEvidence(assetRest), rightsRegistry: createCinemaRightsRegistry(rightsRest), voiceRegistry: createCinemaVoiceRegistry(voiceRest), }; for (const [label, validation] of [ ['asset evidence', validateCinemaAssetEvidence(state.assetEvidence)], ['rights registry', validateCinemaRightsRegistry(state.rightsRegistry)], ['voice registry', validateCinemaVoiceRegistry(state.voiceRegistry)], ] as const) { if (!validation.ok) throw new Error(`Effective Cinema ${label} is invalid: ${validation.issues.map((issue) => `${issue.path}: ${issue.message}`).join('; ')}`); } return state; }