TenantAtlas/app/Support/Baselines/BaselineCompareEvidenceGapDetails.php
ahmido c17255f854 feat: implement baseline subject resolution semantics (#193)
## Summary
- add the structured subject-resolution foundation for baseline compare and baseline capture, including capability guards, subject descriptors, resolution outcomes, and operator action categories
- persist structured evidence-gap subject records and update compare/capture surfaces, landing projections, and cleanup tooling to use the new contract
- add Spec 163 artifacts and focused Pest coverage for classification, determinism, cleanup, and DB-only rendering

## Validation
- `vendor/bin/sail bin pint --dirty --format agent`
- `vendor/bin/sail artisan test --compact tests/Unit/Support/Baselines tests/Feature/Baselines tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php`

## Notes
- verified locally that a fresh post-restart baseline compare run now writes structured `baseline_compare.evidence_gaps.subjects` records instead of the legacy broad payload shape
- excluded the separate `docs/product/spec-candidates.md` worktree change from this branch commit and PR

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #193
2026-03-25 12:40:45 +00:00

662 lines
25 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Support\Baselines;
use App\Models\OperationRun;
use Illuminate\Support\Str;
final class BaselineCompareEvidenceGapDetails
{
public static function fromOperationRun(?OperationRun $run): array
{
if (! $run instanceof OperationRun || ! is_array($run->context)) {
return self::empty();
}
return self::fromContext($run->context);
}
/**
* @param array<string, mixed> $context
*/
public static function fromContext(array $context): array
{
$baselineCompare = $context['baseline_compare'] ?? null;
if (! is_array($baselineCompare)) {
return self::empty();
}
return self::fromBaselineCompare($baselineCompare);
}
/**
* @param array<string, mixed> $baselineCompare
*/
public static function fromBaselineCompare(array $baselineCompare): array
{
$evidenceGaps = $baselineCompare['evidence_gaps'] ?? null;
$evidenceGaps = is_array($evidenceGaps) ? $evidenceGaps : [];
$byReason = self::normalizeCounts($evidenceGaps['by_reason'] ?? null);
$normalizedSubjects = self::normalizeSubjects($evidenceGaps['subjects'] ?? null);
foreach ($normalizedSubjects['subjects'] as $reasonCode => $subjects) {
if (! array_key_exists($reasonCode, $byReason)) {
$byReason[$reasonCode] = count($subjects);
}
}
$count = self::normalizeTotalCount(
$evidenceGaps['count'] ?? null,
$byReason,
$normalizedSubjects['subjects'],
);
$detailState = self::detailState($count, $normalizedSubjects);
$buckets = [];
foreach (self::orderedReasons($byReason, $normalizedSubjects['subjects']) as $reasonCode) {
$rows = $detailState === 'structured_details_recorded'
? array_map(
static fn (array $subject): array => self::projectSubjectRow($subject),
$normalizedSubjects['subjects'][$reasonCode] ?? [],
)
: [];
$reasonCount = $byReason[$reasonCode] ?? count($rows);
if ($reasonCount <= 0 && $rows === []) {
continue;
}
$recordedCount = count($rows);
$structuralCount = count(array_filter(
$rows,
static fn (array $row): bool => (bool) ($row['structural'] ?? false),
));
$transientCount = count(array_filter(
$rows,
static fn (array $row): bool => (bool) ($row['retryable'] ?? false),
));
$operationalCount = max(0, $recordedCount - $structuralCount - $transientCount);
$searchText = trim(implode(' ', array_filter([
Str::lower($reasonCode),
Str::lower(self::reasonLabel($reasonCode)),
...array_map(
static fn (array $row): string => (string) ($row['search_text'] ?? ''),
$rows,
),
])));
$buckets[] = [
'reason_code' => $reasonCode,
'reason_label' => self::reasonLabel($reasonCode),
'count' => $reasonCount,
'recorded_count' => $recordedCount,
'missing_detail_count' => max(0, $reasonCount - $recordedCount),
'structural_count' => $structuralCount,
'operational_count' => $operationalCount,
'transient_count' => $transientCount,
'detail_state' => self::bucketDetailState($detailState, $recordedCount),
'search_text' => $searchText,
'rows' => $rows,
];
}
$recordedSubjectsTotal = array_sum(array_map(
static fn (array $bucket): int => (int) ($bucket['recorded_count'] ?? 0),
$buckets,
));
$structuralCount = array_sum(array_map(
static fn (array $bucket): int => (int) ($bucket['structural_count'] ?? 0),
$buckets,
));
$operationalCount = array_sum(array_map(
static fn (array $bucket): int => (int) ($bucket['operational_count'] ?? 0),
$buckets,
));
$transientCount = array_sum(array_map(
static fn (array $bucket): int => (int) ($bucket['transient_count'] ?? 0),
$buckets,
));
$legacyMode = $detailState === 'legacy_broad_reason';
return [
'summary' => [
'count' => $count,
'by_reason' => $byReason,
'detail_state' => $detailState,
'recorded_subjects_total' => $recordedSubjectsTotal,
'missing_detail_count' => max(0, $count - $recordedSubjectsTotal),
'structural_count' => $structuralCount,
'operational_count' => $operationalCount,
'transient_count' => $transientCount,
'legacy_mode' => $legacyMode,
'requires_regeneration' => $legacyMode,
],
'buckets' => $buckets,
];
}
/**
* @param array<string, mixed> $baselineCompare
* @return array<string, mixed>
*/
public static function diagnosticsPayload(array $baselineCompare): array
{
return array_filter([
'reason_code' => self::stringOrNull($baselineCompare['reason_code'] ?? null),
'subjects_total' => self::intOrNull($baselineCompare['subjects_total'] ?? null),
'resume_token' => self::stringOrNull($baselineCompare['resume_token'] ?? null),
'fidelity' => self::stringOrNull($baselineCompare['fidelity'] ?? null),
'coverage' => is_array($baselineCompare['coverage'] ?? null) ? $baselineCompare['coverage'] : null,
'evidence_capture' => is_array($baselineCompare['evidence_capture'] ?? null) ? $baselineCompare['evidence_capture'] : null,
'evidence_gaps' => is_array($baselineCompare['evidence_gaps'] ?? null) ? $baselineCompare['evidence_gaps'] : null,
], static fn (mixed $value): bool => $value !== null && $value !== []);
}
public static function reasonLabel(string $reason): string
{
$reason = trim($reason);
return match ($reason) {
'ambiguous_match' => 'Ambiguous inventory match',
'policy_record_missing' => 'Policy record missing',
'inventory_record_missing' => 'Inventory record missing',
'foundation_not_policy_backed' => 'Foundation not policy-backed',
'invalid_subject' => 'Invalid subject',
'duplicate_subject' => 'Duplicate subject',
'capture_failed' => 'Evidence capture failed',
'retryable_capture_failure' => 'Retryable evidence capture failure',
'budget_exhausted' => 'Capture budget exhausted',
'throttled' => 'Graph throttled',
'invalid_support_config' => 'Invalid support configuration',
'missing_current' => 'Missing current evidence',
'missing_role_definition_baseline_version_reference' => 'Missing baseline role definition evidence',
'missing_role_definition_current_version_reference' => 'Missing current role definition evidence',
'missing_role_definition_compare_surface' => 'Missing role definition compare surface',
'rollout_disabled' => 'Rollout disabled',
'policy_not_found' => 'Legacy policy not found',
default => Str::of($reason)->replace('_', ' ')->trim()->ucfirst()->toString(),
};
}
public static function subjectClassLabel(string $subjectClass): string
{
return match (trim($subjectClass)) {
SubjectClass::PolicyBacked->value => 'Policy-backed',
SubjectClass::InventoryBacked->value => 'Inventory-backed',
SubjectClass::FoundationBacked->value => 'Foundation-backed',
default => 'Derived',
};
}
public static function resolutionOutcomeLabel(string $resolutionOutcome): string
{
return match (trim($resolutionOutcome)) {
ResolutionOutcome::ResolvedPolicy->value => 'Resolved policy',
ResolutionOutcome::ResolvedInventory->value => 'Resolved inventory',
ResolutionOutcome::PolicyRecordMissing->value => 'Policy record missing',
ResolutionOutcome::InventoryRecordMissing->value => 'Inventory record missing',
ResolutionOutcome::FoundationInventoryOnly->value => 'Foundation inventory only',
ResolutionOutcome::InvalidSubject->value => 'Invalid subject',
ResolutionOutcome::DuplicateSubject->value => 'Duplicate subject',
ResolutionOutcome::AmbiguousMatch->value => 'Ambiguous match',
ResolutionOutcome::InvalidSupportConfig->value => 'Invalid support configuration',
ResolutionOutcome::Throttled->value => 'Graph throttled',
ResolutionOutcome::CaptureFailed->value => 'Capture failed',
ResolutionOutcome::RetryableCaptureFailure->value => 'Retryable capture failure',
ResolutionOutcome::BudgetExhausted->value => 'Budget exhausted',
};
}
public static function operatorActionCategoryLabel(string $operatorActionCategory): string
{
return match (trim($operatorActionCategory)) {
OperatorActionCategory::Retry->value => 'Retry',
OperatorActionCategory::RunInventorySync->value => 'Run inventory sync',
OperatorActionCategory::RunPolicySyncOrBackup->value => 'Run policy sync or backup',
OperatorActionCategory::ReviewPermissions->value => 'Review permissions',
OperatorActionCategory::InspectSubjectMapping->value => 'Inspect subject mapping',
OperatorActionCategory::ProductFollowUp->value => 'Product follow-up',
default => 'No action',
};
}
/**
* @param array<string, int> $byReason
* @return list<array{reason_code: string, reason_label: string, count: int}>
*/
public static function topReasons(array $byReason, int $limit = 5): array
{
$normalized = self::normalizeCounts($byReason);
arsort($normalized);
return array_map(
static fn (string $reason, int $count): array => [
'reason_code' => $reason,
'reason_label' => self::reasonLabel($reason),
'count' => $count,
],
array_slice(array_keys($normalized), 0, $limit),
array_slice(array_values($normalized), 0, $limit),
);
}
/**
* @param list<array<string, mixed>> $buckets
* @return list<array<string, mixed>>
*/
public static function tableRows(array $buckets): array
{
$rows = [];
foreach ($buckets as $bucket) {
if (! is_array($bucket)) {
continue;
}
$bucketRows = is_array($bucket['rows'] ?? null) ? $bucket['rows'] : [];
foreach ($bucketRows as $row) {
if (! is_array($row)) {
continue;
}
$reasonCode = self::stringOrNull($row['reason_code'] ?? null);
$policyType = self::stringOrNull($row['policy_type'] ?? null);
$subjectKey = self::stringOrNull($row['subject_key'] ?? null);
$subjectClass = self::stringOrNull($row['subject_class'] ?? null);
$resolutionOutcome = self::stringOrNull($row['resolution_outcome'] ?? null);
$operatorActionCategory = self::stringOrNull($row['operator_action_category'] ?? null);
if ($reasonCode === null || $policyType === null || $subjectKey === null || $subjectClass === null || $resolutionOutcome === null || $operatorActionCategory === null) {
continue;
}
$rows[] = [
'__id' => md5(implode('|', [$reasonCode, $policyType, $subjectKey, $resolutionOutcome])),
'reason_code' => $reasonCode,
'reason_label' => self::reasonLabel($reasonCode),
'policy_type' => $policyType,
'subject_key' => $subjectKey,
'subject_class' => $subjectClass,
'subject_class_label' => self::subjectClassLabel($subjectClass),
'resolution_path' => self::stringOrNull($row['resolution_path'] ?? null),
'resolution_outcome' => $resolutionOutcome,
'resolution_outcome_label' => self::resolutionOutcomeLabel($resolutionOutcome),
'operator_action_category' => $operatorActionCategory,
'operator_action_category_label' => self::operatorActionCategoryLabel($operatorActionCategory),
'structural' => (bool) ($row['structural'] ?? false),
'retryable' => (bool) ($row['retryable'] ?? false),
'search_text' => self::stringOrNull($row['search_text'] ?? null) ?? '',
];
}
}
return $rows;
}
/**
* @param list<array<string, mixed>> $rows
* @return array<string, string>
*/
public static function reasonFilterOptions(array $rows): array
{
return collect($rows)
->filter(fn (array $row): bool => filled($row['reason_code'] ?? null) && filled($row['reason_label'] ?? null))
->mapWithKeys(fn (array $row): array => [
(string) $row['reason_code'] => (string) $row['reason_label'],
])
->sortBy(fn (string $label): string => Str::lower($label))
->all();
}
/**
* @param list<array<string, mixed>> $rows
* @return array<string, string>
*/
public static function policyTypeFilterOptions(array $rows): array
{
return collect($rows)
->pluck('policy_type')
->filter(fn (mixed $value): bool => is_string($value) && trim($value) !== '')
->mapWithKeys(fn (string $value): array => [$value => $value])
->sortKeysUsing('strnatcasecmp')
->all();
}
/**
* @param list<array<string, mixed>> $rows
* @return array<string, string>
*/
public static function subjectClassFilterOptions(array $rows): array
{
return collect($rows)
->filter(fn (array $row): bool => filled($row['subject_class'] ?? null))
->mapWithKeys(fn (array $row): array => [
(string) $row['subject_class'] => (string) ($row['subject_class_label'] ?? self::subjectClassLabel((string) $row['subject_class'])),
])
->sortBy(fn (string $label): string => Str::lower($label))
->all();
}
/**
* @param list<array<string, mixed>> $rows
* @return array<string, string>
*/
public static function actionCategoryFilterOptions(array $rows): array
{
return collect($rows)
->filter(fn (array $row): bool => filled($row['operator_action_category'] ?? null))
->mapWithKeys(fn (array $row): array => [
(string) $row['operator_action_category'] => (string) ($row['operator_action_category_label'] ?? self::operatorActionCategoryLabel((string) $row['operator_action_category'])),
])
->sortBy(fn (string $label): string => Str::lower($label))
->all();
}
private static function empty(): array
{
return [
'summary' => [
'count' => 0,
'by_reason' => [],
'detail_state' => 'no_gaps',
'recorded_subjects_total' => 0,
'missing_detail_count' => 0,
'structural_count' => 0,
'operational_count' => 0,
'transient_count' => 0,
'legacy_mode' => false,
'requires_regeneration' => false,
],
'buckets' => [],
];
}
/**
* @return array<string, int>
*/
private static function normalizeCounts(mixed $value): array
{
if (! is_array($value)) {
return [];
}
$normalized = [];
foreach ($value as $reason => $count) {
if (! is_string($reason) || trim($reason) === '' || ! is_numeric($count)) {
continue;
}
$intCount = (int) $count;
if ($intCount <= 0) {
continue;
}
$normalized[trim($reason)] = $intCount;
}
arsort($normalized);
return $normalized;
}
/**
* @return array{
* subjects: array<string, list<array<string, mixed>>>,
* legacy_mode: bool
* }
*/
private static function normalizeSubjects(mixed $value): array
{
if ($value === null) {
return [
'subjects' => [],
'legacy_mode' => false,
];
}
if (! is_array($value)) {
return [
'subjects' => [],
'legacy_mode' => true,
];
}
if (! array_is_list($value)) {
return [
'subjects' => [],
'legacy_mode' => true,
];
}
$subjects = [];
foreach ($value as $item) {
$normalized = self::normalizeStructuredSubject($item);
if ($normalized === null) {
return [
'subjects' => [],
'legacy_mode' => true,
];
}
$subjects[$normalized['reason_code']][] = $normalized;
}
foreach ($subjects as &$bucket) {
usort($bucket, static function (array $left, array $right): int {
return [$left['policy_type'], $left['subject_key'], $left['resolution_outcome']]
<=> [$right['policy_type'], $right['subject_key'], $right['resolution_outcome']];
});
}
unset($bucket);
ksort($subjects);
return [
'subjects' => $subjects,
'legacy_mode' => false,
];
}
/**
* @return array<string, mixed>|null
*/
private static function normalizeStructuredSubject(mixed $value): ?array
{
if (! is_array($value)) {
return null;
}
$policyType = self::stringOrNull($value['policy_type'] ?? null);
$subjectKey = self::stringOrNull($value['subject_key'] ?? null);
$subjectClass = self::stringOrNull($value['subject_class'] ?? null);
$resolutionPath = self::stringOrNull($value['resolution_path'] ?? null);
$resolutionOutcome = self::stringOrNull($value['resolution_outcome'] ?? null);
$reasonCode = self::stringOrNull($value['reason_code'] ?? null);
$operatorActionCategory = self::stringOrNull($value['operator_action_category'] ?? null);
if ($policyType === null
|| $subjectKey === null
|| $subjectClass === null
|| $resolutionPath === null
|| $resolutionOutcome === null
|| $reasonCode === null
|| $operatorActionCategory === null) {
return null;
}
if (! SubjectClass::tryFrom($subjectClass) instanceof SubjectClass
|| ! ResolutionPath::tryFrom($resolutionPath) instanceof ResolutionPath
|| ! ResolutionOutcome::tryFrom($resolutionOutcome) instanceof ResolutionOutcome
|| ! OperatorActionCategory::tryFrom($operatorActionCategory) instanceof OperatorActionCategory) {
return null;
}
$sourceModelExpected = self::stringOrNull($value['source_model_expected'] ?? null);
$sourceModelExpected = in_array($sourceModelExpected, ['policy', 'inventory', 'derived'], true) ? $sourceModelExpected : null;
$sourceModelFound = self::stringOrNull($value['source_model_found'] ?? null);
$sourceModelFound = in_array($sourceModelFound, ['policy', 'inventory', 'derived'], true) ? $sourceModelFound : null;
return [
'policy_type' => $policyType,
'subject_external_id' => self::stringOrNull($value['subject_external_id'] ?? null),
'subject_key' => $subjectKey,
'subject_class' => $subjectClass,
'resolution_path' => $resolutionPath,
'resolution_outcome' => $resolutionOutcome,
'reason_code' => $reasonCode,
'operator_action_category' => $operatorActionCategory,
'structural' => self::boolOrFalse($value['structural'] ?? null),
'retryable' => self::boolOrFalse($value['retryable'] ?? null),
'source_model_expected' => $sourceModelExpected,
'source_model_found' => $sourceModelFound,
'legacy_reason_code' => self::stringOrNull($value['legacy_reason_code'] ?? null),
];
}
/**
* @param array<string, int> $byReason
* @param array<string, list<array<string, mixed>>> $subjects
* @return list<string>
*/
private static function orderedReasons(array $byReason, array $subjects): array
{
$reasons = array_keys($byReason);
foreach (array_keys($subjects) as $reason) {
if (! in_array($reason, $reasons, true)) {
$reasons[] = $reason;
}
}
return $reasons;
}
/**
* @param array<string, int> $byReason
* @param array<string, list<array<string, mixed>>> $subjects
*/
private static function normalizeTotalCount(mixed $count, array $byReason, array $subjects): int
{
if (is_numeric($count)) {
$intCount = (int) $count;
if ($intCount >= 0) {
return $intCount;
}
}
$byReasonCount = array_sum($byReason);
if ($byReasonCount > 0) {
return $byReasonCount;
}
return array_sum(array_map(
static fn (array $rows): int => count($rows),
$subjects,
));
}
/**
* @param array{subjects: array<string, list<array<string, mixed>>>, legacy_mode: bool} $subjects
*/
private static function detailState(int $count, array $subjects): string
{
if ($count <= 0) {
return 'no_gaps';
}
if ($subjects['legacy_mode']) {
return 'legacy_broad_reason';
}
return $subjects['subjects'] !== [] ? 'structured_details_recorded' : 'details_not_recorded';
}
private static function bucketDetailState(string $detailState, int $recordedCount): string
{
if ($detailState === 'legacy_broad_reason') {
return 'legacy_broad_reason';
}
if ($recordedCount > 0) {
return 'structured_details_recorded';
}
return 'details_not_recorded';
}
/**
* @param array<string, mixed> $subject
* @return array<string, mixed>
*/
private static function projectSubjectRow(array $subject): array
{
$reasonCode = (string) $subject['reason_code'];
$subjectClass = (string) $subject['subject_class'];
$resolutionOutcome = (string) $subject['resolution_outcome'];
$operatorActionCategory = (string) $subject['operator_action_category'];
return array_merge($subject, [
'reason_label' => self::reasonLabel($reasonCode),
'subject_class_label' => self::subjectClassLabel($subjectClass),
'resolution_outcome_label' => self::resolutionOutcomeLabel($resolutionOutcome),
'operator_action_category_label' => self::operatorActionCategoryLabel($operatorActionCategory),
'search_text' => Str::lower(trim(implode(' ', array_filter([
$reasonCode,
self::reasonLabel($reasonCode),
(string) ($subject['policy_type'] ?? ''),
(string) ($subject['subject_key'] ?? ''),
$subjectClass,
self::subjectClassLabel($subjectClass),
(string) ($subject['resolution_path'] ?? ''),
$resolutionOutcome,
self::resolutionOutcomeLabel($resolutionOutcome),
$operatorActionCategory,
self::operatorActionCategoryLabel($operatorActionCategory),
(string) ($subject['subject_external_id'] ?? ''),
])))),
]);
}
private static function stringOrNull(mixed $value): ?string
{
if (! is_string($value)) {
return null;
}
$trimmed = trim($value);
return $trimmed !== '' ? $trimmed : null;
}
private static function intOrNull(mixed $value): ?int
{
return is_numeric($value) ? (int) $value : null;
}
private static function boolOrFalse(mixed $value): bool
{
if (is_bool($value)) {
return $value;
}
if (is_int($value) || is_float($value) || is_string($value)) {
return filter_var($value, FILTER_VALIDATE_BOOL);
}
return false;
}
}