TenantAtlas/apps/platform/app/Services/EnvironmentReviews/EnvironmentReviewComposer.php
ahmido 77c343fb35 feat: implement decision register summary in environment review packs (#363)
## Summary
- add decision register summary output to environment review packs
- update environment review evidence composition and localized summary rendering
- add coverage for executive pack and derived review pack behavior
- include spec artifacts for feature 308

## Testing
- cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/EnvironmentReview/EnvironmentReviewExecutivePackTest.php tests/Feature/ReviewPack/EnvironmentReviewDerivedReviewPackTest.php

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #363
2026-05-15 12:54:41 +00:00

447 lines
20 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services\EnvironmentReviews;
use App\Models\EvidenceSnapshot;
use App\Models\EnvironmentReview;
use App\Support\EnvironmentReviewStatus;
final class EnvironmentReviewComposer
{
public function __construct(
private readonly EnvironmentReviewFingerprint $fingerprint,
private readonly EnvironmentReviewSectionFactory $sectionFactory,
private readonly EnvironmentReviewReadinessGate $readinessGate,
) {}
/**
* @return array{
* fingerprint: string,
* completeness_state: string,
* status: string,
* summary: array<string, mixed>,
* sections: list<array<string, mixed>>
* }
*/
public function compose(EvidenceSnapshot $snapshot, ?EnvironmentReview $review = null): array
{
$tenant = $snapshot->tenant;
if ($tenant === null) {
throw new \RuntimeException('Evidence snapshot tenant is required for review composition.');
}
$sections = $this->sectionFactory->make($snapshot);
$blockers = $this->readinessGate->blockersForSections($sections);
$sectionStateCounts = $this->readinessGate->sectionStateCounts($sections);
$completeness = $this->readinessGate->completenessForSections($sections);
$status = $this->readinessGate->statusForSections($sections);
$executiveSummarySection = collect($sections)
->firstWhere('section_key', 'executive_summary');
$controlInterpretationSection = collect($sections)
->firstWhere('section_key', 'control_interpretation');
$openRisksSection = collect($sections)
->firstWhere('section_key', 'open_risks');
$acceptedRisksSection = collect($sections)
->firstWhere('section_key', 'accepted_risks');
$operationsSection = collect($sections)
->firstWhere('section_key', 'operations_health');
if ($review instanceof EnvironmentReview && $review->isPublished()) {
$status = EnvironmentReviewStatus::Published;
}
return [
'fingerprint' => $this->fingerprint->forSnapshot($tenant, $snapshot),
'completeness_state' => $completeness->value,
'status' => $status->value,
'summary' => [
'evidence_basis' => [
'snapshot_id' => (int) $snapshot->getKey(),
'snapshot_fingerprint' => (string) $snapshot->fingerprint,
'snapshot_completeness_state' => (string) $snapshot->completeness_state,
'snapshot_generated_at' => $snapshot->generated_at?->toIso8601String(),
],
'section_count' => count($sections),
'section_state_counts' => $sectionStateCounts,
'publish_blockers' => $blockers,
'has_ready_export' => false,
'finding_count' => (int) data_get($sections, '0.summary_payload.finding_count', 0),
'finding_outcomes' => is_array(data_get($sections, '0.summary_payload.finding_outcomes'))
? data_get($sections, '0.summary_payload.finding_outcomes')
: [],
'finding_report_buckets' => is_array(data_get($sections, '0.summary_payload.finding_report_buckets'))
? data_get($sections, '0.summary_payload.finding_report_buckets')
: [],
'canonical_controls' => is_array(data_get($sections, '0.summary_payload.canonical_controls'))
? data_get($sections, '0.summary_payload.canonical_controls')
: [],
'control_interpretation' => is_array(data_get($controlInterpretationSection, 'summary_payload'))
? array_merge(
data_get($controlInterpretationSection, 'summary_payload'),
[
'controls' => is_array(data_get($controlInterpretationSection, 'render_payload.entries'))
? data_get($controlInterpretationSection, 'render_payload.entries')
: [],
],
)
: [],
'report_count' => 2,
'operation_count' => (int) data_get($operationsSection, 'summary_payload.operation_count', 0),
'highlights' => data_get($sections, '0.render_payload.highlights', []),
'recommended_next_actions' => data_get($sections, '0.render_payload.next_actions', []),
'governance_package' => $this->governancePackageSummary(
snapshot: $snapshot,
executiveSummarySection: is_array($executiveSummarySection) ? $executiveSummarySection : [],
controlInterpretationSection: is_array($controlInterpretationSection) ? $controlInterpretationSection : [],
openRisksSection: is_array($openRisksSection) ? $openRisksSection : [],
acceptedRisksSection: is_array($acceptedRisksSection) ? $acceptedRisksSection : [],
),
'last_composed_at' => now()->toIso8601String(),
],
'sections' => $sections,
];
}
/**
* @param array<string, mixed> $executiveSummarySection
* @param array<string, mixed> $controlInterpretationSection
* @param array<string, mixed> $openRisksSection
* @param array<string, mixed> $acceptedRisksSection
* @return array<string, mixed>
*/
private function governancePackageSummary(
EvidenceSnapshot $snapshot,
array $executiveSummarySection,
array $controlInterpretationSection,
array $openRisksSection,
array $acceptedRisksSection,
): array {
$executiveSummaryPayload = is_array($executiveSummarySection['summary_payload'] ?? null)
? $executiveSummarySection['summary_payload']
: [];
$executiveRenderPayload = is_array($executiveSummarySection['render_payload'] ?? null)
? $executiveSummarySection['render_payload']
: [];
$controlInterpretationSummary = is_array($controlInterpretationSection['summary_payload'] ?? null)
? $controlInterpretationSection['summary_payload']
: [];
$openRiskEntries = collect(data_get($openRisksSection, 'render_payload.entries', []))
->filter(static fn (mixed $entry): bool => is_array($entry))
->take(3)
->map(fn (array $entry): array => $this->packageFindingEntry($entry))
->values()
->all();
$acceptedRiskEntries = collect(data_get($acceptedRisksSection, 'render_payload.entries', []))
->filter(static fn (mixed $entry): bool => is_array($entry))
->map(fn (array $entry): array => $this->packageAcceptedRiskEntry($entry))
->values();
$governanceDecisionEntries = $acceptedRiskEntries
->filter(fn (array $entry): bool => $this->requiresGovernanceDecisionFollowUp($entry))
->values();
$stableAcceptedRiskEntries = $acceptedRiskEntries
->reject(fn (array $entry): bool => $this->requiresGovernanceDecisionFollowUp($entry))
->values();
$governanceDecisions = $governanceDecisionEntries
->map(fn (array $entry): array => $this->packageGovernanceDecisionEntry($entry))
->values()
->all();
$decisionSummary = $this->packageDecisionSummary(
snapshot: $snapshot,
acceptedRisksSection: $acceptedRisksSection,
governanceDecisions: $governanceDecisions,
);
return [
'delivery_artifact_family' => 'review_pack',
'interpretation_version' => is_string($controlInterpretationSummary['version_key'] ?? null)
? $controlInterpretationSummary['version_key']
: null,
'executive_summary' => $this->governancePackageExecutiveSummary(
executiveSummaryPayload: $executiveSummaryPayload,
executiveRenderPayload: $executiveRenderPayload,
controlInterpretationSummary: $controlInterpretationSummary,
acceptedRiskCount: $acceptedRiskEntries->count(),
),
'top_findings' => $openRiskEntries,
'accepted_risks' => $stableAcceptedRiskEntries->all(),
'governance_decisions' => $governanceDecisions,
'decision_summary' => $decisionSummary,
'evidence_basis_summary' => $this->governancePackageEvidenceBasisSummary(
snapshot: $snapshot,
controlInterpretationSummary: $controlInterpretationSummary,
),
'supporting_artifact_links' => [
[
'artifact_family' => 'evidence_snapshot',
'artifact_key' => 'evidence_snapshot:'.$snapshot->getKey(),
'purpose' => 'evidence_basis',
],
[
'artifact_family' => 'review_pack',
'artifact_key' => 'review_pack:current_export',
'purpose' => 'stakeholder_delivery',
],
],
];
}
/**
* @param array<string, mixed> $entry
* @return array<string, mixed>
*/
private function packageFindingEntry(array $entry): array
{
return [
'finding_id' => is_numeric($entry['id'] ?? null) ? (int) $entry['id'] : null,
'title' => $this->entryTitle($entry, 'Open finding'),
'severity' => is_string($entry['severity'] ?? null) ? $entry['severity'] : 'unknown',
'status' => is_string($entry['status'] ?? null) ? $entry['status'] : 'unknown',
'summary' => $this->entrySummary($entry, 'This finding remains open in the released review and should be discussed in stakeholder delivery.'),
];
}
/**
* @param array<string, mixed> $entry
* @return array<string, mixed>
*/
private function packageAcceptedRiskEntry(array $entry): array
{
return [
'finding_id' => is_numeric($entry['id'] ?? null) ? (int) $entry['id'] : null,
'title' => $this->entryTitle($entry, 'Accepted risk'),
'governance_state' => is_string($entry['governance_state'] ?? null) ? $entry['governance_state'] : 'unknown',
'summary' => $this->entrySummary($entry, 'This accepted-risk entry qualifies the current governance position for stakeholder delivery.'),
'owner_label' => $this->ownerLabel($entry),
];
}
/**
* @param array<string, mixed> $entry
*/
private function requiresGovernanceDecisionFollowUp(array $entry): bool
{
return in_array((string) ($entry['governance_state'] ?? ''), [
'expired_exception',
'revoked_exception',
'risk_accepted_without_valid_exception',
], true);
}
/**
* @param array<string, mixed> $entry
* @return array<string, mixed>
*/
private function packageGovernanceDecisionEntry(array $entry): array
{
$governanceState = (string) ($entry['governance_state'] ?? 'unknown');
return [
'finding_id' => $entry['finding_id'] ?? null,
'title' => $entry['title'] ?? 'Governance decision',
'governance_state' => $governanceState,
'awareness_reason' => $this->governanceDecisionAwarenessReason($governanceState),
'summary' => match ($governanceState) {
'expired_exception' => 'The accepted-risk exception has expired and needs follow-up before stakeholder delivery.',
'revoked_exception' => 'The accepted-risk exception was revoked and needs follow-up before stakeholder delivery.',
'risk_accepted_without_valid_exception' => 'The accepted-risk entry has no currently valid exception basis and needs follow-up before stakeholder delivery.',
default => 'This governance decision needs follow-up before stakeholder delivery.',
},
'next_action' => $this->governanceDecisionNextAction($governanceState),
'evidence_basis' => 'Included in the anchored released-review evidence basis.',
];
}
/**
* @param array<string, mixed> $acceptedRisksSection
* @param list<array<string, mixed>> $governanceDecisions
* @return array<string, mixed>
*/
private function packageDecisionSummary(
EvidenceSnapshot $snapshot,
array $acceptedRisksSection,
array $governanceDecisions,
): array {
$totalCount = count($governanceDecisions);
$evidenceState = is_string($acceptedRisksSection['completeness_state'] ?? null)
? (string) $acceptedRisksSection['completeness_state']
: (string) $snapshot->completeness_state;
$decisionDataState = $totalCount > 0 || $this->decisionEvidenceIsAvailable($evidenceState)
? 'available'
: 'incomplete';
$status = match (true) {
$totalCount > 0 => 'requires_awareness',
$decisionDataState === 'incomplete' => 'unavailable',
default => 'none',
};
return [
'customer_safe' => true,
'status' => $status,
'decision_data_state' => $decisionDataState,
'evidence_state' => $evidenceState,
'total_count' => $totalCount,
'requires_awareness' => $totalCount > 0,
'summary' => $this->decisionSummaryText($status, $totalCount),
'empty_state' => $this->decisionSummaryEmptyState($status),
'next_action' => $this->decisionSummaryNextAction($status),
'evidence_basis' => sprintf(
'Anchored to evidence snapshot #%d with %s decision-evidence completeness.',
(int) $snapshot->getKey(),
$evidenceState,
),
'source_section_state' => is_string($acceptedRisksSection['completeness_state'] ?? null)
? $acceptedRisksSection['completeness_state']
: null,
'entries' => $governanceDecisions,
];
}
private function decisionEvidenceIsAvailable(string $evidenceState): bool
{
return in_array($evidenceState, ['complete'], true);
}
private function decisionSummaryText(string $status, int $totalCount): string
{
return match ($status) {
'requires_awareness' => sprintf(
'%d governance decision%s require%s customer awareness before relying on this released review.',
$totalCount,
$totalCount === 1 ? '' : 's',
$totalCount === 1 ? 's' : '',
),
'unavailable' => 'Decision evidence is incomplete for this released review; no customer-aware decisions can be confirmed from the current evidence basis.',
default => 'No governance decisions require customer awareness in this released review.',
};
}
private function decisionSummaryEmptyState(string $status): string
{
return match ($status) {
'unavailable' => 'Decision evidence is incomplete in the current released-review basis.',
'requires_awareness' => '',
default => 'No governance decisions require awareness in this released review.',
};
}
private function decisionSummaryNextAction(string $status): string
{
return match ($status) {
'requires_awareness' => 'Review the accepted-risk decision basis before customer delivery.',
'unavailable' => 'Open the review evidence before treating the decision register summary as complete.',
default => 'No customer action is needed for Decision Register follow-up from this review.',
};
}
private function governanceDecisionAwarenessReason(string $governanceState): string
{
return match ($governanceState) {
'expired_exception' => 'The accepted-risk approval has expired and needs customer awareness before the review is relied on.',
'revoked_exception' => 'The accepted-risk approval was revoked and needs customer awareness before the review is relied on.',
'risk_accepted_without_valid_exception' => 'The accepted risk has no valid governance backing in the released review evidence.',
default => 'This accepted-risk governance decision needs customer awareness before the review is relied on.',
};
}
private function governanceDecisionNextAction(string $governanceState): string
{
return match ($governanceState) {
'expired_exception', 'revoked_exception', 'risk_accepted_without_valid_exception' => 'Confirm whether this accepted risk should be renewed, remediated, or removed before relying on the review.',
default => 'Confirm the accepted-risk decision before relying on the review.',
};
}
/**
* @param array<string, mixed> $executiveSummaryPayload
* @param array<string, mixed> $executiveRenderPayload
* @param array<string, mixed> $controlInterpretationSummary
*/
private function governancePackageExecutiveSummary(
array $executiveSummaryPayload,
array $executiveRenderPayload,
array $controlInterpretationSummary,
int $acceptedRiskCount,
): string {
$highlights = collect($executiveRenderPayload['highlights'] ?? [])
->filter(static fn (mixed $highlight): bool => is_string($highlight) && trim($highlight) !== '')
->values();
if ($highlights->isNotEmpty()) {
return (string) $highlights->first();
}
return sprintf(
'This released review summarizes %d mapped control(s), %d open risk(s), and %d accepted-risk item(s) from the anchored evidence basis.',
(int) ($controlInterpretationSummary['mapped_control_count'] ?? 0),
(int) ($executiveSummaryPayload['open_risk_count'] ?? 0),
$acceptedRiskCount,
);
}
/**
* @param array<string, mixed> $controlInterpretationSummary
*/
private function governancePackageEvidenceBasisSummary(EvidenceSnapshot $snapshot, array $controlInterpretationSummary): string
{
return sprintf(
'Anchored to evidence snapshot #%d with %s completeness and %d mapped control(s).',
(int) $snapshot->getKey(),
(string) $snapshot->completeness_state,
(int) ($controlInterpretationSummary['mapped_control_count'] ?? 0),
);
}
/**
* @param array<string, mixed> $entry
*/
private function entryTitle(array $entry, string $fallback): string
{
foreach (['title', 'name', 'finding_title'] as $key) {
$value = $entry[$key] ?? null;
if (is_string($value) && trim($value) !== '') {
return $value;
}
}
return $fallback;
}
/**
* @param array<string, mixed> $entry
*/
private function entrySummary(array $entry, string $fallback): string
{
foreach (['customer_summary', 'summary', 'request_reason'] as $key) {
$value = $entry[$key] ?? null;
if (is_string($value) && trim($value) !== '') {
return $value;
}
}
return $fallback;
}
/**
* @param array<string, mixed> $entry
*/
private function ownerLabel(array $entry): ?string
{
$owner = $entry['owner'] ?? null;
if (is_array($owner)) {
$name = $owner['name'] ?? null;
if (is_string($name) && trim($name) !== '') {
return $name;
}
}
return null;
}
}