, * section: array * } */ public function interpret(EvidenceSnapshot $snapshot, ?EvidenceSnapshotItem $findingsItem): array { $findingsSummary = $this->findingsSummary($findingsItem); $entries = $this->findingEntries($findingsSummary); $unresolvedEntryCount = $entries ->filter(static fn (array $entry): bool => (string) data_get($entry, 'canonical_control_resolution.status') !== 'resolved') ->count(); $controls = $this->controlDefinitions($findingsSummary, $entries); $snapshotLimitations = $this->snapshotLimitations($snapshot, $findingsItem, $unresolvedEntryCount); $controlSummaries = $controls ->map(fn (CanonicalControlDefinition $definition): array => $this->controlSummary( definition: $definition, entries: $this->entriesForControl($entries, $definition->controlKey), snapshotLimitations: $snapshotLimitations, )) ->values() ->all(); $globalLimitations = $this->globalLimitations($controlSummaries, $snapshotLimitations, $controls->isEmpty(), $unresolvedEntryCount); $limitationCounts = $this->limitationCounts($controlSummaries, $globalLimitations); $summary = [ 'version_key' => self::VERSION_KEY, 'display_label' => 'Compliance evidence mapping v1', 'non_certification_disclosure' => 'TenantPilot interprets available evidence for review readiness. This is not a certification, legal attestation, or compliance guarantee.', 'mapped_control_count' => count($controlSummaries), 'follow_up_required_count' => collect($controlSummaries) ->where('readiness_bucket', 'follow_up_required') ->count(), 'limitation_counts' => $limitationCounts, 'limitations' => $globalLimitations, 'controls' => $controlSummaries, ]; return [ 'summary' => $summary, 'section' => [ 'section_key' => self::SECTION_KEY, 'title' => 'Control readiness interpretation', 'sort_order' => 15, 'required' => true, 'completeness_state' => $this->sectionCompleteness($findingsItem, $controls->isEmpty(), $snapshotLimitations), 'source_snapshot_fingerprint' => $this->sourceFingerprint($findingsItem) ?? (string) $snapshot->fingerprint, 'summary_payload' => Arr::except($summary, ['controls']), 'render_payload' => [ 'entries' => array_map( fn (array $control): array => $this->controlExplanation($control, $snapshot), $controlSummaries, ), 'disclosure' => $summary['non_certification_disclosure'], 'next_actions' => $this->sectionNextActions($controlSummaries, $globalLimitations), 'empty_state' => $controlSummaries === [] ? 'No canonical controls are mapped in this released review. Treat the control view as partial until evidence references can be mapped.' : null, ], 'measured_at' => $findingsItem?->measured_at ?? $snapshot->generated_at, ], ]; } /** * @return array */ private function findingsSummary(?EvidenceSnapshotItem $findingsItem): array { return is_array($findingsItem?->summary_payload) ? $findingsItem->summary_payload : []; } /** * @param array $findingsSummary * @return Collection> */ private function findingEntries(array $findingsSummary): Collection { return collect(Arr::wrap($findingsSummary['entries'] ?? [])) ->filter(static fn (mixed $entry): bool => is_array($entry)) ->values(); } /** * @param array $findingsSummary * @param Collection> $entries * @return Collection */ private function controlDefinitions(array $findingsSummary, Collection $entries): Collection { $summaryControls = collect(Arr::wrap($findingsSummary['canonical_controls'] ?? [])) ->filter(static fn (mixed $control): bool => is_array($control)); $entryControls = $entries ->map(static fn (array $entry): mixed => data_get($entry, 'canonical_control_resolution.control')) ->filter(static fn (mixed $control): bool => is_array($control)); return $summaryControls ->merge($entryControls) ->map(fn (array $control): ?CanonicalControlDefinition => $this->definitionFor($control)) ->filter() ->unique(static fn (CanonicalControlDefinition $definition): string => $definition->controlKey) ->sortBy(static fn (CanonicalControlDefinition $definition): string => $definition->name) ->values(); } /** * @param array $control */ private function definitionFor(array $control): ?CanonicalControlDefinition { $controlKey = $control['control_key'] ?? null; if (! is_string($controlKey) || trim($controlKey) === '') { return null; } return $this->catalog->find($controlKey); } /** * @param Collection> $entries * @return Collection> */ private function entriesForControl(Collection $entries, string $controlKey): Collection { return $entries ->filter(static fn (array $entry): bool => (string) data_get($entry, 'canonical_control_resolution.control.control_key') === $controlKey) ->values(); } /** * @param Collection> $entries * @param list $snapshotLimitations * @return array */ private function controlSummary(CanonicalControlDefinition $definition, Collection $entries, array $snapshotLimitations): array { $openEntries = $entries->filter(static fn (array $entry): bool => in_array((string) ($entry['status'] ?? ''), Finding::openStatuses(), true)); $acceptedRiskEntries = $entries->filter(static fn (array $entry): bool => (string) ($entry['status'] ?? '') === Finding::STATUS_RISK_ACCEPTED); $governanceWarnings = $entries->filter(static fn (array $entry): bool => self::hasGovernanceWarning($entry)); $limitationFlags = $this->controlLimitations($acceptedRiskEntries->count(), $snapshotLimitations); $readinessBucket = $this->readinessBucket( openCount: $openEntries->count(), acceptedRiskCount: $acceptedRiskEntries->count(), governanceWarningCount: $governanceWarnings->count(), limitationFlags: $limitationFlags, ); return [ 'control_key' => $definition->controlKey, 'control_name' => $definition->name, 'domain_key' => $definition->domainKey, 'readiness_bucket' => $readinessBucket, 'readiness_label' => self::readinessLabel($readinessBucket), 'limitation_flags' => $limitationFlags, 'limitation_labels' => array_map(self::limitationLabel(...), $limitationFlags), 'customer_summary' => $this->customerSummary($definition, $readinessBucket, $openEntries->count(), $acceptedRiskEntries->count()), 'evidence_basis_summary' => $this->evidenceBasisSummary($entries->count(), $openEntries->count(), $acceptedRiskEntries->count()), 'accepted_risk_summary' => $acceptedRiskEntries->isEmpty() ? null : $this->acceptedRiskSummary($acceptedRiskEntries, $governanceWarnings->count()), 'recommended_next_action' => $this->recommendedNextAction($readinessBucket, $acceptedRiskEntries->count(), $limitationFlags), 'detail_anchor' => 'control-'.$definition->controlKey, 'supporting_finding_ids' => $entries ->pluck('id') ->filter(static fn (mixed $id): bool => is_numeric($id)) ->map(static fn (mixed $id): int => (int) $id) ->values() ->all(), 'finding_count' => $entries->count(), 'open_finding_count' => $openEntries->count(), 'accepted_risk_count' => $acceptedRiskEntries->count(), ]; } /** * @param Collection> $acceptedRiskEntries */ private function acceptedRiskSummary(Collection $acceptedRiskEntries, int $governanceWarningCount): string { if ($governanceWarningCount > 0) { return sprintf( '%d accepted-risk finding(s) need governance follow-up before relying on this interpretation.', $acceptedRiskEntries->count(), ); } return sprintf( '%d accepted-risk finding(s) are part of the evidence basis and qualify the readiness view.', $acceptedRiskEntries->count(), ); } /** * @param list $snapshotLimitations * @return list */ private function controlLimitations(int $acceptedRiskCount, array $snapshotLimitations): array { $limitations = $snapshotLimitations; if ($acceptedRiskCount > 0) { $limitations[] = 'accepted_risk_influenced'; } return array_values(array_unique($limitations)); } /** * @param list $limitationFlags */ private function readinessBucket(int $openCount, int $acceptedRiskCount, int $governanceWarningCount, array $limitationFlags): string { if ($openCount > 0 || $governanceWarningCount > 0) { return 'follow_up_required'; } if ($acceptedRiskCount > 0 || $limitationFlags !== []) { return 'review_recommended'; } return 'evidence_on_record'; } private function customerSummary(CanonicalControlDefinition $definition, string $readinessBucket, int $openCount, int $acceptedRiskCount): string { return match ($readinessBucket) { 'follow_up_required' => sprintf( '%s needs follow-up because %d open finding(s) remain in the released evidence basis.', $definition->name, $openCount, ), 'review_recommended' => $acceptedRiskCount > 0 ? sprintf('%s has evidence on record with accepted-risk context that should be reviewed before relying on the interpretation.', $definition->name) : sprintf('%s has evidence on record, with limitations that should be reviewed before relying on the interpretation.', $definition->name), default => sprintf('%s has evidence on record in this released review.', $definition->name), }; } private function evidenceBasisSummary(int $signalCount, int $openCount, int $acceptedRiskCount): string { $parts = [ sprintf('%d evidence signal(s) reference this control.', $signalCount), ]; if ($openCount > 0) { $parts[] = sprintf('%d open finding(s) still need follow-up.', $openCount); } if ($acceptedRiskCount > 0) { $parts[] = sprintf('%d accepted-risk finding(s) qualify this view.', $acceptedRiskCount); } return implode(' ', $parts); } /** * @param list $limitationFlags */ private function recommendedNextAction(string $readinessBucket, int $acceptedRiskCount, array $limitationFlags): string { if ($readinessBucket === 'follow_up_required') { return 'Review the surfaced findings with the tenant and agree ownership plus follow-up timing.'; } if ($acceptedRiskCount > 0) { return 'Review the accepted-risk owner and next review date before customer delivery.'; } if ($limitationFlags !== []) { return 'Confirm the evidence basis and limitations before using this control as customer-facing readiness support.'; } return 'Keep this evidence on record and revisit it during the normal review cadence.'; } /** * @param array $control * @return array */ private function controlExplanation(array $control, EvidenceSnapshot $snapshot): array { return [ 'title' => $control['control_name'], 'control_key' => $control['control_key'], 'control_name' => $control['control_name'], 'readiness_bucket' => $control['readiness_bucket'], 'readiness_label' => $control['readiness_label'], 'limitation_flags' => $control['limitation_flags'], 'limitation_labels' => $control['limitation_labels'], 'customer_summary' => $control['customer_summary'], 'evidence_basis_summary' => $control['evidence_basis_summary'], 'accepted_risk_summary' => $control['accepted_risk_summary'], 'explanation_text' => $control['customer_summary'], 'evidence_basis_items' => array_values(array_filter([ $control['evidence_basis_summary'], $control['accepted_risk_summary'], ])), 'accepted_risk_context' => $control['accepted_risk_summary'], 'recommended_next_action' => $control['recommended_next_action'], 'proof_access_state' => $this->proofAccessState($snapshot), 'supporting_finding_ids' => $control['supporting_finding_ids'], ]; } /** * @param list> $controlSummaries * @param list $globalLimitations * @return list */ private function sectionNextActions(array $controlSummaries, array $globalLimitations): array { if ($controlSummaries === []) { return ['Review unmapped evidence before using this review for customer-facing readiness discussions.']; } $actions = collect($controlSummaries) ->pluck('recommended_next_action') ->filter(static fn (mixed $action): bool => is_string($action) && trim($action) !== '') ->unique() ->values() ->all(); if (in_array('unmapped', $globalLimitations, true)) { $actions[] = 'Treat this review as partial until unmapped evidence can be interpreted.'; } return array_values(array_unique($actions)); } /** * @param list> $controlSummaries * @param list $snapshotLimitations * @return list */ private function globalLimitations(array $controlSummaries, array $snapshotLimitations, bool $noMappedControls, int $unresolvedEntryCount): array { $limitations = $snapshotLimitations; if ($noMappedControls) { $limitations[] = 'unmapped'; } if ($unresolvedEntryCount > 0) { $limitations[] = 'partial_mapping'; } foreach ($controlSummaries as $control) { foreach (Arr::wrap($control['limitation_flags'] ?? []) as $limitation) { if (is_string($limitation) && trim($limitation) !== '') { $limitations[] = $limitation; } } } return array_values(array_unique($limitations)); } /** * @param list> $controlSummaries * @param list $globalLimitations * @return array */ private function limitationCounts(array $controlSummaries, array $globalLimitations): array { $counts = collect($controlSummaries) ->flatMap(static fn (array $control): array => Arr::wrap($control['limitation_flags'] ?? [])) ->filter(static fn (mixed $limitation): bool => is_string($limitation) && trim($limitation) !== '') ->countBy() ->all(); foreach ($globalLimitations as $limitation) { $counts[$limitation] = max((int) ($counts[$limitation] ?? 0), 1); } ksort($counts); return array_map('intval', $counts); } /** * @return list */ private function snapshotLimitations(EvidenceSnapshot $snapshot, ?EvidenceSnapshotItem $findingsItem, int $unresolvedEntryCount): array { $limitations = []; $state = (string) ($findingsItem?->state ?? $snapshot->completeness_state); if ($state === TenantReviewCompletenessState::Stale->value || (string) $snapshot->status === 'expired' || ($snapshot->expires_at !== null && $snapshot->expires_at->isPast())) { $limitations[] = 'stale_evidence'; } if (in_array($state, [TenantReviewCompletenessState::Partial->value, TenantReviewCompletenessState::Missing->value], true)) { $limitations[] = 'partial_mapping'; } if ($unresolvedEntryCount > 0) { $limitations[] = 'partial_mapping'; } if (! $snapshot->exists || $snapshot->generated_at === null) { $limitations[] = 'supporting_evidence_unavailable'; } return array_values(array_unique($limitations)); } /** * @param list $snapshotLimitations */ private function sectionCompleteness(?EvidenceSnapshotItem $findingsItem, bool $noMappedControls, array $snapshotLimitations): string { if (! $findingsItem instanceof EvidenceSnapshotItem) { return TenantReviewCompletenessState::Missing->value; } if (in_array('stale_evidence', $snapshotLimitations, true)) { return TenantReviewCompletenessState::Stale->value; } if ($noMappedControls || in_array('partial_mapping', $snapshotLimitations, true)) { return TenantReviewCompletenessState::Partial->value; } return TenantReviewCompletenessState::tryFrom((string) $findingsItem->state)?->value ?? TenantReviewCompletenessState::Missing->value; } private function proofAccessState(EvidenceSnapshot $snapshot): string { if ((string) $snapshot->status === 'expired' || ($snapshot->expires_at !== null && $snapshot->expires_at->isPast())) { return 'expired'; } if (! $snapshot->exists || $snapshot->generated_at === null) { return 'unavailable'; } return 'available'; } private function sourceFingerprint(?EvidenceSnapshotItem $item): ?string { $fingerprint = $item?->source_fingerprint; return is_string($fingerprint) && $fingerprint !== '' ? $fingerprint : null; } /** * @param array $entry */ private static function hasGovernanceWarning(array $entry): bool { if (is_string($entry['governance_warning'] ?? null) && trim((string) $entry['governance_warning']) !== '') { return true; } return in_array((string) ($entry['governance_state'] ?? ''), [ 'expired_exception', 'revoked_exception', 'rejected_exception', 'risk_accepted_without_valid_exception', ], true); } public static function readinessLabel(string $bucket): string { return match ($bucket) { 'follow_up_required' => 'Follow-up required', 'review_recommended' => 'Review recommended', 'evidence_on_record' => 'Evidence on record', default => Str::headline($bucket), }; } public static function limitationLabel(string $flag): string { return match ($flag) { 'accepted_risk_influenced' => 'Accepted risk influences this view', 'partial_mapping' => 'Partial evidence mapping', 'stale_evidence' => 'Evidence freshness needs review', 'supporting_evidence_unavailable' => 'Supporting evidence unavailable', 'unmapped' => 'No mapped control coverage', default => Str::headline($flag), }; } }