TenantAtlas/apps/platform/app/Support/Governance/Controls/ComplianceEvidenceMappingV1.php
Ahmed Darrazi 09ba297247
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 1m44s
feat(specs/259): compliance evidence mapping
2026-04-30 23:26:32 +02:00

516 lines
21 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Support\Governance\Controls;
use App\Models\EvidenceSnapshot;
use App\Models\EvidenceSnapshotItem;
use App\Models\Finding;
use App\Support\TenantReviewCompletenessState;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
final readonly class ComplianceEvidenceMappingV1
{
public const string VERSION_KEY = 'compliance_evidence_mapping.v1';
public const string SECTION_KEY = 'control_interpretation';
public function __construct(
private CanonicalControlCatalog $catalog,
) {}
/**
* @return array{
* summary: array<string, mixed>,
* section: array<string, mixed>
* }
*/
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<string, mixed>
*/
private function findingsSummary(?EvidenceSnapshotItem $findingsItem): array
{
return is_array($findingsItem?->summary_payload) ? $findingsItem->summary_payload : [];
}
/**
* @param array<string, mixed> $findingsSummary
* @return Collection<int, array<string, mixed>>
*/
private function findingEntries(array $findingsSummary): Collection
{
return collect(Arr::wrap($findingsSummary['entries'] ?? []))
->filter(static fn (mixed $entry): bool => is_array($entry))
->values();
}
/**
* @param array<string, mixed> $findingsSummary
* @param Collection<int, array<string, mixed>> $entries
* @return Collection<int, CanonicalControlDefinition>
*/
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<string, mixed> $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<int, array<string, mixed>> $entries
* @return Collection<int, array<string, mixed>>
*/
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<int, array<string, mixed>> $entries
* @param list<string> $snapshotLimitations
* @return array<string, mixed>
*/
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<int, array<string, mixed>> $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<string> $snapshotLimitations
* @return list<string>
*/
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<string> $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<string> $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<string, mixed> $control
* @return array<string, mixed>
*/
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<array<string, mixed>> $controlSummaries
* @param list<string> $globalLimitations
* @return list<string>
*/
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<array<string, mixed>> $controlSummaries
* @param list<string> $snapshotLimitations
* @return list<string>
*/
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<array<string, mixed>> $controlSummaries
* @param list<string> $globalLimitations
* @return array<string, int>
*/
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<string>
*/
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<string> $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<string, mixed> $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),
};
}
}