import type { CinemaGenerationAuthorizationArtifact, CinemaGenerationQuoteArtifact, CinemaPaidTaskAuthorizationRequest, CinemaProviderCapabilitySnapshotArtifact, CinemaProviderContractBundle, CinemaProviderContractValidation, CinemaQuotedGenerationJob, CinemaSpendAuthorizationPort, } from './cinema-provider-types.js'; import type { CinemaContractIssue, CinemaSha256 } from './cinema-types.js'; import { sha256Text, stableCinemaJson } from './cinema-shot-compiler.js'; const HASH = /^sha256:[a-f0-9]{64}$/; type Hashable = { contentHash: CinemaSha256 }; export function cinemaArtifactContentHash(artifact: T): CinemaSha256 { const { contentHash: _ignored, ...body } = artifact; return sha256Text(stableCinemaJson(body)); } function issue(issues: CinemaContractIssue[], code: string, path: string, message: string): void { issues.push({ code, path, message }); } function text(issues: CinemaContractIssue[], value: string, path: string): void { if (!value.trim()) issue(issues, 'required-text', path, 'must be a non-empty string'); } function hash(issues: CinemaContractIssue[], value: string, path: string): void { if (!HASH.test(value)) issue(issues, 'invalid-content-hash', path, 'must be a lowercase sha256 content hash'); } function instant(issues: CinemaContractIssue[], value: string, path: string): number { const parsed = Date.parse(value); if (!Number.isFinite(parsed)) issue(issues, 'invalid-date-time', path, 'must be an ISO date-time'); return parsed; } function unique(issues: CinemaContractIssue[], values: string[], path: string): void { if (new Set(values).size !== values.length) issue(issues, 'duplicate-id', path, 'must contain unique values'); } function money( issues: CinemaContractIssue[], value: { currency: string; amount: number }, path: string, currency?: string, ): void { text(issues, value.currency, `${path}.currency`); if (!Number.isFinite(value.amount) || value.amount < 0) issue(issues, 'invalid-money', `${path}.amount`, 'must be a finite non-negative amount'); if (currency && value.currency !== currency) issue(issues, 'currency-mismatch', `${path}.currency`, `must use ${currency}`); } function contentHash(issues: CinemaContractIssue[], artifact: Hashable, path = 'contentHash'): void { hash(issues, artifact.contentHash, path); if (artifact.contentHash !== cinemaArtifactContentHash(artifact)) { issue(issues, 'artifact-hash-mismatch', path, 'must hash the canonical artifact body excluding contentHash'); } } function result(issues: CinemaContractIssue[]): CinemaProviderContractValidation { return { ok: issues.length === 0, issues }; } export function sealCinemaCapabilitySnapshot( input: Omit, ): CinemaProviderCapabilitySnapshotArtifact { const artifact = { ...structuredClone(input), contentHash: 'sha256:'.padEnd(71, '0') as CinemaSha256 }; artifact.contentHash = cinemaArtifactContentHash(artifact); return artifact; } export function sealCinemaGenerationQuote( input: Omit, ): CinemaGenerationQuoteArtifact { const artifact = { ...structuredClone(input), contentHash: 'sha256:'.padEnd(71, '0') as CinemaSha256 }; artifact.contentHash = cinemaArtifactContentHash(artifact); return artifact; } export function sealCinemaGenerationAuthorization( input: Omit, ): CinemaGenerationAuthorizationArtifact { const artifact = { ...structuredClone(input), contentHash: 'sha256:'.padEnd(71, '0') as CinemaSha256 }; artifact.contentHash = cinemaArtifactContentHash(artifact); return artifact; } export function validateCinemaCapabilitySnapshot( snapshot: CinemaProviderCapabilitySnapshotArtifact, now?: string, ): CinemaProviderContractValidation { const issues: CinemaContractIssue[] = []; [snapshot.snapshotId, snapshot.projectSlug, snapshot.routeId, snapshot.transportId, snapshot.discovery.version] .forEach((value, index) => text(issues, value, ['snapshotId', 'projectSlug', 'routeId', 'transportId', 'discovery.version'][index])); const discovered = instant(issues, snapshot.discoveredAt, 'discoveredAt'); const expires = instant(issues, snapshot.expiresAt, 'expiresAt'); if (expires <= discovered) issue(issues, 'invalid-capability-ttl', 'expiresAt', 'must be after discoveredAt'); if (now && expires <= instant(issues, now, 'now')) issue(issues, 'stale-capability-snapshot', 'expiresAt', 'capability snapshot has expired'); if (!snapshot.discovery.authenticated) issue(issues, 'provider-unauthenticated', 'discovery.authenticated', 'route must be authenticated before quotation'); if (snapshot.capabilities.mediaKinds.length === 0 || snapshot.capabilities.operations.length === 0) { issue(issues, 'empty-capabilities', 'capabilities', 'must declare media kinds and operations'); } unique(issues, snapshot.capabilities.mediaKinds, 'capabilities.mediaKinds'); unique(issues, snapshot.capabilities.operations, 'capabilities.operations'); unique(issues, snapshot.capabilities.models, 'capabilities.models'); unique(issues, snapshot.capabilities.workflows, 'capabilities.workflows'); if (!Number.isInteger(snapshot.capabilities.maximumReferenceImages) || snapshot.capabilities.maximumReferenceImages < 0) { issue(issues, 'invalid-reference-budget', 'capabilities.maximumReferenceImages', 'must be a non-negative integer'); } for (const field of ['maximumReferenceVideos', 'maximumReferenceAudio'] as const) { const cap = snapshot.capabilities[field]; if (cap !== undefined && (!Number.isInteger(cap) || cap < 0)) { issue(issues, 'invalid-reference-budget', `capabilities.${field}`, 'must be a non-negative integer when present'); } } const concurrency = snapshot.capabilities.maximumConcurrentJobs; if (concurrency !== null && (!Number.isInteger(concurrency) || concurrency < 1)) { issue(issues, 'invalid-concurrency', 'capabilities.maximumConcurrentJobs', 'must be null or a positive integer'); } contentHash(issues, snapshot); return result(issues); } function validateQuotedJob( issues: CinemaContractIssue[], job: CinemaQuotedGenerationJob, index: number, currency: string, ): void { const path = `jobs[${index}]`; text(issues, job.jobId, `${path}.jobId`); hash(issues, job.payloadHash, `${path}.payloadHash`); hash(issues, job.providerArgumentsHash, `${path}.providerArgumentsHash`); job.sourceHashes.forEach((value, sourceIndex) => hash(issues, value, `${path}.sourceHashes[${sourceIndex}]`)); unique(issues, job.sourceHashes, `${path}.sourceHashes`); if (!Number.isInteger(job.quantity) || job.quantity < 1) issue(issues, 'invalid-job-quantity', `${path}.quantity`, 'must be a positive integer'); money(issues, job.unitCost, `${path}.unitCost`, currency); money(issues, job.maximumCost, `${path}.maximumCost`, currency); const computed = Number((job.unitCost.amount * job.quantity).toFixed(8)); if (job.maximumCost.amount < computed) issue(issues, 'underquoted-job', `${path}.maximumCost`, 'cannot be below unitCost × quantity'); } export function validateCinemaGenerationQuote( quote: CinemaGenerationQuoteArtifact, snapshot: CinemaProviderCapabilitySnapshotArtifact, now?: string, ): CinemaProviderContractValidation { const issues = [...validateCinemaCapabilitySnapshot(snapshot, now).issues]; [quote.quoteId, quote.projectSlug, quote.routeId, quote.capabilitySnapshotId] .forEach((value, index) => text(issues, value, ['quoteId', 'projectSlug', 'routeId', 'capabilitySnapshotId'][index])); const created = instant(issues, quote.createdAt, 'createdAt'); const expires = instant(issues, quote.expiresAt, 'expiresAt'); if (expires <= created) issue(issues, 'invalid-quote-ttl', 'expiresAt', 'must be after createdAt'); if (now && expires <= Date.parse(now)) issue(issues, 'stale-generation-quote', 'expiresAt', 'quote has expired'); if (quote.jobs.length === 0) issue(issues, 'empty-generation-quote', 'jobs', 'must quote at least one job'); unique(issues, quote.jobs.map((job) => job.jobId), 'jobs.jobId'); unique(issues, quote.jobs.map((job) => job.payloadHash), 'jobs.payloadHash'); quote.jobs.forEach((job, index) => validateQuotedJob(issues, job, index, quote.totalCost.currency)); money(issues, quote.totalCost, 'totalCost'); money(issues, quote.maximumBalanceDebit, 'maximumBalanceDebit', quote.totalCost.currency); money(issues, quote.balanceEnvelope.observedBefore, 'balanceEnvelope.observedBefore', quote.totalCost.currency); money(issues, quote.balanceEnvelope.minimumBefore, 'balanceEnvelope.minimumBefore', quote.totalCost.currency); money(issues, quote.balanceEnvelope.maximumBefore, 'balanceEnvelope.maximumBefore', quote.totalCost.currency); const jobTotal = Number(quote.jobs.reduce((sum, job) => sum + job.unitCost.amount * job.quantity, 0).toFixed(8)); const maximumJobTotal = Number(quote.jobs.reduce((sum, job) => sum + job.maximumCost.amount, 0).toFixed(8)); if (quote.totalCost.amount !== jobTotal) issue(issues, 'quote-total-mismatch', 'totalCost.amount', `must equal exact job total ${jobTotal}`); if (quote.maximumBalanceDebit.amount < maximumJobTotal) issue(issues, 'quote-cap-below-total', 'maximumBalanceDebit.amount', 'must cover the sum of every job maximum'); if (quote.balanceEnvelope.minimumBefore.amount > quote.balanceEnvelope.observedBefore.amount || quote.balanceEnvelope.observedBefore.amount > quote.balanceEnvelope.maximumBefore.amount) { issue(issues, 'balance-outside-envelope', 'balanceEnvelope', 'observed balance must be inside the approved envelope'); } if (quote.projectSlug !== snapshot.projectSlug || quote.routeId !== snapshot.routeId || quote.accountClass !== snapshot.accountClass || quote.capabilitySnapshotId !== snapshot.snapshotId || quote.capabilitySnapshotHash !== snapshot.contentHash) { issue(issues, 'quote-capability-mismatch', 'capabilitySnapshotId', 'quote must bind the exact route capability snapshot'); } hash(issues, quote.capabilitySnapshotHash, 'capabilitySnapshotHash'); contentHash(issues, quote); return result(issues); } export function validateCinemaGenerationAuthorization( authorization: CinemaGenerationAuthorizationArtifact, quote: CinemaGenerationQuoteArtifact, snapshot: CinemaProviderCapabilitySnapshotArtifact, now?: string, ): CinemaProviderContractValidation { const issues = [...validateCinemaGenerationQuote(quote, snapshot, now).issues]; [authorization.authorizationId, authorization.projectSlug, authorization.quoteId, authorization.capabilitySnapshotId, authorization.routeId, authorization.approverId] .forEach((value, index) => text(issues, value, ['authorizationId', 'projectSlug', 'quoteId', 'capabilitySnapshotId', 'routeId', 'approverId'][index])); const approved = instant(issues, authorization.approvedAt, 'approvedAt'); const expires = instant(issues, authorization.expiresAt, 'expiresAt'); if (expires <= approved) issue(issues, 'invalid-authorization-ttl', 'expiresAt', 'must be after approvedAt'); if (now && approved > Date.parse(now)) issue(issues, 'future-generation-authorization', 'approvedAt', 'authorization approval cannot be in the future'); if (now && expires <= Date.parse(now)) issue(issues, 'stale-generation-authorization', 'expiresAt', 'authorization has expired'); unique(issues, authorization.authorizedJobIds, 'authorizedJobIds'); if (authorization.complianceEvidenceIds.length === 0) issue(issues, 'missing-compliance-evidence', 'complianceEvidenceIds', 'approval requires durable evidence'); unique(issues, authorization.complianceEvidenceIds, 'complianceEvidenceIds'); money(issues, authorization.maximumSpend, 'maximumSpend', quote.totalCost.currency); const quotedIds = [...quote.jobs.map((job) => job.jobId)].sort(); const authorizedIds = [...authorization.authorizedJobIds].sort(); if (stableCinemaJson(quotedIds) !== stableCinemaJson(authorizedIds)) { issue(issues, 'authorization-job-mismatch', 'authorizedJobIds', 'must authorize every and only job in the immutable quote'); } if (authorization.maximumSpend.amount < quote.totalCost.amount || authorization.maximumSpend.amount > quote.maximumBalanceDebit.amount) { issue(issues, 'authorization-cap-mismatch', 'maximumSpend.amount', 'must cover quote total without exceeding quoted maximum debit'); } if (authorization.projectSlug !== quote.projectSlug || authorization.quoteId !== quote.quoteId || authorization.quoteHash !== quote.contentHash || authorization.routeId !== quote.routeId || authorization.capabilitySnapshotId !== snapshot.snapshotId || authorization.capabilitySnapshotHash !== snapshot.contentHash) { issue(issues, 'authorization-quote-mismatch', 'quoteId', 'authorization must bind the exact quote and capability snapshot'); } hash(issues, authorization.quoteHash, 'quoteHash'); hash(issues, authorization.capabilitySnapshotHash, 'capabilitySnapshotHash'); contentHash(issues, authorization); return result(issues); } export function createCinemaSpendAuthorizationPort(bundle: CinemaProviderContractBundle): CinemaSpendAuthorizationPort { return { verifyPaidTask(request: CinemaPaidTaskAuthorizationRequest): void { const validation = validateCinemaGenerationAuthorization( bundle.authorization, bundle.quote, bundle.snapshot, request.now, ); if (!validation.ok) throw new Error(`Cinema spend authorization invalid: ${validation.issues.map((entry) => `${entry.code} (${entry.path})`).join(', ')}`); const { authorization, quote } = bundle; if (request.spendAuthorizationId !== authorization.authorizationId) throw new Error('Cinema task authorization id does not match the supplied authorization'); if (request.projectSlug !== authorization.projectSlug || request.routeId !== authorization.routeId) throw new Error('Cinema task project or route does not match its authorization'); const job = quote.jobs.find((entry) => entry.payloadHash === request.payloadHash); if (!job || job.taskKind !== request.taskKind) throw new Error('Cinema task payload and kind are not present in the authorized quote'); const quotedCost = Number((job.unitCost.amount * job.quantity).toFixed(8)); if (request.estimatedCost.currency !== job.maximumCost.currency || request.estimatedCost.amount > job.maximumCost.amount) { throw new Error('Cinema task estimated cost exceeds its exact authorized job cap'); } if (request.estimatedCost.amount < quotedCost) { throw new Error('Cinema task estimated cost must cover the exact quoted cost'); } const reused = request.queueAuthorizationUses.find((entry) => entry.taskId !== request.taskId && entry.payloadHash === request.payloadHash && ['waiting-lane', 'leased', 'succeeded'].includes(entry.status)); if (reused) throw new Error(`Cinema authorization job ${job.jobId} is already used by ${reused.taskId}`); const committed = request.queueAuthorizationUses .filter((entry) => entry.taskId !== request.taskId && ['waiting-lane', 'leased', 'succeeded'].includes(entry.status)) .reduce((sum, entry) => sum + (entry.actualCost.amount > 0 ? entry.actualCost.amount : entry.estimatedCost.amount), 0); if (request.estimatedCost.currency !== authorization.maximumSpend.currency || Number((committed + request.estimatedCost.amount).toFixed(8)) > authorization.maximumSpend.amount) { throw new Error('Cinema authorization aggregate committed spend would exceed its exact approved maximum'); } }, }; }