*/ public function build(ReviewPack $reviewPack): array { $reviewPack->loadMissing([ 'tenant.workspace', 'environmentReview.sections', 'environmentReview.evidenceSnapshot', 'environmentReview.currentExportReviewPack', ]); $tenant = $reviewPack->tenant; $workspace = $tenant?->workspace; $review = $reviewPack->environmentReview; if (! $tenant instanceof ManagedEnvironment || ! $workspace instanceof Workspace || ! $review instanceof EnvironmentReview) { throw new InvalidArgumentException('Management report PDF requires a tenant, workspace, and released review.'); } if ((int) ($review->current_export_review_pack_id ?? 0) !== (int) $reviewPack->getKey()) { throw new InvalidArgumentException('Management report PDF can only be generated from the current review pack.'); } $disclosureDecision = self::customerExecutiveDisclosureDecision($reviewPack); $profile = is_array($disclosureDecision['profile'] ?? null) ? $disclosureDecision['profile'] : []; $readiness = is_array($disclosureDecision['readiness'] ?? null) ? $disclosureDecision['readiness'] : []; $disclosure = is_array($disclosureDecision['disclosure'] ?? null) ? $disclosureDecision['disclosure'] : []; if ((string) ($disclosureDecision['reason_code'] ?? '') === 'management_report_pdf_profile_invalid') { throw new InvalidArgumentException('Management report PDF requires the customer executive profile without fallback.'); } $guidance = ReviewPackOutputResolutionGuidance::fromReadiness($readiness); if ((bool) ($disclosureDecision['is_blocked'] ?? false)) { throw new InvalidArgumentException('Management report PDF generation is blocked by the customer-facing disclosure policy.'); } $payload = [ 'title' => 'TenantPilot Management Report', 'report_type' => 'management_report_pdf', 'profile' => self::PROFILE, 'profile_label' => (string) ($profile['label'] ?? 'Customer executive'), 'audience_label' => (string) ($profile['audience_label'] ?? 'Customer executive'), 'classification' => 'Customer-facing management report', 'workspace' => [ 'id' => (int) $workspace->getKey(), 'name' => (string) $workspace->name, ], 'managed_environment' => [ 'id' => (int) $tenant->getKey(), 'name' => (string) $tenant->name, 'external_id' => (string) $tenant->external_id, ], 'provenance' => [ 'environment_review_id' => (int) $review->getKey(), 'review_pack_id' => (int) $reviewPack->getKey(), 'review_status' => (string) $review->status, 'review_fingerprint' => (string) $review->fingerprint, 'review_pack_fingerprint' => (string) $reviewPack->fingerprint, 'review_pack_sha256' => (string) $reviewPack->sha256, 'generated_at' => now()->toIso8601String(), ], 'chapters' => [ $this->chapter('cover', 'Cover', [ 'environment' => (string) $tenant->name, 'workspace' => (string) $workspace->name, 'profile' => (string) ($profile['label'] ?? 'Customer executive'), 'classification' => 'Customer-facing management report', 'generated_at' => now()->toFormattedDateString(), ]), $this->chapter('executive_summary', 'Executive summary', [ 'summary' => $this->firstString( data_get($reviewPack->summary, 'governance_package.executive_summary'), data_get($review->summary, 'governance_package.executive_summary'), data_get($review->summary, 'executive_summary'), __('localization.review.rendered_report_summary_fallback'), ), ]), $this->chapter('governance_posture', 'Governance posture', [ 'state' => (string) ($guidance['label'] ?? 'Unavailable'), 'boundary' => (string) ($guidance['boundary_label'] ?? 'Needs review'), 'reason' => (string) ($guidance['primary_reason'] ?? ''), 'impact' => (string) ($guidance['impact'] ?? ''), ]), $this->chapter('key_decisions', 'Key decisions', [ 'items' => $this->listItems( data_get($reviewPack->summary, 'decision_summary.entries') ?? data_get($review->summary, 'decision_summary.entries') ?? data_get($review->summary, 'governance_decisions'), ['title', 'label', 'decision', 'summary', 'rationale'], ), ]), $this->chapter('top_risks', 'Top risks and findings', [ 'items' => $this->listItems( data_get($reviewPack->summary, 'top_findings') ?? data_get($review->summary, 'top_findings') ?? data_get($review->summary, 'finding_report_buckets.high'), ['title', 'label', 'severity', 'summary', 'recommendation'], 5, ), ]), $this->chapter('accepted_risks', 'Accepted risks', [ 'items' => $this->listItems( data_get($reviewPack->summary, 'risk_acceptance') ?? data_get($review->summary, 'risk_acceptance'), ['title', 'label', 'risk', 'summary', 'accepted_until'], 5, ), ]), $this->chapter('evidence_basis', 'Evidence basis', [ 'evidence_snapshot_id' => $review->evidence_snapshot_id !== null ? (int) $review->evidence_snapshot_id : null, 'evidence_completeness' => (string) ($readiness['evidence_completeness_state'] ?? 'unknown'), 'review_pack_generated_at' => $reviewPack->generated_at?->toIso8601String(), 'review_pack_sha256' => (string) $reviewPack->sha256, ]), $this->chapter('limitations', 'Limitations and disclosures', [ 'limitations' => $this->listItems($guidance['limitations'] ?? [], ['label', 'reason', 'severity']), 'warnings' => $this->listItems($disclosure['warnings'], ['label', 'summary']), 'mandatory_disclosures' => $this->listItems($disclosure['mandatory_disclosures'], ['label', 'summary', 'proof_state']), ]), $this->chapter('next_actions', 'Next actions', [ 'items' => $this->listItems( data_get($reviewPack->summary, 'recommended_next_actions') ?? data_get($review->summary, 'recommended_next_actions') ?? [], ['title', 'label', 'summary', 'owner', 'due'], ), ]), $this->chapter('method_summary', 'Method summary', [ 'summary' => 'Generated from the current customer-safe Review Pack and released review state. Raw evidence payloads, secrets, and Graph API responses are not included in this management PDF.', ]), ], ]; return $this->sanitize($payload); } /** * @return array{ * is_blocked: bool, * reason_code: ?string, * reason: ?string, * profile: array, * readiness: array, * disclosure: array * } */ public static function customerExecutiveDisclosureDecision(ReviewPack $reviewPack): array { $reviewPack->loadMissing([ 'environmentReview.sections', 'environmentReview.evidenceSnapshot', 'environmentReview.currentExportReviewPack', ]); $review = $reviewPack->environmentReview; if (! $review instanceof EnvironmentReview) { throw new InvalidArgumentException('Management report PDF requires a released review.'); } $profile = ReportProfileRegistry::resolve(self::PROFILE, self::PROFILE); $readiness = ReviewPackOutputResolutionGuidance::readinessForReview($review); if ((string) ($profile['effective_key'] ?? '') !== self::PROFILE || (bool) ($profile['is_fallback'] ?? false)) { return [ 'is_blocked' => true, 'reason_code' => 'management_report_pdf_profile_invalid', 'reason' => 'Management report PDF requires the customer executive profile without fallback.', 'profile' => $profile, 'readiness' => $readiness, 'disclosure' => [ 'blocking_reasons' => [], 'warnings' => [], 'mandatory_disclosures' => [], 'proof_states' => [], ], ]; } $metadata = [ 'non_certification_disclosure' => data_get($reviewPack->summary, 'control_interpretation.non_certification_disclosure') ?? data_get($review->summary, 'control_interpretation.non_certification_disclosure'), ]; $disclosure = ReportDisclosurePolicy::evaluate($profile, $readiness, $metadata); $blockingReason = $disclosure['blocking_reasons'][0] ?? null; if (is_array($blockingReason)) { $label = trim((string) ($blockingReason['label'] ?? '')); $summary = trim((string) ($blockingReason['summary'] ?? '')); return [ 'is_blocked' => true, 'reason_code' => (string) ($blockingReason['key'] ?? 'disclosure_blocked'), 'reason' => $label !== '' && $summary !== '' ? "{$label}: {$summary}" : ($summary !== '' ? $summary : $label), 'profile' => $profile, 'readiness' => $readiness, 'disclosure' => $disclosure, ]; } return [ 'is_blocked' => false, 'reason_code' => null, 'reason' => null, 'profile' => $profile, 'readiness' => $readiness, 'disclosure' => $disclosure, ]; } /** * @param array $content * @return array{key:string,title:string,content:array} */ private function chapter(string $key, string $title, array $content): array { return compact('key', 'title', 'content'); } private function firstString(mixed ...$values): string { foreach ($values as $value) { if (is_scalar($value) && trim((string) $value) !== '') { return trim((string) $value); } } return ''; } /** * @param list $fields * @return list> */ private function listItems(mixed $items, array $fields, int $limit = 8): array { if (! is_iterable($items)) { return []; } $normalized = []; foreach ($items as $item) { if (is_scalar($item)) { $normalized[] = ['summary' => trim((string) $item)]; } elseif (is_array($item)) { $entry = []; foreach ($fields as $field) { $value = data_get($item, $field); if (is_scalar($value) && trim((string) $value) !== '') { $entry[$field] = trim((string) $value); } } if ($entry !== []) { $normalized[] = $entry; } } if (count($normalized) >= $limit) { break; } } return $normalized; } private function sanitize(mixed $value): mixed { if (is_array($value)) { $sanitized = []; foreach ($value as $key => $item) { $sanitized[$key] = $this->sanitize($item); } return $sanitized; } if (! is_scalar($value) && $value !== null) { return null; } $text = preg_replace('/\s+/', ' ', trim((string) $value)); if (! is_string($text)) { return null; } if ($text === '') { return $text; } if (preg_match('/SQLSTATE|Bearer\s+|access[_-]?token|refresh[_-]?token|client[_-]?secret|private[_-]?key|password/i', $text) === 1) { return '[redacted]'; } return mb_substr(strip_tags($text), 0, 1200); } }