import { createHash } from 'node:crypto' import { DomainError } from '@devrunbook/domain' import { authorizeWorkspaceAction, type AuthenticatedActor, type WorkspaceAuthorizationLookup, } from '../auth/workspace-authorization' import { lintPlaybookPackage } from '../quality/playbook-package-linter' import { evaluateStaticCase, type QualityMatrix, type StaticEvaluationCase, type StaticEvaluationObservation, type StaticEvaluationResult, } from '../quality/static-quality-evaluation' import { parsePrivatePlaybookDraftEtag, type PrivatePlaybookDraftStore, } from './private-playbook-drafts' export interface PrivatePlaybookQualityStore { attestReview(attestation: { readonly workspaceId: string readonly versionId: string readonly reviewedBy: string readonly attestedDigest: string readonly schemaAndSemanticValidationPassed: boolean readonly blockingLintFindingCount: number readonly limitationsDocumented: boolean readonly unresolvedSafetyRegression: boolean readonly review: Readonly> readonly reviewedAt: Date }): Promise upsertStaticCase(request: { readonly workspaceId: string readonly versionId: string readonly evaluationCase: StaticEvaluationCase readonly caseDigest: string readonly now: Date }): Promise appendStaticResult(request: { readonly workspaceId: string readonly versionId: string readonly logicalCaseId: string readonly fixtureVersion: string readonly result: StaticEvaluationResult readonly environment: Readonly> readonly executedBy: string readonly now: Date }): Promise } export interface PrivatePlaybookQualityDependencies { readonly authorization: WorkspaceAuthorizationLookup readonly drafts: PrivatePlaybookDraftStore readonly quality: PrivatePlaybookQualityStore readonly now: () => Date } interface QualityActorRequest { readonly actor: AuthenticatedActor | null readonly workspaceId: string readonly versionId: string readonly expectedEtag: string } async function authorizeEditor( dependencies: PrivatePlaybookQualityDependencies, request: QualityActorRequest, ): Promise { const context = await authorizeWorkspaceAction(dependencies.authorization, { actor: request.actor, workspaceId: request.workspaceId, action: 'write', }) return context.userId } async function currentDraft( dependencies: PrivatePlaybookQualityDependencies, request: QualityActorRequest, ) { const draft = await dependencies.drafts.findVersionForWorkspace( request.workspaceId, request.versionId, ) if (!draft) throw new DomainError( 'private_playbook_not_found', 'Private playbook not found', ) const expected = parsePrivatePlaybookDraftEtag(request.expectedEtag) if ( expected.revision !== draft.draftRevision || expected.digest !== draft.draftDigest ) { throw new DomainError( 'private_playbook_quality_conflict', 'The private playbook changed before evidence was recorded', ) } if (draft.publishedAt !== null) { throw new DomainError( 'private_playbook_published_immutable', 'Published playbook versions cannot receive mutable draft evidence', ) } return draft } function canonical(value: unknown): unknown { if (Array.isArray(value)) return value.map(canonical) if (value !== null && typeof value === 'object') { return Object.fromEntries( Object.entries(value as Record) .sort(([left], [right]) => left.localeCompare(right, 'en')) .map(([key, child]) => [key, canonical(child)]), ) } return value } function digest(value: unknown): string { return createHash('sha256') .update(JSON.stringify(canonical(value)), 'utf8') .digest('hex') } function reviewNote(value: string): string { const note = value.trim() if (note.length < 1 || note.length > 4000) { throw new DomainError( 'private_playbook_review_invalid', 'Review note must contain between 1 and 4000 characters', ) } return note } export async function reviewPrivatePlaybookDraft( dependencies: PrivatePlaybookQualityDependencies, request: QualityActorRequest & { readonly limitationsDocumented: boolean readonly unresolvedSafetyRegression: boolean readonly note: string }, ) { const reviewedBy = await authorizeEditor(dependencies, request) const draft = await currentDraft(dependencies, request) const lint = lintPlaybookPackage({ packageJson: draft.packageJson, packageDigest: draft.draftDigest, template: { path: 'template', content: draft.templateText }, published: false, }) const blockingLintFindingCount = lint.findings.filter( (finding) => finding.severity === 'error', ).length const recorded = await dependencies.quality.attestReview({ workspaceId: request.workspaceId, versionId: request.versionId, reviewedBy, attestedDigest: draft.draftDigest, schemaAndSemanticValidationPassed: true, blockingLintFindingCount, limitationsDocumented: request.limitationsDocumented, unresolvedSafetyRegression: request.unresolvedSafetyRegression, review: { note: reviewNote(request.note), lint }, reviewedAt: dependencies.now(), }) if (!recorded) throw new DomainError( 'private_playbook_quality_conflict', 'The private playbook changed before review evidence was recorded', ) return { digest: draft.draftDigest, lint, recorded: true as const } } export async function evaluatePrivatePlaybookStaticCase( dependencies: PrivatePlaybookQualityDependencies, request: QualityActorRequest & { readonly evaluationCase: StaticEvaluationCase readonly observation: StaticEvaluationObservation readonly dimensions: QualityMatrix readonly environment: Readonly> }, ) { const executedBy = await authorizeEditor(dependencies, request) const draft = await currentDraft(dependencies, request) const target = request.evaluationCase.target if ( target.id !== draft.logicalId || target.version !== draft.semanticVersion || target.digest !== draft.draftDigest ) { throw new DomainError( 'private_playbook_evaluation_stale', 'Evaluation case target does not match the current draft', ) } const caseId = await dependencies.quality.upsertStaticCase({ workspaceId: request.workspaceId, versionId: request.versionId, evaluationCase: request.evaluationCase, caseDigest: digest(request.evaluationCase), now: dependencies.now(), }) if (!caseId) throw new DomainError( 'private_playbook_evaluation_conflict', 'The private playbook changed before the evaluation case was stored', ) const result = evaluateStaticCase( request.evaluationCase, request.observation, request.dimensions, ) const resultId = await dependencies.quality.appendStaticResult({ workspaceId: request.workspaceId, versionId: request.versionId, logicalCaseId: request.evaluationCase.id, fixtureVersion: request.evaluationCase.fixture.version, result, environment: request.environment, executedBy, now: dependencies.now(), }) if (!resultId) throw new DomainError( 'private_playbook_evaluation_conflict', 'The private playbook changed before the result was stored', ) return { caseId, resultId, result } }