with(['tenant', 'evidenceSnapshot.items', 'tenantReview.sections'])->find($this->reviewPackId); $operationRun = OperationRun::query()->find($this->operationRunId); if (! $reviewPack instanceof ReviewPack || ! $operationRun instanceof OperationRun) { Log::warning('GenerateReviewPackJob: missing records', [ 'review_pack_id' => $this->reviewPackId, 'operation_run_id' => $this->operationRunId, ]); return; } $tenant = $reviewPack->tenant; if (! $tenant instanceof Tenant) { $this->markFailed($reviewPack, $operationRun, $operationRunService, 'tenant_not_found', 'Tenant not found'); return; } $snapshot = $reviewPack->evidenceSnapshot; if (! $snapshot instanceof EvidenceSnapshot) { $this->markFailed($reviewPack, $operationRun, $operationRunService, 'missing_snapshot', 'Evidence snapshot not found'); return; } // Mark running via OperationRunService (auto-sets started_at) $operationRunService->updateRun($operationRun, OperationRunStatus::Running->value); $reviewPack->update(['status' => ReviewPackStatus::Generating->value]); try { $this->executeGeneration($reviewPack, $operationRun, $tenant, $snapshot, $operationRunService); } catch (Throwable $e) { $this->markFailed($reviewPack, $operationRun, $operationRunService, 'generation_error', $e->getMessage()); throw $e; } } private function executeGeneration(ReviewPack $reviewPack, OperationRun $operationRun, Tenant $tenant, EvidenceSnapshot $snapshot, OperationRunService $operationRunService): void { $review = $reviewPack->tenantReview; if ($review instanceof TenantReview) { $this->executeReviewDerivedGeneration($reviewPack, $review, $operationRun, $tenant, $snapshot, $operationRunService); return; } $options = $reviewPack->options ?? []; $includePii = (bool) ($options['include_pii'] ?? true); $includeOperations = (bool) ($options['include_operations'] ?? true); $items = $snapshot->items->keyBy('dimension_key'); $findingsPayload = $this->itemSummaryPayload($items->get('findings_summary')); $permissionPosturePayload = $this->itemSummaryPayload($items->get('permission_posture')); $entraRolesPayload = $this->itemSummaryPayload($items->get('entra_admin_roles')); $operationsPayload = $this->itemSummaryPayload($items->get('operations_summary')); $riskAcceptance = is_array($snapshot->summary['risk_acceptance'] ?? null) ? $snapshot->summary['risk_acceptance'] : (is_array($findingsPayload['risk_acceptance'] ?? null) ? $findingsPayload['risk_acceptance'] : []); $findings = collect(is_array($findingsPayload['entries'] ?? null) ? $findingsPayload['entries'] : []); $recentOperations = collect($includeOperations && is_array($operationsPayload['entries'] ?? null) ? $operationsPayload['entries'] : []); $hardening = is_array($snapshot->summary['hardening'] ?? null) ? $snapshot->summary['hardening'] : []; $dataFreshness = $this->computeDataFreshness($items); // 6. Build file map $fileMap = $this->buildFileMap( findings: $findings, hardening: $hardening, permissionPosture: is_array($permissionPosturePayload['payload'] ?? null) ? $permissionPosturePayload['payload'] : [], entraAdminRoles: ['roles' => is_array($entraRolesPayload['roles'] ?? null) ? $entraRolesPayload['roles'] : []], recentOperations: $recentOperations, tenant: $tenant, snapshot: $snapshot, dataFreshness: $dataFreshness, riskAcceptance: $riskAcceptance, includePii: $includePii, includeOperations: $includeOperations, ); // 7. Assemble ZIP $tempFile = tempnam(sys_get_temp_dir(), 'review-pack-'); try { $this->assembleZip($tempFile, $fileMap); // 8. Compute SHA-256 $sha256 = hash_file('sha256', $tempFile); $fileSize = filesize($tempFile); // 9. Store on exports disk $filePath = sprintf( 'review-packs/%s/%s.zip', $tenant->external_id, now()->format('Y-m-d-His'), ); Storage::disk('exports')->put($filePath, file_get_contents($tempFile)); } finally { if (file_exists($tempFile)) { unlink($tempFile); } } // 10. Compute fingerprint $fingerprint = app(ReviewPackService::class)->computeFingerprint($tenant, $options); // 11. Compute summary $summary = [ 'finding_count' => (int) ($snapshot->summary['finding_count'] ?? $findings->count()), 'report_count' => (int) ($snapshot->summary['report_count'] ?? 0), 'operation_count' => $recentOperations->count(), 'data_freshness' => $dataFreshness, 'risk_acceptance' => $riskAcceptance, 'evidence_resolution' => [ 'outcome' => 'resolved', 'snapshot_id' => (int) $snapshot->getKey(), 'snapshot_fingerprint' => (string) $snapshot->fingerprint, 'completeness_state' => (string) $snapshot->completeness_state, ], ]; // 12. Update ReviewPack $retentionDays = (int) config('tenantpilot.review_pack.retention_days', 90); $reviewPack->update([ 'status' => ReviewPackStatus::Ready->value, 'evidence_snapshot_id' => (int) $snapshot->getKey(), 'fingerprint' => $fingerprint, 'sha256' => $sha256, 'file_size' => $fileSize, 'file_path' => $filePath, 'file_disk' => 'exports', 'generated_at' => now(), 'expires_at' => now()->addDays($retentionDays), 'summary' => $summary, ]); // 13. Mark OperationRun completed (auto-sends OperationRunCompleted notification) $operationRunService->updateRun( $operationRun, status: OperationRunStatus::Completed->value, outcome: OperationRunOutcome::Succeeded->value, summaryCounts: $summary, ); } private function executeReviewDerivedGeneration( ReviewPack $reviewPack, TenantReview $review, OperationRun $operationRun, Tenant $tenant, EvidenceSnapshot $snapshot, OperationRunService $operationRunService, ): void { $options = $reviewPack->options ?? []; $includePii = (bool) ($options['include_pii'] ?? true); $includeOperations = (bool) ($options['include_operations'] ?? true); $generatedAt = now(); $fileMap = $this->buildReviewDerivedFileMap( reviewPack: $reviewPack, review: $review, tenant: $tenant, snapshot: $snapshot, includePii: $includePii, includeOperations: $includeOperations, generatedAt: $generatedAt, ); $tempFile = tempnam(sys_get_temp_dir(), 'review-pack-'); try { $this->assembleZip($tempFile, $fileMap); $sha256 = hash_file('sha256', $tempFile); $fileSize = filesize($tempFile); $filePath = sprintf( 'review-packs/%s/review-%d-%s.zip', $tenant->external_id, (int) $review->getKey(), $generatedAt->format('Y-m-d-His'), ); Storage::disk('exports')->put($filePath, file_get_contents($tempFile)); } finally { if (file_exists($tempFile)) { unlink($tempFile); } } $fingerprint = app(ReviewPackService::class)->computeFingerprintForReview($review, $options); $reviewSummary = is_array($review->summary) ? $review->summary : []; $summary = [ 'tenant_review_id' => (int) $review->getKey(), 'review_status' => (string) $review->status, 'review_completeness_state' => (string) $review->completeness_state, 'section_count' => $review->sections->count(), 'finding_count' => (int) ($reviewSummary['finding_count'] ?? 0), 'report_count' => (int) ($reviewSummary['report_count'] ?? 0), 'operation_count' => $includeOperations ? (int) ($reviewSummary['operation_count'] ?? 0) : 0, 'highlights' => is_array($reviewSummary['highlights'] ?? null) ? $reviewSummary['highlights'] : [], 'publish_blockers' => is_array($reviewSummary['publish_blockers'] ?? null) ? $reviewSummary['publish_blockers'] : [], 'delivery_bundle' => $this->deliveryBundleSummary($review), 'evidence_resolution' => [ 'outcome' => 'resolved', 'snapshot_id' => (int) $snapshot->getKey(), 'snapshot_fingerprint' => (string) $snapshot->fingerprint, 'completeness_state' => (string) $snapshot->completeness_state, ], ]; $retentionDays = (int) config('tenantpilot.review_pack.retention_days', 90); $reviewPack->update([ 'status' => ReviewPackStatus::Ready->value, 'evidence_snapshot_id' => (int) $snapshot->getKey(), 'fingerprint' => $fingerprint, 'sha256' => $sha256, 'file_size' => $fileSize, 'file_path' => $filePath, 'file_disk' => 'exports', 'generated_at' => $generatedAt, 'expires_at' => $generatedAt->copy()->addDays($retentionDays), 'summary' => $summary, ]); $review->update([ 'current_export_review_pack_id' => (int) $reviewPack->getKey(), 'summary' => array_merge($reviewSummary, [ 'has_ready_export' => true, 'current_export_review_pack_id' => (int) $reviewPack->getKey(), ]), ]); $operationRunService->updateRun( $operationRun, status: OperationRunStatus::Completed->value, outcome: OperationRunOutcome::Succeeded->value, summaryCounts: [ 'created' => 1, 'finding_count' => (int) ($summary['finding_count'] ?? 0), 'report_count' => (int) ($summary['report_count'] ?? 0), 'operation_count' => (int) ($summary['operation_count'] ?? 0), 'errors_recorded' => 0, ], ); } /** * @return array */ private function computeDataFreshness($items): array { return [ 'permission_posture' => $items->get('permission_posture')?->freshness_at?->toIso8601String(), 'entra_admin_roles' => $items->get('entra_admin_roles')?->freshness_at?->toIso8601String(), 'findings' => $items->get('findings_summary')?->freshness_at?->toIso8601String(), 'hardening' => $items->get('baseline_drift_posture')?->freshness_at?->toIso8601String(), ]; } /** * Build the file map for the ZIP contents. * * @return array */ private function buildFileMap( $findings, array $hardening, array $permissionPosture, array $entraAdminRoles, $recentOperations, Tenant $tenant, EvidenceSnapshot $snapshot, array $dataFreshness, array $riskAcceptance, bool $includePii, bool $includeOperations, ): array { $files = []; // findings.csv $files['findings.csv'] = $this->buildFindingsCsv($findings, $includePii); // hardening.json $files['hardening.json'] = json_encode($hardening, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR); // metadata.json $files['metadata.json'] = json_encode([ 'version' => '1.0', 'tenant_id' => $tenant->external_id, 'tenant_name' => $includePii ? $tenant->name : '[REDACTED]', 'generated_at' => now()->toIso8601String(), 'evidence_snapshot' => [ 'id' => (int) $snapshot->getKey(), 'fingerprint' => (string) $snapshot->fingerprint, 'completeness_state' => (string) $snapshot->completeness_state, 'generated_at' => $snapshot->generated_at?->toIso8601String(), ], 'redaction_integrity' => [ 'protected_values_hidden' => true, 'note' => RedactionIntegrity::protectedValueNote(), ], 'options' => [ 'include_pii' => $includePii, 'include_operations' => $includeOperations, ], ], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR); // operations.csv $files['operations.csv'] = $this->buildOperationsCsv($recentOperations, $includePii); // reports/entra_admin_roles.json $files['reports/entra_admin_roles.json'] = json_encode( $this->redactReportPayload($entraAdminRoles, $includePii), JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR, ); // reports/permission_posture.json $files['reports/permission_posture.json'] = json_encode( $this->redactReportPayload($permissionPosture, $includePii), JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR, ); // summary.json $files['summary.json'] = json_encode([ 'data_freshness' => $dataFreshness, 'finding_count' => $findings->count(), 'report_count' => count(array_filter([$permissionPosture, $entraAdminRoles], static fn (array $payload): bool => $payload !== [])), 'operation_count' => $recentOperations->count(), 'risk_acceptance' => $riskAcceptance, 'snapshot_id' => (int) $snapshot->getKey(), ], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR); return $files; } /** * Build findings CSV content. * * @param \Illuminate\Database\Eloquent\Collection $findings */ private function buildFindingsCsv($findings, bool $includePii): string { $handle = fopen('php://temp', 'r+'); $this->writeCsvRow($handle, ['id', 'finding_type', 'severity', 'status', 'title', 'description', 'created_at', 'updated_at']); foreach ($findings as $finding) { $row = $finding instanceof Finding ? [ $finding->id, $finding->finding_type, $finding->severity, $finding->status, $includePii ? ($finding->title ?? '') : '[REDACTED]', $includePii ? ($finding->description ?? '') : '[REDACTED]', $finding->created_at?->toIso8601String(), $finding->updated_at?->toIso8601String(), ] : [ $finding['id'] ?? '', $finding['finding_type'] ?? '', $finding['severity'] ?? '', $finding['status'] ?? '', $includePii ? ($finding['title'] ?? '') : '[REDACTED]', $includePii ? ($finding['description'] ?? '') : '[REDACTED]', $finding['created_at'] ?? '', $finding['updated_at'] ?? '', ]; $this->writeCsvRow($handle, [ ...$row, ]); } rewind($handle); $content = stream_get_contents($handle); fclose($handle); return $content; } /** * Build operations CSV content. */ private function buildOperationsCsv($operations, bool $includePii): string { $handle = fopen('php://temp', 'r+'); $this->writeCsvRow($handle, ['id', 'type', 'status', 'outcome', 'initiator', 'started_at', 'completed_at']); foreach ($operations as $operation) { $row = $operation instanceof OperationRun ? [ $operation->id, $operation->type, $operation->status, $operation->outcome, $includePii ? ($operation->user?->name ?? '') : '[REDACTED]', $operation->started_at?->toIso8601String(), $operation->completed_at?->toIso8601String(), ] : [ $operation['id'] ?? '', $operation['type'] ?? '', $operation['status'] ?? '', $operation['outcome'] ?? '', $includePii ? ($operation['initiator_name'] ?? '') : '[REDACTED]', $operation['started_at'] ?? '', $operation['completed_at'] ?? '', ]; $this->writeCsvRow($handle, [ ...$row, ]); } rewind($handle); $content = stream_get_contents($handle); fclose($handle); return $content; } /** * @param resource $handle * @param array $row */ private function writeCsvRow($handle, array $row): void { fputcsv($handle, $row, ',', '"', '\\'); } /** * Redact PII from a report payload. * * @param array $payload * @return array */ private function redactReportPayload(array $payload, bool $includePii): array { $payload = $this->redactProtectedPayload($payload); return $includePii ? $payload : $this->redactArrayPii($payload); } /** * Recursively redact PII fields from an array. * * @param array $data * @return array */ private function redactArrayPii(array $data): array { $piiKeys = ['displayName', 'display_name', 'userPrincipalName', 'user_principal_name', 'email', 'mail']; foreach ($data as $key => $value) { if (is_string($key) && in_array($key, $piiKeys, true)) { $data[$key] = '[REDACTED]'; } elseif (is_array($value)) { $data[$key] = $this->redactArrayPii($value); } } return $data; } /** * @param array $data * @param array $segments * @return array */ private function redactProtectedPayload(array $data, array $segments = []): array { foreach ($data as $key => $value) { $nextSegments = [...$segments, (string) $key]; $jsonPointer = $this->jsonPointer($nextSegments); if (is_string($key) && $this->classifier()->protectsField('snapshot', $key, $jsonPointer)) { $data[$key] = SecretClassificationService::REDACTED; continue; } if (is_array($value)) { $data[$key] = $this->redactProtectedPayload($value, $nextSegments); continue; } if (is_string($value)) { $data[$key] = $this->classifier()->sanitizeAuditString($value); } } return $data; } /** * @param array $segments */ private function jsonPointer(array $segments): string { if ($segments === []) { return '/'; } return '/'.implode('/', array_map( static fn (string $segment): string => str_replace(['~', '/'], ['~0', '~1'], $segment), $segments, )); } private function classifier(): SecretClassificationService { return app(SecretClassificationService::class); } /** * Assemble a ZIP file from a file map. * * @param array $fileMap */ private function assembleZip(string $tempFile, array $fileMap): void { $zip = new ZipArchive; $result = $zip->open($tempFile, ZipArchive::CREATE | ZipArchive::OVERWRITE); if ($result !== true) { throw new \RuntimeException("Failed to create ZIP archive: error code {$result}"); } // Add files in alphabetical order for deterministic output ksort($fileMap); foreach ($fileMap as $filename => $content) { $zip->addFromString($filename, $content); } $zip->close(); } /** * @return array */ private function buildReviewDerivedFileMap( ReviewPack $reviewPack, TenantReview $review, Tenant $tenant, EvidenceSnapshot $snapshot, bool $includePii, bool $includeOperations, \Carbon\CarbonInterface $generatedAt, ): array { $reviewSummary = is_array($review->summary) ? $review->summary : []; $deliveryMetadata = $this->deliveryBundleMetadata( reviewPack: $reviewPack, review: $review, snapshot: $snapshot, generatedAt: $generatedAt, ); $sections = $review->sections ->filter(fn (mixed $section): bool => $includeOperations || $section->section_key !== 'operations_health') ->values(); $files = [ 'metadata.json' => json_encode([ 'version' => '1.0', 'tenant_id' => $tenant->external_id, 'tenant_name' => $includePii ? $tenant->name : '[REDACTED]', 'generated_at' => $generatedAt->toIso8601String(), 'delivery_bundle' => $deliveryMetadata, 'tenant_review' => [ 'id' => (int) $review->getKey(), 'status' => (string) $review->status, 'completeness_state' => (string) $review->completeness_state, 'published_at' => $review->published_at?->toIso8601String(), 'fingerprint' => (string) $review->fingerprint, ], 'evidence_snapshot' => [ 'id' => (int) $snapshot->getKey(), 'fingerprint' => (string) $snapshot->fingerprint, 'completeness_state' => (string) $snapshot->completeness_state, 'generated_at' => $snapshot->generated_at?->toIso8601String(), ], 'options' => [ 'include_pii' => $includePii, 'include_operations' => $includeOperations, ], 'redaction_integrity' => [ 'protected_values_hidden' => true, 'note' => RedactionIntegrity::protectedValueNote(), ], ], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR), 'summary.json' => json_encode($this->redactReportPayload(array_merge( [ 'tenant_review_id' => (int) $review->getKey(), 'review_status' => (string) $review->status, 'review_completeness_state' => (string) $review->completeness_state, ], $reviewSummary, [ 'delivery_bundle' => $this->deliveryBundleSummary($review), ], ), $includePii), JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR), 'sections.json' => json_encode($sections->map(function ($section) use ($includePii): array { $summaryPayload = is_array($section->summary_payload) ? $section->summary_payload : []; $renderPayload = is_array($section->render_payload) ? $section->render_payload : []; return [ 'section_key' => (string) $section->section_key, 'title' => (string) $section->title, 'sort_order' => (int) $section->sort_order, 'required' => (bool) $section->required, 'completeness_state' => (string) $section->completeness_state, 'summary_payload' => $this->redactReportPayload($summaryPayload, $includePii), 'render_payload' => $this->redactReportPayload($renderPayload, $includePii), ]; })->all(), JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR), ReviewPackService::EXECUTIVE_ENTRYPOINT_FILENAME => $this->buildExecutiveEntrypoint( review: $review, tenant: $tenant, snapshot: $snapshot, reviewSummary: $reviewSummary, includePii: $includePii, generatedAt: $generatedAt, ), ]; foreach ($sections as $section) { $renderPayload = is_array($section->render_payload) ? $section->render_payload : []; $summaryPayload = is_array($section->summary_payload) ? $section->summary_payload : []; $filename = sprintf('sections/%02d-%s.json', (int) $section->sort_order, (string) $section->section_key); $files[$filename] = json_encode([ 'title' => (string) $section->title, 'completeness_state' => (string) $section->completeness_state, 'summary_payload' => $this->redactReportPayload($summaryPayload, $includePii), 'render_payload' => $this->redactReportPayload($renderPayload, $includePii), ], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR); } return $files; } /** * @return array */ private function deliveryBundleSummary(TenantReview $review): array { return [ 'contract' => ReviewPackService::REVIEW_DERIVED_DELIVERY_CONTRACT, 'executive_entrypoint_file' => ReviewPackService::EXECUTIVE_ENTRYPOINT_FILENAME, 'appendix_files' => ['metadata.json', 'summary.json', 'sections.json'], 'interpretation_version' => $review->controlInterpretationVersion(), ]; } /** * @return array */ private function deliveryBundleMetadata( ReviewPack $reviewPack, TenantReview $review, EvidenceSnapshot $snapshot, \Carbon\CarbonInterface $generatedAt, ): array { return [ 'contract' => ReviewPackService::REVIEW_DERIVED_DELIVERY_CONTRACT, 'artifact_family' => 'review_pack', 'review_pack_id' => (int) $reviewPack->getKey(), 'generated_at' => $generatedAt->toIso8601String(), 'released_review' => [ 'id' => (int) $review->getKey(), 'status' => (string) $review->status, 'completeness_state' => (string) $review->completeness_state, 'published_at' => $review->published_at?->toIso8601String(), ], 'interpretation_version' => $review->controlInterpretationVersion(), 'evidence_basis' => [ 'snapshot_id' => (int) $snapshot->getKey(), 'snapshot_fingerprint' => (string) $snapshot->fingerprint, 'completeness_state' => (string) $snapshot->completeness_state, 'generated_at' => $snapshot->generated_at?->toIso8601String(), ], 'entrypoint' => [ 'file' => ReviewPackService::EXECUTIVE_ENTRYPOINT_FILENAME, 'role' => 'executive_entrypoint', 'audience' => 'executive', 'format' => 'text/markdown', ], 'appendix' => [ [ 'file' => 'metadata.json', 'role' => 'bundle_metadata', 'description' => 'Structured delivery metadata and artifact role map.', ], [ 'file' => 'summary.json', 'role' => 'review_summary_appendix', 'description' => 'Structured released-review summary truth.', ], [ 'file' => 'sections.json', 'role' => 'section_detail_appendix', 'description' => 'Structured released-review section detail.', ], ], ]; } /** * @param array $reviewSummary */ private function buildExecutiveEntrypoint( TenantReview $review, Tenant $tenant, EvidenceSnapshot $snapshot, array $reviewSummary, bool $includePii, \Carbon\CarbonInterface $generatedAt, ): string { $package = is_array($reviewSummary['governance_package'] ?? null) ? $this->redactReportPayload($reviewSummary['governance_package'], $includePii) : []; $controlInterpretation = is_array($reviewSummary['control_interpretation'] ?? null) ? $reviewSummary['control_interpretation'] : []; $nonCertificationDisclosure = $this->plainText( $controlInterpretation['non_certification_disclosure'] ?? null, 'TenantPilot interprets available evidence for review readiness. This is not a certification, legal attestation, or compliance guarantee.', ); $tenantName = $includePii ? $tenant->name : '[REDACTED]'; $topFindings = is_array($package['top_findings'] ?? null) ? $package['top_findings'] : []; $acceptedRisks = is_array($package['accepted_risks'] ?? null) ? $package['accepted_risks'] : []; $governanceDecisions = is_array($package['governance_decisions'] ?? null) ? $package['governance_decisions'] : []; $nextActions = is_array($reviewSummary['recommended_next_actions'] ?? null) ? $reviewSummary['recommended_next_actions'] : []; $lines = [ '# Executive summary', '', 'Tenant: '.$this->plainText($tenantName, '[REDACTED]'), 'Released review: #'.((int) $review->getKey()), 'Review status: '.$this->plainText($review->status, 'unknown'), 'Generated at: '.$generatedAt->toIso8601String(), '', '## Executive story', '', $this->plainText($package['executive_summary'] ?? null, 'No executive summary is available for this released review.'), '', '## Evidence basis', '', $this->plainText( $package['evidence_basis_summary'] ?? null, sprintf('Anchored to evidence snapshot #%d with %s completeness.', (int) $snapshot->getKey(), (string) $snapshot->completeness_state), ), '', '## Key findings', '', ...$this->entryBullets($topFindings, 'No key findings are listed for this released review.'), '', '## Accepted risks', '', ...$this->entryBullets($acceptedRisks, 'No accepted risks are listed for this released review.'), '', '## Governance decisions requiring awareness', '', ...$this->entryBullets($governanceDecisions, 'No governance decisions require awareness in this released review.'), '', '## Next actions', '', ...$this->textBullets($nextActions, 'No next action is listed for this released review.'), '', '## Non-certification disclosure', '', $nonCertificationDisclosure, '', '## Structured auditor appendix', '', 'This executive entrypoint is the first file to read. The structured auditor appendix remains available in metadata.json, summary.json, and sections.json.', '', ]; return implode("\n", $lines); } /** * @param array $entries * @return list */ private function entryBullets(array $entries, string $emptyText): array { if ($entries === []) { return ['- '.$emptyText]; } return collect($entries) ->filter(static fn (mixed $entry): bool => is_array($entry)) ->map(function (array $entry): string { $title = $this->plainText($entry['title'] ?? null, 'Entry'); $summary = $this->plainText($entry['summary'] ?? null, ''); return $summary === '' ? '- '.$title : '- '.$title.' - '.$summary; }) ->values() ->all(); } /** * @param array $entries * @return list */ private function textBullets(array $entries, string $emptyText): array { $bullets = collect($entries) ->filter(static fn (mixed $entry): bool => is_string($entry) && trim($entry) !== '') ->map(fn (string $entry): string => '- '.$this->plainText($entry, '')) ->values() ->all(); return $bullets === [] ? ['- '.$emptyText] : $bullets; } private function plainText(mixed $value, string $fallback): string { if (! is_scalar($value) && $value !== null) { return $fallback; } $text = preg_replace('/\s+/', ' ', trim((string) $value)); return is_string($text) && $text !== '' ? $text : $fallback; } private function markFailed(ReviewPack $reviewPack, OperationRun $operationRun, OperationRunService $operationRunService, string $reasonCode, string $errorMessage): void { $reviewPack->update([ 'status' => ReviewPackStatus::Failed->value, 'summary' => array_merge($reviewPack->summary ?? [], [ 'evidence_resolution' => array_merge($reviewPack->summary['evidence_resolution'] ?? [], [ 'outcome' => $reasonCode, 'reasons' => [mb_substr($errorMessage, 0, 500)], ]), ]), ]); $operationRunService->updateRun( $operationRun, status: OperationRunStatus::Completed->value, outcome: OperationRunOutcome::Failed->value, failures: [ ['code' => $reasonCode, 'message' => mb_substr($errorMessage, 0, 500)], ], ); } private function itemSummaryPayload(mixed $item): array { if (! $item instanceof \App\Models\EvidenceSnapshotItem || ! is_array($item->summary_payload)) { return []; } return $item->summary_payload; } }