TenantAtlas/apps/platform/app/Jobs/GenerateReviewPackJob.php
Ahmed Darrazi 549a9a0004
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 1m0s
feat: review pack output contract and readiness semantics (spec 347)
Implemented the output contract and readiness semantics for review packs. Also added spec 348.
Includes changes to ChooseEnvironment, CustomerReviewWorkspace, GenerateReviewPackJob and related blade views.
Added comprehensive tests.
2026-06-03 01:14:29 +02:00

1199 lines
46 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Models\EvidenceSnapshot;
use App\Models\Finding;
use App\Models\OperationRun;
use App\Models\ReviewPack;
use App\Models\ManagedEnvironment;
use App\Models\EnvironmentReview;
use App\Services\Intune\SecretClassificationService;
use App\Services\OperationRunService;
use App\Services\ReviewPackService;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\RedactionIntegrity;
use App\Support\ReviewPackStatus;
use App\Support\ReviewPacks\ReviewPackOutputReadiness;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Throwable;
use ZipArchive;
class GenerateReviewPackJob implements ShouldQueue
{
use Queueable;
public int $timeout = 240;
public bool $failOnTimeout = true;
public function __construct(
public int $reviewPackId,
public int $operationRunId,
) {}
public function handle(OperationRunService $operationRunService): void
{
$reviewPack = ReviewPack::query()->with(['tenant', 'evidenceSnapshot.items', 'environmentReview.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 ManagedEnvironment) {
$this->markFailed($reviewPack, $operationRun, $operationRunService, 'tenant_not_found', 'ManagedEnvironment 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, ManagedEnvironment $tenant, EvidenceSnapshot $snapshot, OperationRunService $operationRunService): void
{
$review = $reviewPack->environmentReview;
if ($review instanceof EnvironmentReview) {
$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,
);
$fileCount = count($fileMap);
$operationRun = $operationRunService->updateRun(
$operationRun,
status: OperationRunStatus::Running->value,
outcome: OperationRunOutcome::Pending->value,
summaryCounts: [
'total' => $fileCount,
'processed' => 0,
'succeeded' => 0,
'failed' => 0,
'skipped' => 0,
],
);
// 7. Assemble ZIP
$tempFile = tempnam(sys_get_temp_dir(), 'review-pack-');
try {
$this->assembleZip($tempFile, $fileMap, function () use ($operationRunService, &$operationRun): void {
$operationRun = $operationRunService->incrementSummaryCounts($operationRun, [
'processed' => 1,
'created' => 1,
]);
});
// 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: [
'total' => $fileCount,
'processed' => $fileCount,
'created' => $fileCount,
'finding_count' => (int) ($summary['finding_count'] ?? 0),
'report_count' => (int) ($summary['report_count'] ?? 0),
'operation_count' => (int) ($summary['operation_count'] ?? 0),
],
);
}
private function executeReviewDerivedGeneration(
ReviewPack $reviewPack,
EnvironmentReview $review,
OperationRun $operationRun,
ManagedEnvironment $tenant,
EvidenceSnapshot $snapshot,
OperationRunService $operationRunService,
): void {
$options = $reviewPack->options ?? [];
$includePii = (bool) ($options['include_pii'] ?? true);
$includeOperations = (bool) ($options['include_operations'] ?? true);
$generatedAt = now();
$sections = $this->reviewDerivedSections($review, $includeOperations);
$fileMap = $this->buildReviewDerivedFileMap(
reviewPack: $reviewPack,
review: $review,
tenant: $tenant,
snapshot: $snapshot,
sections: $sections,
includePii: $includePii,
includeOperations: $includeOperations,
generatedAt: $generatedAt,
);
$fileCount = count($fileMap);
$operationRun = $operationRunService->updateRun(
$operationRun,
status: OperationRunStatus::Running->value,
outcome: OperationRunOutcome::Pending->value,
summaryCounts: [
'total' => $fileCount,
'processed' => 0,
'succeeded' => 0,
'failed' => 0,
'skipped' => 0,
],
);
$tempFile = tempnam(sys_get_temp_dir(), 'review-pack-');
try {
$this->assembleZip($tempFile, $fileMap, function () use ($operationRunService, &$operationRun): void {
$operationRun = $operationRunService->incrementSummaryCounts($operationRun, [
'processed' => 1,
'created' => 1,
]);
});
$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 = $this->reviewDerivedSummaryPayload(
reviewPack: $reviewPack,
review: $review,
snapshot: $snapshot,
sections: $sections,
includePii: $includePii,
includeOperations: $includeOperations,
hasReadyExport: true,
);
$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: [
'total' => $fileCount,
'processed' => $fileCount,
'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<string, ?string>
*/
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<string, string>
*/
private function buildFileMap(
$findings,
array $hardening,
array $permissionPosture,
array $entraAdminRoles,
$recentOperations,
ManagedEnvironment $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',
'managed_environment_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<int, Finding> $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<int, mixed> $row
*/
private function writeCsvRow($handle, array $row): void
{
fputcsv($handle, $row, ',', '"', '\\');
}
/**
* Redact PII from a report payload.
*
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
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<string, mixed> $data
* @return array<string, mixed>
*/
private function redactArrayPii(array $data): array
{
$piiKeys = [
'displayName',
'display_name',
'userPrincipalName',
'user_principal_name',
'email',
'mail',
'tenant_name',
'tenant_label',
'customer_name',
'owner_label',
'owner_name',
'actor_label',
'actor_name',
'initiator_name',
'requested_by',
'approved_by',
];
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<int|string, mixed> $data
* @param array<int, string> $segments
* @return array<int|string, mixed>
*/
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<int, string> $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<string, string> $fileMap
*/
private function assembleZip(string $tempFile, array $fileMap, ?callable $afterWrite = null): 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);
$afterWrite && $afterWrite();
}
$zip->close();
}
/**
* @return array<string, string>
*/
private function buildReviewDerivedFileMap(
ReviewPack $reviewPack,
EnvironmentReview $review,
ManagedEnvironment $tenant,
EvidenceSnapshot $snapshot,
Collection $sections,
bool $includePii,
bool $includeOperations,
\Carbon\CarbonInterface $generatedAt,
): array {
$reviewSummary = is_array($review->summary) ? $review->summary : [];
$sectionFiles = $sections
->map(fn (mixed $section): string => $this->reviewDerivedSectionFilename($section))
->values()
->all();
$summaryPayload = $this->reviewDerivedSummaryPayload(
reviewPack: $reviewPack,
review: $review,
snapshot: $snapshot,
sections: $sections,
includePii: $includePii,
includeOperations: $includeOperations,
hasReadyExport: true,
);
$deliveryMetadata = $this->deliveryBundleMetadata(
reviewPack: $reviewPack,
review: $review,
snapshot: $snapshot,
generatedAt: $generatedAt,
sectionFiles: $sectionFiles,
outputReadiness: is_array($summaryPayload['output_readiness'] ?? null)
? $summaryPayload['output_readiness']
: [],
);
$files = [
'metadata.json' => json_encode([
'version' => '1.0',
'managed_environment_id' => $tenant->external_id,
'tenant_name' => $includePii ? $tenant->name : '[REDACTED]',
'generated_at' => $generatedAt->toIso8601String(),
'delivery_bundle' => $deliveryMetadata,
'environment_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,
],
'output_readiness' => data_get($summaryPayload, 'output_readiness', []),
'redaction_integrity' => [
'protected_values_hidden' => true,
'note' => RedactionIntegrity::protectedValueNote(),
],
], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR),
'summary.json' => json_encode(
$this->redactReportPayload($summaryPayload, $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,
outputReadiness: is_array($summaryPayload['output_readiness'] ?? null)
? $summaryPayload['output_readiness']
: [],
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 = $this->reviewDerivedSectionFilename($section);
$files[$filename] = json_encode([
'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),
], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR);
}
return $files;
}
/**
* @return array<string, mixed>
*/
private function deliveryBundleSummary(EnvironmentReview $review, Collection $sections): array
{
return [
'contract' => ReviewPackService::REVIEW_DERIVED_DELIVERY_CONTRACT,
'executive_entrypoint_file' => ReviewPackService::EXECUTIVE_ENTRYPOINT_FILENAME,
'appendix_files' => ['metadata.json', 'summary.json', 'sections.json'],
'section_files' => $sections
->map(fn (mixed $section): string => $this->reviewDerivedSectionFilename($section))
->values()
->all(),
'interpretation_version' => $review->controlInterpretationVersion(),
];
}
/**
* @return array<string, mixed>
*/
private function deliveryBundleMetadata(
ReviewPack $reviewPack,
EnvironmentReview $review,
EvidenceSnapshot $snapshot,
\Carbon\CarbonInterface $generatedAt,
array $sectionFiles,
array $outputReadiness,
): array {
$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.',
],
];
foreach ($sectionFiles as $sectionFile) {
$appendix[] = [
'file' => $sectionFile,
'role' => 'section_appendix_entry',
'description' => 'Structured appendix entry for a released-review section.',
];
}
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' => $appendix,
'section_file_semantics' => 'Section completeness_state describes source and evidence completeness. A section appendix file may still exist when the section state is missing.',
'output_readiness' => $outputReadiness,
];
}
/**
* @param array<string, mixed> $reviewSummary
*/
private function buildExecutiveEntrypoint(
EnvironmentReview $review,
ManagedEnvironment $tenant,
EvidenceSnapshot $snapshot,
array $reviewSummary,
array $outputReadiness,
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'] : [];
$decisionSummary = is_array($package['decision_summary'] ?? null) ? $package['decision_summary'] : [];
$governanceDecisions = is_array($decisionSummary['entries'] ?? null)
? $decisionSummary['entries']
: (is_array($package['governance_decisions'] ?? null) ? $package['governance_decisions'] : []);
$nextActions = is_array($reviewSummary['recommended_next_actions'] ?? null) ? $reviewSummary['recommended_next_actions'] : [];
$limitations = $this->executiveLimitationsLines($outputReadiness);
$lines = [
'# Executive summary',
'',
'ManagedEnvironment: '.$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),
),
'',
...($limitations === [] ? [] : [
'## Limitations',
'',
...$limitations,
'',
]),
'## 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->decisionSummaryLines($decisionSummary, $governanceDecisions),
'',
'## 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<string, mixed> $decisionSummary
* @param array<int, mixed> $entries
* @return list<string>
*/
private function decisionSummaryLines(array $decisionSummary, array $entries): array
{
$lines = [];
$summary = $this->plainText($decisionSummary['summary'] ?? null, '');
$nextAction = $this->plainText($decisionSummary['next_action'] ?? null, '');
if ($summary !== '') {
$lines[] = $summary;
}
if ($nextAction !== '') {
$lines[] = 'Next action: '.$nextAction;
}
if ($lines !== []) {
$lines[] = '';
}
return [
...$lines,
...$this->entryBullets($entries, 'No governance decisions require awareness in this released review.'),
];
}
private function reviewDerivedSections(EnvironmentReview $review, bool $includeOperations): Collection
{
return $review->sections
->filter(fn (mixed $section): bool => $includeOperations || $section->section_key !== 'operations_health')
->values();
}
private function reviewDerivedSectionFilename(mixed $section): string
{
return sprintf('sections/%02d-%s.json', (int) $section->sort_order, (string) $section->section_key);
}
/**
* @return array<string, mixed>
*/
private function reviewDerivedSummaryPayload(
ReviewPack $reviewPack,
EnvironmentReview $review,
EvidenceSnapshot $snapshot,
Collection $sections,
bool $includePii,
bool $includeOperations,
bool $hasReadyExport,
): array {
$reviewSummary = is_array($review->summary) ? $review->summary : [];
$publishBlockers = is_array($reviewSummary['publish_blockers'] ?? null) ? $reviewSummary['publish_blockers'] : [];
$sectionStateCounts = $this->sectionStateCounts($sections);
$requiredSections = $sections->filter(static fn (mixed $section): bool => (bool) $section->required)->values();
$requiredSectionStateCounts = $this->sectionStateCounts($requiredSections);
$outputReadiness = ReviewPackOutputReadiness::derive(
reviewStatus: (string) $review->status,
reviewCompletenessState: (string) $review->completeness_state,
evidenceCompletenessState: (string) $snapshot->completeness_state,
sectionStateCounts: $sectionStateCounts,
requiredSectionCount: $requiredSections->count(),
requiredSectionStateCounts: $requiredSectionStateCounts,
publishBlockers: $publishBlockers,
hasReadyExport: $hasReadyExport,
includePii: $includePii,
protectedValuesHidden: true,
disclosurePresent: $this->nonCertificationDisclosurePresent($reviewSummary),
);
$governancePackage = is_array($reviewSummary['governance_package'] ?? null)
? $this->redactReportPayload($reviewSummary['governance_package'], $includePii)
: [];
return array_merge($reviewSummary, [
'environment_review_id' => (int) $review->getKey(),
'review_status' => (string) $review->status,
'review_completeness_state' => (string) $review->completeness_state,
'section_count' => $sections->count(),
'section_state_counts' => $sectionStateCounts,
'required_section_state_counts' => $requiredSectionStateCounts,
'has_ready_export' => $hasReadyExport,
'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' => $publishBlockers,
'governance_package' => $governancePackage,
'recommended_next_actions' => is_array($reviewSummary['recommended_next_actions'] ?? null)
? $reviewSummary['recommended_next_actions']
: [],
'delivery_bundle' => $this->deliveryBundleSummary($review, $sections),
'evidence_basis' => [
'snapshot_id' => (int) $snapshot->getKey(),
'snapshot_fingerprint' => (string) $snapshot->fingerprint,
'completeness_state' => (string) $snapshot->completeness_state,
'generated_at' => $snapshot->generated_at?->toIso8601String(),
],
'evidence_resolution' => [
'outcome' => 'resolved',
'snapshot_id' => (int) $snapshot->getKey(),
'snapshot_fingerprint' => (string) $snapshot->fingerprint,
'completeness_state' => (string) $snapshot->completeness_state,
],
'output_readiness' => $outputReadiness,
]);
}
/**
* @return array<string, int>
*/
private function sectionStateCounts(Collection $sections): array
{
return $sections
->countBy(static fn (mixed $section): string => (string) $section->completeness_state)
->map(static fn (int $count): int => max(0, $count))
->all();
}
/**
* @param array<string, mixed> $reviewSummary
*/
private function nonCertificationDisclosurePresent(array $reviewSummary): bool
{
$controlInterpretation = is_array($reviewSummary['control_interpretation'] ?? null)
? $reviewSummary['control_interpretation']
: [];
$disclosure = $this->plainText(
$controlInterpretation['non_certification_disclosure'] ?? null,
'TenantPilot interprets available evidence for review readiness. This is not a certification, legal attestation, or compliance guarantee.',
);
return $disclosure !== '';
}
/**
* @param array<string, mixed> $outputReadiness
* @return list<string>
*/
private function executiveLimitationsLines(array $outputReadiness): array
{
$codes = collect($outputReadiness['limitations'] ?? [])
->filter(static fn (mixed $limitation): bool => is_array($limitation) && is_string($limitation['code'] ?? null))
->pluck('code')
->values()
->all();
if ($codes === []) {
return [];
}
$lines = [];
if (in_array('evidence_basis_missing', $codes, true)) {
$lines[] = '- This review was published with a missing evidence basis.';
} elseif (in_array('evidence_basis_stale', $codes, true)) {
$lines[] = '- This review was published with a stale evidence basis.';
} elseif (in_array('evidence_basis_incomplete', $codes, true)) {
$lines[] = '- This review was published with an incomplete evidence basis.';
}
if (in_array('required_sections_incomplete', $codes, true)) {
$lines[] = '- Some required sections are included as structured appendices but are marked missing because their source evidence was incomplete at generation time.';
}
if (in_array('export_not_ready', $codes, true)) {
$lines[] = '- The package exists, but export readiness had not passed at generation time.';
}
if (in_array('contains_pii', $codes, true)) {
$lines[] = '- PII is included in this package. Review the contents before external sharing.';
}
if (in_array('publish_blockers_present', $codes, true)) {
$lines[] = '- Publish blockers remain recorded in the released review summary.';
}
if (in_array('disclosure_missing', $codes, true)) {
$lines[] = '- The non-certification disclosure was not fully available in the released review payload.';
}
return $lines;
}
/**
* @param array<int, mixed> $entries
* @return list<string>
*/
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<int, mixed> $entries
* @return list<string>
*/
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;
}
}