From c17255f8547fecc138d52a38b63d865b935650a9 Mon Sep 17 00:00:00 2001 From: ahmido Date: Wed, 25 Mar 2026 12:40:45 +0000 Subject: [PATCH] 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 Reviewed-on: https://git.cloudarix.de/ahmido/TenantAtlas/pulls/193 --- .github/agents/copilot-instructions.md | 5 +- .../Commands/PurgeLegacyBaselineGapRuns.php | 153 +++++ app/Filament/Pages/BaselineCompareLanding.php | 3 + .../Resources/BaselineSnapshotResource.php | 6 +- .../Resources/OperationRunResource.php | 15 +- app/Jobs/CaptureBaselineSnapshotJob.php | 32 +- app/Jobs/CompareBaselineToTenantJob.php | 182 +++++- .../BaselineCompareEvidenceGapTable.php | 41 +- app/Models/OperationRun.php | 77 +++ .../Baselines/BaselineCaptureService.php | 4 +- .../Baselines/BaselineCompareService.php | 4 +- .../Baselines/BaselineContentCapturePhase.php | 76 ++- .../BaselineCompareEvidenceGapDetails.php | 540 +++++++++++------- .../BaselineCompareExplanationRegistry.php | 30 + .../Baselines/BaselineCompareStats.php | 75 ++- app/Support/Baselines/BaselineScope.php | 30 +- .../BaselineSupportCapabilityGuard.php | 79 +++ .../Baselines/OperatorActionCategory.php | 16 + app/Support/Baselines/ResolutionOutcome.php | 25 + .../Baselines/ResolutionOutcomeRecord.php | 36 ++ app/Support/Baselines/ResolutionPath.php | 14 + app/Support/Baselines/SubjectClass.php | 13 + app/Support/Baselines/SubjectDescriptor.php | 47 ++ app/Support/Baselines/SubjectResolver.php | 201 +++++++ .../Baselines/SupportCapabilityRecord.php | 67 +++ .../Inventory/InventoryPolicyTypeMeta.php | 140 +++++ config/tenantpilot.php | 35 ++ lang/en/baseline-compare.php | 21 +- .../entries/evidence-gap-subjects.blade.php | 45 +- .../pages/baseline-compare-landing.blade.php | 2 + .../checklists/requirements.md | 35 ++ .../contracts/openapi.yaml | 334 +++++++++++ .../data-model.md | 167 ++++++ specs/163-baseline-subject-resolution/plan.md | 246 ++++++++ .../quickstart.md | 87 +++ .../research.md | 65 +++ specs/163-baseline-subject-resolution/spec.md | 174 ++++++ .../163-baseline-subject-resolution/tasks.md | 194 +++++++ .../BaselineCaptureGapClassificationTest.php | 110 ++++ .../BaselineCompareAmbiguousMatchGapTest.php | 17 +- .../BaselineCompareGapClassificationTest.php | 143 +++++ .../BaselineCompareResumeTokenTest.php | 22 +- .../BaselineGapContractCleanupTest.php | 137 +++++ .../BaselineResolutionDeterminismTest.php | 192 +++++++ .../BaselineSupportCapabilityGuardTest.php | 121 ++++ .../Support/AssertsStructuredBaselineGaps.php | 43 ++ .../BaselineSubjectResolutionFixtures.php | 85 +++ .../BaselineCompareEvidenceGapTableTest.php | 85 ++- ...ineCompareLandingAdminTenantParityTest.php | 53 +- ...CompareLandingDuplicateNamesBannerTest.php | 9 +- ...aselineCompareLandingWhyNoFindingsTest.php | 2 +- .../BaselineGapSurfacesDbOnlyRenderTest.php | 135 +++++ .../OperationRunEnterpriseDetailPageTest.php | 66 ++- .../Support/Baselines/SubjectResolverTest.php | 95 +++ ...ntoryPolicyTypeMetaBaselineSupportTest.php | 60 ++ ...ryPolicyTypeMetaResolutionContractTest.php | 78 +++ 56 files changed, 4393 insertions(+), 376 deletions(-) create mode 100644 app/Console/Commands/PurgeLegacyBaselineGapRuns.php create mode 100644 app/Support/Baselines/BaselineSupportCapabilityGuard.php create mode 100644 app/Support/Baselines/OperatorActionCategory.php create mode 100644 app/Support/Baselines/ResolutionOutcome.php create mode 100644 app/Support/Baselines/ResolutionOutcomeRecord.php create mode 100644 app/Support/Baselines/ResolutionPath.php create mode 100644 app/Support/Baselines/SubjectClass.php create mode 100644 app/Support/Baselines/SubjectDescriptor.php create mode 100644 app/Support/Baselines/SubjectResolver.php create mode 100644 app/Support/Baselines/SupportCapabilityRecord.php create mode 100644 specs/163-baseline-subject-resolution/checklists/requirements.md create mode 100644 specs/163-baseline-subject-resolution/contracts/openapi.yaml create mode 100644 specs/163-baseline-subject-resolution/data-model.md create mode 100644 specs/163-baseline-subject-resolution/plan.md create mode 100644 specs/163-baseline-subject-resolution/quickstart.md create mode 100644 specs/163-baseline-subject-resolution/research.md create mode 100644 specs/163-baseline-subject-resolution/spec.md create mode 100644 specs/163-baseline-subject-resolution/tasks.md create mode 100644 tests/Feature/Baselines/BaselineCaptureGapClassificationTest.php create mode 100644 tests/Feature/Baselines/BaselineCompareGapClassificationTest.php create mode 100644 tests/Feature/Baselines/BaselineGapContractCleanupTest.php create mode 100644 tests/Feature/Baselines/BaselineResolutionDeterminismTest.php create mode 100644 tests/Feature/Baselines/BaselineSupportCapabilityGuardTest.php create mode 100644 tests/Feature/Baselines/Support/AssertsStructuredBaselineGaps.php create mode 100644 tests/Feature/Baselines/Support/BaselineSubjectResolutionFixtures.php create mode 100644 tests/Feature/Filament/BaselineGapSurfacesDbOnlyRenderTest.php create mode 100644 tests/Unit/Support/Baselines/SubjectResolverTest.php create mode 100644 tests/Unit/Support/Inventory/InventoryPolicyTypeMetaBaselineSupportTest.php create mode 100644 tests/Unit/Support/Inventory/InventoryPolicyTypeMetaResolutionContractTest.php diff --git a/.github/agents/copilot-instructions.md b/.github/agents/copilot-instructions.md index ea348d12..d34058c6 100644 --- a/.github/agents/copilot-instructions.md +++ b/.github/agents/copilot-instructions.md @@ -105,6 +105,7 @@ ## Active Technologies - PostgreSQL (via Sail) plus existing read models persisted in application tables (161-operator-explanation-layer) - PHP 8.4 / Laravel 12, Blade, Alpine via Filament, Tailwind CSS v4 + Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `OperationRun` and baseline compare services (162-baseline-gap-details) - PostgreSQL with JSONB-backed `operation_runs.context`; no new tables required (162-baseline-gap-details) +- PostgreSQL via existing application tables, especially `operation_runs.context` and baseline snapshot summary JSON (163-baseline-subject-resolution) - PHP 8.4.15 (feat/005-bulk-operations) @@ -124,8 +125,8 @@ ## Code Style PHP 8.4.15: Follow standard conventions ## Recent Changes +- 163-baseline-subject-resolution-session-1774398153: Added PHP 8.4 + Laravel 12, Filament v5, Livewire v4 +- 163-baseline-subject-resolution: Added PHP 8.4 + Laravel 12, Filament v5, Livewire v4 - 162-baseline-gap-details: Added PHP 8.4 / Laravel 12, Blade, Alpine via Filament, Tailwind CSS v4 + Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `OperationRun` and baseline compare services -- 161-operator-explanation-layer: Added PHP 8.4 + Laravel 12, Filament v5, Livewire v4 -- 160-operation-lifecycle-guarantees: Added PHP 8.4.15 + Laravel 12, Filament 5, Livewire 4, Pest 4, Laravel queue workers, existing `OperationRunService`, `TrackOperationRun`, `OperationUxPresenter`, `ReasonPresenter`, `BadgeCatalog` domain badges, and current Operations Monitoring pages diff --git a/app/Console/Commands/PurgeLegacyBaselineGapRuns.php b/app/Console/Commands/PurgeLegacyBaselineGapRuns.php new file mode 100644 index 00000000..95a977fe --- /dev/null +++ b/app/Console/Commands/PurgeLegacyBaselineGapRuns.php @@ -0,0 +1,153 @@ +environment(['local', 'testing'])) { + $this->error('This cleanup command is limited to local and testing environments.'); + + return self::FAILURE; + } + + $types = $this->normalizedTypes(); + $workspaceIds = array_values(array_filter( + array_map( + static fn (mixed $workspaceId): int => is_numeric($workspaceId) ? (int) $workspaceId : 0, + (array) $this->option('workspace'), + ), + static fn (int $workspaceId): bool => $workspaceId > 0, + )); + $tenantIds = $this->resolveTenantIds(array_values(array_filter((array) $this->option('tenant')))); + $limit = max(1, (int) $this->option('limit')); + $dryRun = ! (bool) $this->option('force'); + + $query = OperationRun::query() + ->whereIn('type', $types) + ->orderBy('id') + ->limit($limit); + + if ($workspaceIds !== []) { + $query->whereIn('workspace_id', $workspaceIds); + } + + if ($tenantIds !== []) { + $query->whereIn('tenant_id', $tenantIds); + } + + $candidates = $query->get(); + $matched = $candidates + ->filter(static fn (OperationRun $run): bool => $run->hasLegacyBaselineGapPayload()) + ->values(); + + if ($matched->isEmpty()) { + $this->info('No legacy baseline gap runs matched the current filters.'); + + return self::SUCCESS; + } + + $this->table( + ['Run', 'Type', 'Tenant', 'Workspace', 'Legacy signal'], + $matched + ->map(fn (OperationRun $run): array => [ + 'Run' => (string) $run->getKey(), + 'Type' => (string) $run->type, + 'Tenant' => $run->tenant_id !== null ? (string) $run->tenant_id : '—', + 'Workspace' => $run->workspace_id !== null ? (string) $run->workspace_id : '—', + 'Legacy signal' => $this->legacySignal($run), + ]) + ->all(), + ); + + if ($dryRun) { + $this->warn(sprintf( + 'Dry run: matched %d legacy baseline run(s). Re-run with --force to delete them.', + $matched->count(), + )); + + return self::SUCCESS; + } + + OperationRun::query() + ->whereKey($matched->modelKeys()) + ->delete(); + + $this->info(sprintf('Deleted %d legacy baseline run(s).', $matched->count())); + + return self::SUCCESS; + } + + /** + * @return array + */ + private function normalizedTypes(): array + { + $types = array_values(array_unique(array_filter( + array_map( + static fn (mixed $type): ?string => is_string($type) && trim($type) !== '' ? trim($type) : null, + (array) $this->option('type'), + ), + ))); + + if ($types === []) { + return ['baseline_compare', 'baseline_capture']; + } + + return array_values(array_filter( + $types, + static fn (string $type): bool => in_array($type, ['baseline_compare', 'baseline_capture'], true), + )); + } + + /** + * @param array $tenantIdentifiers + * @return array + */ + private function resolveTenantIds(array $tenantIdentifiers): array + { + if ($tenantIdentifiers === []) { + return []; + } + + $tenantIds = []; + + foreach ($tenantIdentifiers as $identifier) { + $tenant = Tenant::query()->forTenant($identifier)->first(); + + if ($tenant instanceof Tenant) { + $tenantIds[] = (int) $tenant->getKey(); + } + } + + return array_values(array_unique($tenantIds)); + } + + private function legacySignal(OperationRun $run): string + { + $byReason = $run->baselineGapEnvelope()['by_reason'] ?? null; + $byReason = is_array($byReason) ? $byReason : []; + + if (array_key_exists('policy_not_found', $byReason)) { + return 'legacy_reason_code'; + } + + return 'legacy_subject_shape'; + } +} diff --git a/app/Filament/Pages/BaselineCompareLanding.php b/app/Filament/Pages/BaselineCompareLanding.php index 42fec8ba..d8fba82e 100644 --- a/app/Filament/Pages/BaselineCompareLanding.php +++ b/app/Filament/Pages/BaselineCompareLanding.php @@ -60,6 +60,8 @@ class BaselineCompareLanding extends Page public ?int $duplicateNamePoliciesCount = null; + public ?int $duplicateNameSubjectsCount = null; + public ?int $operationRunId = null; public ?int $findingsCount = null; @@ -136,6 +138,7 @@ public function refreshStats(): void $this->profileId = $stats->profileId; $this->snapshotId = $stats->snapshotId; $this->duplicateNamePoliciesCount = $stats->duplicateNamePoliciesCount; + $this->duplicateNameSubjectsCount = $stats->duplicateNameSubjectsCount; $this->operationRunId = $stats->operationRunId; $this->findingsCount = $stats->findingsCount; $this->severityCounts = $stats->severityCounts !== [] ? $stats->severityCounts : null; diff --git a/app/Filament/Resources/BaselineSnapshotResource.php b/app/Filament/Resources/BaselineSnapshotResource.php index 1fac0486..026514c7 100644 --- a/app/Filament/Resources/BaselineSnapshotResource.php +++ b/app/Filament/Resources/BaselineSnapshotResource.php @@ -371,9 +371,9 @@ private static function applyLifecycleFilter(Builder $query, mixed $value): Buil private static function gapCountExpression(Builder $query): string { return match ($query->getConnection()->getDriverName()) { - 'sqlite' => "COALESCE(CAST(json_extract(summary_jsonb, '$.gaps.by_reason.missing_evidence') AS INTEGER), 0) + COALESCE(CAST(json_extract(summary_jsonb, '$.gaps.by_reason.missing_role_definition_version_reference') AS INTEGER), 0) + COALESCE(CAST(json_extract(summary_jsonb, '$.gaps.by_reason.invalid_subject') AS INTEGER), 0) + COALESCE(CAST(json_extract(summary_jsonb, '$.gaps.by_reason.duplicate_subject') AS INTEGER), 0) + COALESCE(CAST(json_extract(summary_jsonb, '$.gaps.by_reason.policy_not_found') AS INTEGER), 0) + COALESCE(CAST(json_extract(summary_jsonb, '$.gaps.by_reason.throttled') AS INTEGER), 0) + COALESCE(CAST(json_extract(summary_jsonb, '$.gaps.by_reason.capture_failed') AS INTEGER), 0) + COALESCE(CAST(json_extract(summary_jsonb, '$.gaps.by_reason.budget_exhausted') AS INTEGER), 0)", - 'pgsql' => "COALESCE((summary_jsonb #>> '{gaps,by_reason,missing_evidence}')::int, 0) + COALESCE((summary_jsonb #>> '{gaps,by_reason,missing_role_definition_version_reference}')::int, 0) + COALESCE((summary_jsonb #>> '{gaps,by_reason,invalid_subject}')::int, 0) + COALESCE((summary_jsonb #>> '{gaps,by_reason,duplicate_subject}')::int, 0) + COALESCE((summary_jsonb #>> '{gaps,by_reason,policy_not_found}')::int, 0) + COALESCE((summary_jsonb #>> '{gaps,by_reason,throttled}')::int, 0) + COALESCE((summary_jsonb #>> '{gaps,by_reason,capture_failed}')::int, 0) + COALESCE((summary_jsonb #>> '{gaps,by_reason,budget_exhausted}')::int, 0)", - default => "COALESCE(CAST(JSON_EXTRACT(summary_jsonb, '$.gaps.by_reason.missing_evidence') AS UNSIGNED), 0) + COALESCE(CAST(JSON_EXTRACT(summary_jsonb, '$.gaps.by_reason.missing_role_definition_version_reference') AS UNSIGNED), 0) + COALESCE(CAST(JSON_EXTRACT(summary_jsonb, '$.gaps.by_reason.invalid_subject') AS UNSIGNED), 0) + COALESCE(CAST(JSON_EXTRACT(summary_jsonb, '$.gaps.by_reason.duplicate_subject') AS UNSIGNED), 0) + COALESCE(CAST(JSON_EXTRACT(summary_jsonb, '$.gaps.by_reason.policy_not_found') AS UNSIGNED), 0) + COALESCE(CAST(JSON_EXTRACT(summary_jsonb, '$.gaps.by_reason.throttled') AS UNSIGNED), 0) + COALESCE(CAST(JSON_EXTRACT(summary_jsonb, '$.gaps.by_reason.capture_failed') AS UNSIGNED), 0) + COALESCE(CAST(JSON_EXTRACT(summary_jsonb, '$.gaps.by_reason.budget_exhausted') AS UNSIGNED), 0)", + 'sqlite' => "MAX(0, COALESCE(CAST(json_extract(summary_jsonb, '$.gaps.count') AS INTEGER), 0) - COALESCE(CAST(json_extract(summary_jsonb, '$.gaps.by_reason.meta_fallback') AS INTEGER), 0))", + 'pgsql' => "GREATEST(0, COALESCE((summary_jsonb #>> '{gaps,count}')::int, 0) - COALESCE((summary_jsonb #>> '{gaps,by_reason,meta_fallback}')::int, 0))", + default => "GREATEST(0, COALESCE(CAST(JSON_EXTRACT(summary_jsonb, '$.gaps.count') AS SIGNED), 0) - COALESCE(CAST(JSON_EXTRACT(summary_jsonb, '$.gaps.by_reason.meta_fallback') AS SIGNED), 0))", }; } diff --git a/app/Filament/Resources/OperationRunResource.php b/app/Filament/Resources/OperationRunResource.php index 601096f3..69d748e3 100644 --- a/app/Filament/Resources/OperationRunResource.php +++ b/app/Filament/Resources/OperationRunResource.php @@ -653,13 +653,26 @@ private static function baselineCompareFacts( $facts[] = $factory->keyFact( 'Evidence gap detail', match ($gapSummary['detail_state'] ?? 'no_gaps') { - 'details_recorded' => 'Recorded subjects available', + 'structured_details_recorded' => 'Structured subject details available', 'details_not_recorded' => 'Detailed rows were not recorded', + 'legacy_broad_reason' => 'Legacy development payload should be regenerated', default => 'No evidence gaps recorded', }, ); } + if ((int) ($gapSummary['structural_count'] ?? 0) > 0) { + $facts[] = $factory->keyFact('Structural gaps', (string) (int) $gapSummary['structural_count']); + } + + if ((int) ($gapSummary['operational_count'] ?? 0) > 0) { + $facts[] = $factory->keyFact('Operational gaps', (string) (int) $gapSummary['operational_count']); + } + + if ((int) ($gapSummary['transient_count'] ?? 0) > 0) { + $facts[] = $factory->keyFact('Transient gaps', (string) (int) $gapSummary['transient_count']); + } + if ($uncoveredTypes !== []) { sort($uncoveredTypes, SORT_STRING); $facts[] = $factory->keyFact('Uncovered types', implode(', ', array_slice($uncoveredTypes, 0, 12)).(count($uncoveredTypes) > 12 ? '…' : '')); diff --git a/app/Jobs/CaptureBaselineSnapshotJob.php b/app/Jobs/CaptureBaselineSnapshotJob.php index ed49297a..2d748f77 100644 --- a/app/Jobs/CaptureBaselineSnapshotJob.php +++ b/app/Jobs/CaptureBaselineSnapshotJob.php @@ -108,6 +108,7 @@ public function handle( : null; $effectiveScope = BaselineScope::fromJsonb($context['effective_scope'] ?? null); + $truthfulTypes = $this->truthfulTypesFromContext($context, $effectiveScope); $captureMode = $profile->capture_mode instanceof BaselineCaptureMode ? $profile->capture_mode @@ -127,6 +128,7 @@ public function handle( scope: $effectiveScope, identity: $identity, latestInventorySyncRunId: $latestInventorySyncRunId, + policyTypes: $truthfulTypes, ); $subjects = $inventoryResult['subjects']; @@ -262,6 +264,9 @@ public function handle( 'gaps' => [ 'count' => $gapsCount, 'by_reason' => $gapsByReason, + 'subjects' => is_array($phaseResult['gap_subjects'] ?? null) && $phaseResult['gap_subjects'] !== [] + ? array_values($phaseResult['gap_subjects']) + : null, ], 'resume_token' => $resumeToken, ], @@ -296,7 +301,7 @@ public function handle( /** * @return array{ * subjects_total: int, - * subjects: list, + * subjects: list, * inventory_by_key: arraywhere('tenant_id', $sourceTenant->getKey()); @@ -325,7 +331,7 @@ private function collectInventorySubjects( $query->where('last_seen_operation_run_id', $latestInventorySyncRunId); } - $query->whereIn('policy_type', $scope->allTypes()); + $query->whereIn('policy_type', is_array($policyTypes) && $policyTypes !== [] ? $policyTypes : $scope->allTypes()); /** @var array $inventoryByKey */ $inventoryByKey = []; @@ -413,6 +419,7 @@ private function collectInventorySubjects( static fn (array $item): array => [ 'policy_type' => (string) $item['policy_type'], 'subject_external_id' => (string) $item['tenant_subject_external_id'], + 'subject_key' => (string) $item['subject_key'], ], $inventoryByKey, )); @@ -425,6 +432,27 @@ private function collectInventorySubjects( ]; } + /** + * @param array $context + * @return list + */ + private function truthfulTypesFromContext(array $context, BaselineScope $effectiveScope): array + { + $truthfulTypes = data_get($context, 'effective_scope.truthful_types'); + + if (is_array($truthfulTypes)) { + $truthfulTypes = array_values(array_filter($truthfulTypes, 'is_string')); + + if ($truthfulTypes !== []) { + sort($truthfulTypes, SORT_STRING); + + return $truthfulTypes; + } + } + + return $effectiveScope->allTypes(); + } + /** * @param arrayallTypes(); + $effectiveTypes = $this->truthfulTypesFromContext($context, $effectiveScope); $scopeKey = 'baseline_profile:'.$profile->getKey(); $captureMode = $profile->capture_mode instanceof BaselineCaptureMode @@ -363,6 +364,7 @@ public function handle( static fn (array $item): array => [ 'policy_type' => (string) $item['policy_type'], 'subject_external_id' => (string) $item['subject_external_id'], + 'subject_key' => (string) $item['subject_key'], ], $currentItems, )); @@ -1111,6 +1113,27 @@ private function snapshotBlockedMessage(string $reasonCode): string }; } + /** + * @param array $context + * @return list + */ + private function truthfulTypesFromContext(array $context, BaselineScope $effectiveScope): array + { + $truthfulTypes = data_get($context, 'effective_scope.truthful_types'); + + if (is_array($truthfulTypes)) { + $truthfulTypes = array_values(array_filter($truthfulTypes, 'is_string')); + + if ($truthfulTypes !== []) { + sort($truthfulTypes, SORT_STRING); + + return $truthfulTypes; + } + } + + return $effectiveScope->allTypes(); + } + /** * Compare baseline items vs current inventory and produce drift results. * @@ -1961,40 +1984,159 @@ private function mergeGapCounts(array ...$gaps): array /** * @param list $ambiguousKeys - * @param array> $phaseGapSubjects - * @param array> $driftGapSubjects - * @return array> + * @return list> */ - private function collectGapSubjects(array $ambiguousKeys, array $phaseGapSubjects, array $driftGapSubjects): array + private function collectGapSubjects(array $ambiguousKeys, mixed $phaseGapSubjects, mixed $driftGapSubjects): array { $subjects = []; + $seen = []; if ($ambiguousKeys !== []) { - $subjects['ambiguous_match'] = array_slice($ambiguousKeys, 0, self::GAP_SUBJECTS_LIMIT); - } - - foreach ([$phaseGapSubjects, $driftGapSubjects] as $subjectMap) { - foreach ($subjectMap as $reason => $keys) { - if (! is_string($reason) || ! is_array($keys) || $keys === []) { + foreach (array_slice($ambiguousKeys, 0, self::GAP_SUBJECTS_LIMIT) as $ambiguousKey) { + if (! is_string($ambiguousKey) || $ambiguousKey === '') { continue; } - $subjects[$reason] = array_slice( - array_values(array_unique([ - ...($subjects[$reason] ?? []), - ...array_values(array_filter($keys, 'is_string')), - ])), - 0, - self::GAP_SUBJECTS_LIMIT, - ); + [$policyType, $subjectKey] = $this->splitGapSubjectKey($ambiguousKey); + + if ($policyType === null || $subjectKey === null) { + continue; + } + + $descriptor = $this->subjectResolver()->describeForCompare($policyType, subjectKey: $subjectKey); + $record = array_merge($descriptor->toArray(), $this->subjectResolver()->ambiguousMatch($descriptor)->toArray()); + $fingerprint = md5(json_encode([$record['policy_type'], $record['subject_key'], $record['reason_code']])); + + if (isset($seen[$fingerprint])) { + continue; + } + + $seen[$fingerprint] = true; + $subjects[] = $record; } } - ksort($subjects); + foreach ($this->normalizeStructuredGapSubjects($phaseGapSubjects) as $record) { + $fingerprint = md5(json_encode([$record['policy_type'] ?? null, $record['subject_key'] ?? null, $record['reason_code'] ?? null])); + + if (isset($seen[$fingerprint])) { + continue; + } + + $seen[$fingerprint] = true; + $subjects[] = $record; + } + + foreach ($this->normalizeLegacyGapSubjects($driftGapSubjects) as $record) { + $fingerprint = md5(json_encode([$record['policy_type'] ?? null, $record['subject_key'] ?? null, $record['reason_code'] ?? null])); + + if (isset($seen[$fingerprint])) { + continue; + } + + $seen[$fingerprint] = true; + $subjects[] = $record; + } + + return array_slice($subjects, 0, self::GAP_SUBJECTS_LIMIT); + } + + /** + * @return list> + */ + private function normalizeStructuredGapSubjects(mixed $value): array + { + if (! is_array($value)) { + return []; + } + + $subjects = []; + + foreach ($value as $record) { + if (! is_array($record)) { + continue; + } + + if (! is_string($record['policy_type'] ?? null) || ! is_string($record['subject_key'] ?? null) || ! is_string($record['reason_code'] ?? null)) { + continue; + } + + $subjects[] = $record; + } return $subjects; } + /** + * @return list> + */ + private function normalizeLegacyGapSubjects(mixed $value): array + { + if (! is_array($value)) { + return []; + } + + $subjects = []; + + foreach ($value as $reasonCode => $keys) { + if (! is_string($reasonCode) || ! is_array($keys)) { + continue; + } + + foreach ($keys as $key) { + if (! is_string($key) || $key === '') { + continue; + } + + [$policyType, $subjectKey] = $this->splitGapSubjectKey($key); + + if ($policyType === null || $subjectKey === null) { + continue; + } + + $descriptor = $this->subjectResolver()->describeForCompare($policyType, subjectKey: $subjectKey); + $outcome = match ($reasonCode) { + 'missing_current' => $this->subjectResolver()->missingExpectedRecord($descriptor), + 'ambiguous_match' => $this->subjectResolver()->ambiguousMatch($descriptor), + default => $this->subjectResolver()->captureFailed($descriptor), + }; + + $record = array_merge($descriptor->toArray(), $outcome->toArray()); + $record['reason_code'] = $reasonCode; + $subjects[] = $record; + } + } + + return $subjects; + } + + /** + * @return array{0: ?string, 1: ?string} + */ + private function splitGapSubjectKey(string $value): array + { + $parts = explode('|', $value, 2); + + if (count($parts) !== 2) { + return [null, null]; + } + + [$policyType, $subjectKey] = $parts; + $policyType = trim($policyType); + $subjectKey = trim($subjectKey); + + if ($policyType === '' || $subjectKey === '') { + return [null, null]; + } + + return [$policyType, $subjectKey]; + } + + private function subjectResolver(): SubjectResolver + { + return app(SubjectResolver::class); + } + /** * @param array}> $currentItems * @param array $resolvedCurrentEvidence diff --git a/app/Livewire/BaselineCompareEvidenceGapTable.php b/app/Livewire/BaselineCompareEvidenceGapTable.php index fad2a44a..43ce7430 100644 --- a/app/Livewire/BaselineCompareEvidenceGapTable.php +++ b/app/Livewire/BaselineCompareEvidenceGapTable.php @@ -20,14 +20,7 @@ class BaselineCompareEvidenceGapTable extends TableComponent { /** - * @var list + * @var list> */ public array $gapRows = []; @@ -84,6 +77,12 @@ public function table(Table $table): Table SelectFilter::make('policy_type') ->label(__('baseline-compare.evidence_gap_policy_type')) ->options(BaselineCompareEvidenceGapDetails::policyTypeFilterOptions($this->gapRows)), + SelectFilter::make('subject_class') + ->label(__('baseline-compare.evidence_gap_subject_class')) + ->options(BaselineCompareEvidenceGapDetails::subjectClassFilterOptions($this->gapRows)), + SelectFilter::make('operator_action_category') + ->label(__('baseline-compare.evidence_gap_next_action')) + ->options(BaselineCompareEvidenceGapDetails::actionCategoryFilterOptions($this->gapRows)), ]) ->striped() ->deferLoading(! app()->runningUnitTests()) @@ -103,6 +102,22 @@ public function table(Table $table): Table ->searchable() ->sortable() ->wrap(), + TextColumn::make('subject_class_label') + ->label(__('baseline-compare.evidence_gap_subject_class')) + ->badge() + ->searchable() + ->sortable() + ->wrap(), + TextColumn::make('resolution_outcome_label') + ->label(__('baseline-compare.evidence_gap_outcome')) + ->searchable() + ->sortable() + ->wrap(), + TextColumn::make('operator_action_category_label') + ->label(__('baseline-compare.evidence_gap_next_action')) + ->searchable() + ->sortable() + ->wrap(), TextColumn::make('subject_key') ->label(__('baseline-compare.evidence_gap_subject_key')) ->searchable() @@ -131,6 +146,8 @@ private function filterRows(Collection $rows, ?string $search, array $filters): $normalizedSearch = Str::lower(trim((string) $search)); $reasonCode = $filters['reason_code']['value'] ?? null; $policyType = $filters['policy_type']['value'] ?? null; + $subjectClass = $filters['subject_class']['value'] ?? null; + $operatorActionCategory = $filters['operator_action_category']['value'] ?? null; return $rows ->when( @@ -149,6 +166,14 @@ function (Collection $rows) use ($normalizedSearch): Collection { filled($policyType), fn (Collection $rows): Collection => $rows->where('policy_type', (string) $policyType) ) + ->when( + filled($subjectClass), + fn (Collection $rows): Collection => $rows->where('subject_class', (string) $subjectClass) + ) + ->when( + filled($operatorActionCategory), + fn (Collection $rows): Collection => $rows->where('operator_action_category', (string) $operatorActionCategory) + ) ->values(); } diff --git a/app/Models/OperationRun.php b/app/Models/OperationRun.php index ffc3447e..7f19c8d9 100644 --- a/app/Models/OperationRun.php +++ b/app/Models/OperationRun.php @@ -193,4 +193,81 @@ public function freshnessState(): OperationRunFreshnessState { return OperationRunFreshnessState::forRun($this); } + + /** + * @return array + */ + public function baselineGapEnvelope(): array + { + $context = is_array($this->context) ? $this->context : []; + + return match ((string) $this->type) { + 'baseline_compare' => is_array(data_get($context, 'baseline_compare.evidence_gaps')) + ? data_get($context, 'baseline_compare.evidence_gaps') + : [], + 'baseline_capture' => is_array(data_get($context, 'baseline_capture.gaps')) + ? data_get($context, 'baseline_capture.gaps') + : [], + default => [], + }; + } + + public function hasStructuredBaselineGapPayload(): bool + { + $subjects = $this->baselineGapEnvelope()['subjects'] ?? null; + + if (! is_array($subjects) || ! array_is_list($subjects) || $subjects === []) { + return false; + } + + foreach ($subjects as $subject) { + if (! is_array($subject)) { + return false; + } + + foreach ([ + 'policy_type', + 'subject_key', + 'subject_class', + 'resolution_path', + 'resolution_outcome', + 'reason_code', + 'operator_action_category', + 'structural', + 'retryable', + ] as $key) { + if (! array_key_exists($key, $subject)) { + return false; + } + } + } + + return true; + } + + public function hasLegacyBaselineGapPayload(): bool + { + $envelope = $this->baselineGapEnvelope(); + $byReason = is_array($envelope['by_reason'] ?? null) ? $envelope['by_reason'] : []; + + if (array_key_exists('policy_not_found', $byReason)) { + return true; + } + + $subjects = $envelope['subjects'] ?? null; + + if (! is_array($subjects)) { + return false; + } + + if (! array_is_list($subjects)) { + return $subjects !== []; + } + + if ($subjects === []) { + return false; + } + + return ! $this->hasStructuredBaselineGapPayload(); + } } diff --git a/app/Services/Baselines/BaselineCaptureService.php b/app/Services/Baselines/BaselineCaptureService.php index 4b259661..2904b23b 100644 --- a/app/Services/Baselines/BaselineCaptureService.php +++ b/app/Services/Baselines/BaselineCaptureService.php @@ -15,6 +15,7 @@ use App\Support\Baselines\BaselineProfileStatus; use App\Support\Baselines\BaselineReasonCodes; use App\Support\Baselines\BaselineScope; +use App\Support\Baselines\BaselineSupportCapabilityGuard; use App\Support\OperationRunType; final class BaselineCaptureService @@ -22,6 +23,7 @@ final class BaselineCaptureService public function __construct( private readonly OperationRunService $runs, private readonly BaselineFullContentRolloutGate $rolloutGate, + private readonly BaselineSupportCapabilityGuard $capabilityGuard, ) {} /** @@ -53,7 +55,7 @@ public function startCapture( ], 'baseline_profile_id' => (int) $profile->getKey(), 'source_tenant_id' => (int) $sourceTenant->getKey(), - 'effective_scope' => $effectiveScope->toEffectiveScopeContext(), + 'effective_scope' => $effectiveScope->toEffectiveScopeContext($this->capabilityGuard, 'capture'), 'capture_mode' => $captureMode->value, ]; diff --git a/app/Services/Baselines/BaselineCompareService.php b/app/Services/Baselines/BaselineCompareService.php index 46664068..b9bd59ad 100644 --- a/app/Services/Baselines/BaselineCompareService.php +++ b/app/Services/Baselines/BaselineCompareService.php @@ -17,6 +17,7 @@ use App\Support\Baselines\BaselineProfileStatus; use App\Support\Baselines\BaselineReasonCodes; use App\Support\Baselines\BaselineScope; +use App\Support\Baselines\BaselineSupportCapabilityGuard; use App\Support\OperationRunType; use App\Support\ReasonTranslation\ReasonPresenter; @@ -26,6 +27,7 @@ public function __construct( private readonly OperationRunService $runs, private readonly BaselineFullContentRolloutGate $rolloutGate, private readonly BaselineSnapshotTruthResolver $snapshotTruthResolver, + private readonly BaselineSupportCapabilityGuard $capabilityGuard, ) {} /** @@ -101,7 +103,7 @@ public function startCompare( ], 'baseline_profile_id' => (int) $profile->getKey(), 'baseline_snapshot_id' => $snapshotId, - 'effective_scope' => $effectiveScope->toEffectiveScopeContext(), + 'effective_scope' => $effectiveScope->toEffectiveScopeContext($this->capabilityGuard, 'compare'), 'capture_mode' => $captureMode->value, ]; diff --git a/app/Services/Baselines/BaselineContentCapturePhase.php b/app/Services/Baselines/BaselineContentCapturePhase.php index 8d8e4dde..a925e02c 100644 --- a/app/Services/Baselines/BaselineContentCapturePhase.php +++ b/app/Services/Baselines/BaselineContentCapturePhase.php @@ -10,23 +10,28 @@ use App\Services\Intune\PolicyCaptureOrchestrator; use App\Support\Baselines\BaselineEvidenceResumeToken; use App\Support\Baselines\PolicyVersionCapturePurpose; +use App\Support\Baselines\ResolutionOutcomeRecord; +use App\Support\Baselines\ResolutionPath; +use App\Support\Baselines\SubjectDescriptor; +use App\Support\Baselines\SubjectResolver; use Throwable; final class BaselineContentCapturePhase { public function __construct( private readonly PolicyCaptureOrchestrator $captureOrchestrator, + private readonly ?SubjectResolver $subjectResolver = null, ) {} /** * Capture baseline-purpose policy versions (content + assignments + scope tags) within a run budget. * - * @param list $subjects + * @param list $subjects * @param array{max_items_per_run: int, max_concurrency: int, max_retries: int} $budgets * @return array{ * stats: array{requested: int, succeeded: int, skipped: int, failed: int, throttled: int}, * gaps: array, - * gap_subjects: array>, + * gap_subjects: list>, * resume_token: ?string, * captured_versions: array $gaps */ $gaps = []; - /** @var array> $gapSubjects */ + /** @var list> $gapSubjects */ $gapSubjects = []; $capturedVersions = []; @@ -90,26 +95,40 @@ public function capture( foreach ($chunk as $subject) { $policyType = trim((string) ($subject['policy_type'] ?? '')); $externalId = trim((string) ($subject['subject_external_id'] ?? '')); + $subjectKey = trim((string) ($subject['subject_key'] ?? '')); + $descriptor = $this->resolver()->describeForCapture( + $policyType !== '' ? $policyType : 'unknown', + $externalId !== '' ? $externalId : null, + $subjectKey !== '' ? $subjectKey : null, + ); if ($policyType === '' || $externalId === '') { - $gaps['invalid_subject'] = ($gaps['invalid_subject'] ?? 0) + 1; - $gapSubjects['invalid_subject'][] = ($policyType !== '' ? $policyType : 'unknown').'|'.($externalId !== '' ? $externalId : 'unknown'); + $this->recordGap($gaps, $gapSubjects, $descriptor, $this->resolver()->invalidSubject($descriptor)); $stats['failed']++; continue; } - $subjectKey = $policyType.'|'.$externalId; + $captureKey = $policyType.'|'.$externalId; - if (isset($seen[$subjectKey])) { - $gaps['duplicate_subject'] = ($gaps['duplicate_subject'] ?? 0) + 1; - $gapSubjects['duplicate_subject'][] = $subjectKey; + if (isset($seen[$captureKey])) { + $this->recordGap($gaps, $gapSubjects, $descriptor, $this->resolver()->duplicateSubject($descriptor)); $stats['skipped']++; continue; } - $seen[$subjectKey] = true; + $seen[$captureKey] = true; + + if ( + $descriptor->resolutionPath === ResolutionPath::FoundationInventory + || $descriptor->resolutionPath === ResolutionPath::Inventory + ) { + $this->recordGap($gaps, $gapSubjects, $descriptor, $this->resolver()->structuralInventoryOnly($descriptor)); + $stats['skipped']++; + + continue; + } $policy = Policy::query() ->where('tenant_id', (int) $tenant->getKey()) @@ -118,8 +137,7 @@ public function capture( ->first(); if (! $policy instanceof Policy) { - $gaps['policy_not_found'] = ($gaps['policy_not_found'] ?? 0) + 1; - $gapSubjects['policy_not_found'][] = $subjectKey; + $this->recordGap($gaps, $gapSubjects, $descriptor, $this->resolver()->missingExpectedRecord($descriptor)); $stats['failed']++; continue; @@ -158,7 +176,7 @@ public function capture( $version = $result['version'] ?? null; if ($version instanceof PolicyVersion) { - $capturedVersions[$subjectKey] = [ + $capturedVersions[$captureKey] = [ 'policy_type' => $policyType, 'subject_external_id' => $externalId, 'version' => $version, @@ -184,12 +202,10 @@ public function capture( } if ($isThrottled) { - $gaps['throttled'] = ($gaps['throttled'] ?? 0) + 1; - $gapSubjects['throttled'][] = $subjectKey; + $this->recordGap($gaps, $gapSubjects, $descriptor, $this->resolver()->throttled($descriptor)); $stats['throttled']++; } else { - $gaps['capture_failed'] = ($gaps['capture_failed'] ?? 0) + 1; - $gapSubjects['capture_failed'][] = $subjectKey; + $this->recordGap($gaps, $gapSubjects, $descriptor, $this->resolver()->captureFailed($descriptor)); $stats['failed']++; } @@ -209,23 +225,26 @@ public function capture( $remainingCount = max(0, count($subjects) - $processed); if ($remainingCount > 0) { - $gaps['budget_exhausted'] = ($gaps['budget_exhausted'] ?? 0) + $remainingCount; - foreach (array_slice($subjects, $processed) as $remainingSubject) { $remainingPolicyType = trim((string) ($remainingSubject['policy_type'] ?? '')); $remainingExternalId = trim((string) ($remainingSubject['subject_external_id'] ?? '')); + $remainingSubjectKey = trim((string) ($remainingSubject['subject_key'] ?? '')); if ($remainingPolicyType === '' || $remainingExternalId === '') { continue; } - $gapSubjects['budget_exhausted'][] = $remainingPolicyType.'|'.$remainingExternalId; + $remainingDescriptor = $this->resolver()->describeForCapture( + $remainingPolicyType, + $remainingExternalId, + $remainingSubjectKey !== '' ? $remainingSubjectKey : null, + ); + $this->recordGap($gaps, $gapSubjects, $remainingDescriptor, $this->resolver()->budgetExhausted($remainingDescriptor)); } } } ksort($gaps); - ksort($gapSubjects); return [ 'stats' => $stats, @@ -236,6 +255,21 @@ public function capture( ]; } + /** + * @param array $gaps + * @param list> $gapSubjects + */ + private function recordGap(array &$gaps, array &$gapSubjects, SubjectDescriptor $descriptor, ResolutionOutcomeRecord $outcome): void + { + $gaps[$outcome->reasonCode] = ($gaps[$outcome->reasonCode] ?? 0) + 1; + $gapSubjects[] = array_merge($descriptor->toArray(), $outcome->toArray()); + } + + private function resolver(): SubjectResolver + { + return $this->subjectResolver ?? app(SubjectResolver::class); + } + private function retryDelayMs(int $attempt): int { $attempt = max(0, $attempt); diff --git a/app/Support/Baselines/BaselineCompareEvidenceGapDetails.php b/app/Support/Baselines/BaselineCompareEvidenceGapDetails.php index 329e3b98..c6cae5c5 100644 --- a/app/Support/Baselines/BaselineCompareEvidenceGapDetails.php +++ b/app/Support/Baselines/BaselineCompareEvidenceGapDetails.php @@ -9,33 +9,6 @@ final class BaselineCompareEvidenceGapDetails { - /** - * @return array{ - * summary: array{ - * count: int, - * by_reason: array, - * detail_state: string, - * recorded_subjects_total: int, - * missing_detail_count: int - * }, - * buckets: list - * }> - * } - */ public static function fromOperationRun(?OperationRun $run): array { if (! $run instanceof OperationRun || ! is_array($run->context)) { @@ -47,31 +20,6 @@ public static function fromOperationRun(?OperationRun $run): array /** * @param array $context - * @return array{ - * summary: array{ - * count: int, - * by_reason: array, - * detail_state: string, - * recorded_subjects_total: int, - * missing_detail_count: int - * }, - * buckets: list - * }> - * } */ public static function fromContext(array $context): array { @@ -86,31 +34,6 @@ public static function fromContext(array $context): array /** * @param array $baselineCompare - * @return array{ - * summary: array{ - * count: int, - * by_reason: array, - * detail_state: string, - * recorded_subjects_total: int, - * missing_detail_count: int - * }, - * buckets: list - * }> - * } */ public static function fromBaselineCompare(array $baselineCompare): array { @@ -118,31 +41,49 @@ public static function fromBaselineCompare(array $baselineCompare): array $evidenceGaps = is_array($evidenceGaps) ? $evidenceGaps : []; $byReason = self::normalizeCounts($evidenceGaps['by_reason'] ?? null); - $subjects = self::normalizeSubjects($evidenceGaps['subjects'] ?? null); + $normalizedSubjects = self::normalizeSubjects($evidenceGaps['subjects'] ?? null); - foreach ($subjects as $reason => $keys) { - if (! array_key_exists($reason, $byReason)) { - $byReason[$reason] = count($keys); + foreach ($normalizedSubjects['subjects'] as $reasonCode => $subjects) { + if (! array_key_exists($reasonCode, $byReason)) { + $byReason[$reasonCode] = count($subjects); } } - $count = self::normalizeTotalCount($evidenceGaps['count'] ?? null, $byReason, $subjects); - $detailState = self::detailState($count, $subjects); - + $count = self::normalizeTotalCount( + $evidenceGaps['count'] ?? null, + $byReason, + $normalizedSubjects['subjects'], + ); + $detailState = self::detailState($count, $normalizedSubjects); $buckets = []; - foreach (self::orderedReasons($byReason, $subjects) as $reason) { - $rows = self::rowsForReason($reason, $subjects[$reason] ?? []); - $reasonCount = $byReason[$reason] ?? count($rows); + 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($reason), - Str::lower(self::reasonLabel($reason)), + Str::lower($reasonCode), + Str::lower(self::reasonLabel($reasonCode)), ...array_map( static fn (array $row): string => (string) ($row['search_text'] ?? ''), $rows, @@ -150,12 +91,15 @@ public static function fromBaselineCompare(array $baselineCompare): array ]))); $buckets[] = [ - 'reason_code' => $reason, - 'reason_label' => self::reasonLabel($reason), + 'reason_code' => $reasonCode, + 'reason_label' => self::reasonLabel($reasonCode), 'count' => $reasonCount, 'recorded_count' => $recordedCount, 'missing_detail_count' => max(0, $reasonCount - $recordedCount), - 'detail_state' => $recordedCount > 0 ? 'details_recorded' : 'details_not_recorded', + 'structural_count' => $structuralCount, + 'operational_count' => $operationalCount, + 'transient_count' => $transientCount, + 'detail_state' => self::bucketDetailState($detailState, $recordedCount), 'search_text' => $searchText, 'rows' => $rows, ]; @@ -165,6 +109,19 @@ public static function fromBaselineCompare(array $baselineCompare): array 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' => [ @@ -173,6 +130,11 @@ public static function fromBaselineCompare(array $baselineCompare): array '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, ]; @@ -201,21 +163,68 @@ public static function reasonLabel(string $reason): string return match ($reason) { 'ambiguous_match' => 'Ambiguous inventory match', - 'policy_not_found' => 'Policy not found', - 'missing_current' => 'Missing current evidence', + '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 $byReason * @return list @@ -238,14 +247,7 @@ public static function topReasons(array $byReason, int $limit = 5): array /** * @param list> $buckets - * @return list + * @return list> */ public static function tableRows(array $buckets): array { @@ -264,26 +266,32 @@ public static function tableRows(array $buckets): array } $reasonCode = self::stringOrNull($row['reason_code'] ?? null); - $reasonLabel = self::stringOrNull($row['reason_label'] ?? 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 || $reasonLabel === null || $policyType === null || $subjectKey === null) { + if ($reasonCode === null || $policyType === null || $subjectKey === null || $subjectClass === null || $resolutionOutcome === null || $operatorActionCategory === null) { continue; } $rows[] = [ - '__id' => md5(implode('|', [$reasonCode, $policyType, $subjectKey])), + '__id' => md5(implode('|', [$reasonCode, $policyType, $subjectKey, $resolutionOutcome])), 'reason_code' => $reasonCode, - 'reason_label' => $reasonLabel, + 'reason_label' => self::reasonLabel($reasonCode), 'policy_type' => $policyType, 'subject_key' => $subjectKey, - 'search_text' => Str::lower(implode(' ', [ - $reasonCode, - $reasonLabel, - $policyType, - $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) ?? '', ]; } } @@ -321,32 +329,35 @@ public static function policyTypeFilterOptions(array $rows): array } /** - * @return array{ - * summary: array{ - * count: int, - * by_reason: array, - * detail_state: string, - * recorded_subjects_total: int, - * missing_detail_count: int - * }, - * buckets: list - * }> - * } + * @param list> $rows + * @return array */ + 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> $rows + * @return array + */ + 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 [ @@ -356,6 +367,11 @@ private static function empty(): array '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' => [], ]; @@ -392,41 +408,125 @@ private static function normalizeCounts(mixed $value): array } /** - * @return array> + * @return array{ + * subjects: array>>, + * legacy_mode: bool + * } */ private static function normalizeSubjects(mixed $value): array { + if ($value === null) { + return [ + 'subjects' => [], + 'legacy_mode' => false, + ]; + } + if (! is_array($value)) { - return []; + return [ + 'subjects' => [], + 'legacy_mode' => true, + ]; } - $normalized = []; - - foreach ($value as $reason => $keys) { - if (! is_string($reason) || trim($reason) === '' || ! is_array($keys)) { - continue; - } - - $items = array_values(array_unique(array_filter(array_map( - static fn (mixed $item): ?string => is_string($item) && trim($item) !== '' ? trim($item) : null, - $keys, - )))); - - if ($items === []) { - continue; - } - - $normalized[trim($reason)] = $items; + if (! array_is_list($value)) { + return [ + 'subjects' => [], + 'legacy_mode' => true, + ]; } - ksort($normalized); + $subjects = []; - return $normalized; + 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|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 $byReason - * @param array> $subjects + * @param array>> $subjects * @return list */ private static function orderedReasons(array $byReason, array $subjects): array @@ -444,7 +544,7 @@ private static function orderedReasons(array $byReason, array $subjects): array /** * @param array $byReason - * @param array> $subjects + * @param array>> $subjects */ private static function normalizeTotalCount(mixed $count, array $byReason, array $subjects): int { @@ -463,13 +563,13 @@ private static function normalizeTotalCount(mixed $count, array $byReason, array } return array_sum(array_map( - static fn (array $keys): int => count($keys), + static fn (array $rows): int => count($rows), $subjects, )); } /** - * @param array> $subjects + * @param array{subjects: array>>, legacy_mode: bool} $subjects */ private static function detailState(int $count, array $subjects): string { @@ -477,66 +577,57 @@ private static function detailState(int $count, array $subjects): string return 'no_gaps'; } - return $subjects !== [] ? 'details_recorded' : 'details_not_recorded'; + 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 list $subjects - * @return list + * @param array $subject + * @return array */ - private static function rowsForReason(string $reason, array $subjects): array + private static function projectSubjectRow(array $subject): array { - $rows = []; + $reasonCode = (string) $subject['reason_code']; + $subjectClass = (string) $subject['subject_class']; + $resolutionOutcome = (string) $subject['resolution_outcome']; + $operatorActionCategory = (string) $subject['operator_action_category']; - foreach ($subjects as $subject) { - [$policyType, $subjectKey] = self::splitSubject($subject); - - if ($policyType === null || $subjectKey === null) { - continue; - } - - $rows[] = [ - 'reason_code' => $reason, - 'reason_label' => self::reasonLabel($reason), - 'policy_type' => $policyType, - 'subject_key' => $subjectKey, - 'search_text' => Str::lower(implode(' ', [ - $reason, - self::reasonLabel($reason), - $policyType, - $subjectKey, - ])), - ]; - } - - return $rows; - } - - /** - * @return array{0: ?string, 1: ?string} - */ - private static function splitSubject(string $subject): array - { - $parts = explode('|', $subject, 2); - - if (count($parts) !== 2) { - return [null, null]; - } - - $policyType = trim($parts[0]); - $subjectKey = trim($parts[1]); - - if ($policyType === '' || $subjectKey === '') { - return [null, null]; - } - - return [$policyType, $subjectKey]; + 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 @@ -545,13 +636,26 @@ private static function stringOrNull(mixed $value): ?string return null; } - $value = trim($value); + $trimmed = trim($value); - return $value !== '' ? $value : null; + 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; + } } diff --git a/app/Support/Baselines/BaselineCompareExplanationRegistry.php b/app/Support/Baselines/BaselineCompareExplanationRegistry.php index 3811b1a3..5bdf32d3 100644 --- a/app/Support/Baselines/BaselineCompareExplanationRegistry.php +++ b/app/Support/Baselines/BaselineCompareExplanationRegistry.php @@ -181,6 +181,36 @@ private function countDescriptors( ); } + if ($stats->evidenceGapStructuralCount !== null && $stats->evidenceGapStructuralCount > 0) { + $descriptors[] = new CountDescriptor( + label: 'Structural gaps', + value: (int) $stats->evidenceGapStructuralCount, + role: CountDescriptor::ROLE_RELIABILITY_SIGNAL, + qualifier: 'product or support limit', + visibilityTier: CountDescriptor::VISIBILITY_DIAGNOSTIC, + ); + } + + if ($stats->evidenceGapOperationalCount !== null && $stats->evidenceGapOperationalCount > 0) { + $descriptors[] = new CountDescriptor( + label: 'Operational gaps', + value: (int) $stats->evidenceGapOperationalCount, + role: CountDescriptor::ROLE_RELIABILITY_SIGNAL, + qualifier: 'local evidence missing', + visibilityTier: CountDescriptor::VISIBILITY_DIAGNOSTIC, + ); + } + + if ($stats->evidenceGapTransientCount !== null && $stats->evidenceGapTransientCount > 0) { + $descriptors[] = new CountDescriptor( + label: 'Transient gaps', + value: (int) $stats->evidenceGapTransientCount, + role: CountDescriptor::ROLE_RELIABILITY_SIGNAL, + qualifier: 'retry may help', + visibilityTier: CountDescriptor::VISIBILITY_DIAGNOSTIC, + ); + } + if ($stats->severityCounts !== []) { foreach (['high' => 'High severity', 'medium' => 'Medium severity', 'low' => 'Low severity'] as $key => $label) { $value = (int) ($stats->severityCounts[$key] ?? 0); diff --git a/app/Support/Baselines/BaselineCompareStats.php b/app/Support/Baselines/BaselineCompareStats.php index 9300e10a..c4bc89ac 100644 --- a/app/Support/Baselines/BaselineCompareStats.php +++ b/app/Support/Baselines/BaselineCompareStats.php @@ -58,6 +58,7 @@ private function __construct( public readonly ?int $profileId, public readonly ?int $snapshotId, public readonly ?int $duplicateNamePoliciesCount, + public readonly ?int $duplicateNameSubjectsCount, public readonly ?int $operationRunId, public readonly ?int $findingsCount, public readonly array $severityCounts, @@ -75,6 +76,10 @@ private function __construct( public readonly ?array $rbacRoleDefinitionSummary = null, public readonly array $evidenceGapDetails = [], public readonly array $baselineCompareDiagnostics = [], + public readonly ?int $evidenceGapStructuralCount = null, + public readonly ?int $evidenceGapOperationalCount = null, + public readonly ?int $evidenceGapTransientCount = null, + public readonly ?bool $evidenceGapLegacyMode = null, ) {} public static function forTenant(?Tenant $tenant): self @@ -119,7 +124,9 @@ public static function forTenant(?Tenant $tenant): self : null; $effectiveScope = BaselineScope::effective($profileScope, $overrideScope); - $duplicateNamePoliciesCount = self::duplicateNamePoliciesCount($tenant, $effectiveScope); + $duplicateNameStats = self::duplicateNameStats($tenant, $effectiveScope); + $duplicateNamePoliciesCount = $duplicateNameStats['policy_count']; + $duplicateNameSubjectsCount = $duplicateNameStats['subject_count']; if ($snapshotId === null) { return new self( @@ -129,6 +136,7 @@ public static function forTenant(?Tenant $tenant): self profileId: $profileId, snapshotId: null, duplicateNamePoliciesCount: $duplicateNamePoliciesCount, + duplicateNameSubjectsCount: $duplicateNameSubjectsCount, operationRunId: null, findingsCount: null, severityCounts: [], @@ -152,6 +160,19 @@ public static function forTenant(?Tenant $tenant): self $rbacRoleDefinitionSummary = self::rbacRoleDefinitionSummaryForRun($latestRun); $evidenceGapDetails = self::evidenceGapDetailsForRun($latestRun); $baselineCompareDiagnostics = self::baselineCompareDiagnosticsForRun($latestRun); + $evidenceGapSummary = is_array($evidenceGapDetails['summary'] ?? null) ? $evidenceGapDetails['summary'] : []; + $evidenceGapStructuralCount = is_numeric($evidenceGapSummary['structural_count'] ?? null) + ? (int) $evidenceGapSummary['structural_count'] + : null; + $evidenceGapOperationalCount = is_numeric($evidenceGapSummary['operational_count'] ?? null) + ? (int) $evidenceGapSummary['operational_count'] + : null; + $evidenceGapTransientCount = is_numeric($evidenceGapSummary['transient_count'] ?? null) + ? (int) $evidenceGapSummary['transient_count'] + : null; + $evidenceGapLegacyMode = is_bool($evidenceGapSummary['legacy_mode'] ?? null) + ? (bool) $evidenceGapSummary['legacy_mode'] + : null; // Active run (queued/running) if ($latestRun instanceof OperationRun && in_array($latestRun->status, ['queued', 'running'], true)) { @@ -162,6 +183,7 @@ public static function forTenant(?Tenant $tenant): self profileId: $profileId, snapshotId: $snapshotId, duplicateNamePoliciesCount: $duplicateNamePoliciesCount, + duplicateNameSubjectsCount: $duplicateNameSubjectsCount, operationRunId: (int) $latestRun->getKey(), findingsCount: null, severityCounts: [], @@ -179,6 +201,10 @@ public static function forTenant(?Tenant $tenant): self rbacRoleDefinitionSummary: $rbacRoleDefinitionSummary, evidenceGapDetails: $evidenceGapDetails, baselineCompareDiagnostics: $baselineCompareDiagnostics, + evidenceGapStructuralCount: $evidenceGapStructuralCount, + evidenceGapOperationalCount: $evidenceGapOperationalCount, + evidenceGapTransientCount: $evidenceGapTransientCount, + evidenceGapLegacyMode: $evidenceGapLegacyMode, ); } @@ -196,6 +222,7 @@ public static function forTenant(?Tenant $tenant): self profileId: $profileId, snapshotId: $snapshotId, duplicateNamePoliciesCount: $duplicateNamePoliciesCount, + duplicateNameSubjectsCount: $duplicateNameSubjectsCount, operationRunId: (int) $latestRun->getKey(), findingsCount: null, severityCounts: [], @@ -213,6 +240,10 @@ public static function forTenant(?Tenant $tenant): self rbacRoleDefinitionSummary: $rbacRoleDefinitionSummary, evidenceGapDetails: $evidenceGapDetails, baselineCompareDiagnostics: $baselineCompareDiagnostics, + evidenceGapStructuralCount: $evidenceGapStructuralCount, + evidenceGapOperationalCount: $evidenceGapOperationalCount, + evidenceGapTransientCount: $evidenceGapTransientCount, + evidenceGapLegacyMode: $evidenceGapLegacyMode, ); } @@ -252,6 +283,7 @@ public static function forTenant(?Tenant $tenant): self profileId: $profileId, snapshotId: $snapshotId, duplicateNamePoliciesCount: $duplicateNamePoliciesCount, + duplicateNameSubjectsCount: $duplicateNameSubjectsCount, operationRunId: $latestRun instanceof OperationRun ? (int) $latestRun->getKey() : null, findingsCount: $totalFindings, severityCounts: $severityCounts, @@ -269,6 +301,10 @@ public static function forTenant(?Tenant $tenant): self rbacRoleDefinitionSummary: $rbacRoleDefinitionSummary, evidenceGapDetails: $evidenceGapDetails, baselineCompareDiagnostics: $baselineCompareDiagnostics, + evidenceGapStructuralCount: $evidenceGapStructuralCount, + evidenceGapOperationalCount: $evidenceGapOperationalCount, + evidenceGapTransientCount: $evidenceGapTransientCount, + evidenceGapLegacyMode: $evidenceGapLegacyMode, ); } @@ -282,6 +318,7 @@ public static function forTenant(?Tenant $tenant): self profileId: $profileId, snapshotId: $snapshotId, duplicateNamePoliciesCount: $duplicateNamePoliciesCount, + duplicateNameSubjectsCount: $duplicateNameSubjectsCount, operationRunId: (int) $latestRun->getKey(), findingsCount: 0, severityCounts: $severityCounts, @@ -299,6 +336,10 @@ public static function forTenant(?Tenant $tenant): self rbacRoleDefinitionSummary: $rbacRoleDefinitionSummary, evidenceGapDetails: $evidenceGapDetails, baselineCompareDiagnostics: $baselineCompareDiagnostics, + evidenceGapStructuralCount: $evidenceGapStructuralCount, + evidenceGapOperationalCount: $evidenceGapOperationalCount, + evidenceGapTransientCount: $evidenceGapTransientCount, + evidenceGapLegacyMode: $evidenceGapLegacyMode, ); } @@ -309,6 +350,7 @@ public static function forTenant(?Tenant $tenant): self profileId: $profileId, snapshotId: $snapshotId, duplicateNamePoliciesCount: $duplicateNamePoliciesCount, + duplicateNameSubjectsCount: $duplicateNameSubjectsCount, operationRunId: null, findingsCount: null, severityCounts: $severityCounts, @@ -326,6 +368,10 @@ public static function forTenant(?Tenant $tenant): self rbacRoleDefinitionSummary: $rbacRoleDefinitionSummary, evidenceGapDetails: $evidenceGapDetails, baselineCompareDiagnostics: $baselineCompareDiagnostics, + evidenceGapStructuralCount: $evidenceGapStructuralCount, + evidenceGapOperationalCount: $evidenceGapOperationalCount, + evidenceGapTransientCount: $evidenceGapTransientCount, + evidenceGapLegacyMode: $evidenceGapLegacyMode, ); } @@ -382,6 +428,7 @@ public static function forWidget(?Tenant $tenant): self profileId: (int) $profile->getKey(), snapshotId: $snapshotId, duplicateNamePoliciesCount: null, + duplicateNameSubjectsCount: null, operationRunId: $latestRun instanceof OperationRun ? (int) $latestRun->getKey() : null, findingsCount: $totalFindings, severityCounts: [ @@ -397,17 +444,23 @@ public static function forWidget(?Tenant $tenant): self ); } - private static function duplicateNamePoliciesCount(Tenant $tenant, BaselineScope $effectiveScope): int + /** + * @return array{policy_count: int, subject_count: int} + */ + private static function duplicateNameStats(Tenant $tenant, BaselineScope $effectiveScope): array { $policyTypes = $effectiveScope->allTypes(); if ($policyTypes === []) { - return 0; + return [ + 'policy_count' => 0, + 'subject_count' => 0, + ]; } $latestInventorySyncRunId = self::latestInventorySyncRunId($tenant); - $compute = static function () use ($tenant, $policyTypes, $latestInventorySyncRunId): int { + $compute = static function () use ($tenant, $policyTypes, $latestInventorySyncRunId): array { /** * @var array $countsByKey */ @@ -440,14 +493,19 @@ private static function duplicateNamePoliciesCount(Tenant $tenant, BaselineScope }); $duplicatePolicies = 0; + $duplicateSubjects = 0; foreach ($countsByKey as $count) { if ($count > 1) { + $duplicateSubjects++; $duplicatePolicies += $count; } } - return $duplicatePolicies; + return [ + 'policy_count' => $duplicatePolicies, + 'subject_count' => $duplicateSubjects, + ]; }; if (app()->environment('testing')) { @@ -461,7 +519,10 @@ private static function duplicateNamePoliciesCount(Tenant $tenant, BaselineScope $latestInventorySyncRunId ?? 'all', ); - return (int) Cache::remember($cacheKey, now()->addSeconds(60), $compute); + /** @var array{policy_count: int, subject_count: int} $stats */ + $stats = Cache::remember($cacheKey, now()->addSeconds(60), $compute); + + return $stats; } private static function latestInventorySyncRunId(Tenant $tenant): ?int @@ -675,6 +736,7 @@ private static function empty( ?string $profileName = null, ?int $profileId = null, ?int $duplicateNamePoliciesCount = null, + ?int $duplicateNameSubjectsCount = null, ): self { return new self( state: $state, @@ -683,6 +745,7 @@ private static function empty( profileId: $profileId, snapshotId: null, duplicateNamePoliciesCount: $duplicateNamePoliciesCount, + duplicateNameSubjectsCount: $duplicateNameSubjectsCount, operationRunId: null, findingsCount: null, severityCounts: [], diff --git a/app/Support/Baselines/BaselineScope.php b/app/Support/Baselines/BaselineScope.php index d348efe1..e4a6d6d3 100644 --- a/app/Support/Baselines/BaselineScope.php +++ b/app/Support/Baselines/BaselineScope.php @@ -118,6 +118,17 @@ public function allTypes(): array )); } + /** + * @return list + */ + public function truthfulTypes(string $operation, ?BaselineSupportCapabilityGuard $guard = null): array + { + $guard ??= app(BaselineSupportCapabilityGuard::class); + $guardResult = $guard->guardTypes($this->allTypes(), $operation); + + return $guardResult['allowed_types']; + } + /** * @return array */ @@ -134,17 +145,32 @@ public function toJsonb(): array * * @return array{policy_types: list, foundation_types: list, all_types: list, foundations_included: bool} */ - public function toEffectiveScopeContext(): array + public function toEffectiveScopeContext(?BaselineSupportCapabilityGuard $guard = null, ?string $operation = null): array { $expanded = $this->expandDefaults(); $allTypes = self::uniqueSorted(array_merge($expanded->policyTypes, $expanded->foundationTypes)); - return [ + $context = [ 'policy_types' => $expanded->policyTypes, 'foundation_types' => $expanded->foundationTypes, 'all_types' => $allTypes, 'foundations_included' => $expanded->foundationTypes !== [], ]; + + if (! is_string($operation) || $operation === '') { + return $context; + } + + $guard ??= app(BaselineSupportCapabilityGuard::class); + $guardResult = $guard->guardTypes($allTypes, $operation); + + return array_merge($context, [ + 'truthful_types' => $guardResult['allowed_types'], + 'limited_types' => $guardResult['limited_types'], + 'unsupported_types' => $guardResult['unsupported_types'], + 'invalid_support_types' => $guardResult['invalid_support_types'], + 'capabilities' => $guardResult['capabilities'], + ]); } /** diff --git a/app/Support/Baselines/BaselineSupportCapabilityGuard.php b/app/Support/Baselines/BaselineSupportCapabilityGuard.php new file mode 100644 index 00000000..04a00789 --- /dev/null +++ b/app/Support/Baselines/BaselineSupportCapabilityGuard.php @@ -0,0 +1,79 @@ +resolver->capability($policyType); + } + + /** + * @param list $policyTypes + * @return array{ + * allowed_types: list, + * limited_types: list, + * unsupported_types: list, + * invalid_support_types: list, + * capabilities: array> + * } + */ + public function guardTypes(array $policyTypes, string $operation): array + { + $allowedTypes = []; + $limitedTypes = []; + $unsupportedTypes = []; + $invalidSupportTypes = []; + $capabilities = []; + + foreach (array_values(array_unique(array_filter($policyTypes, 'is_string'))) as $policyType) { + $record = $this->inspectType($policyType); + $mode = $record->supportModeFor($operation); + + $capabilities[$policyType] = array_merge( + $record->toArray(), + ['support_mode' => $mode], + ); + + if ($mode === 'invalid_support_config') { + $invalidSupportTypes[] = $policyType; + $unsupportedTypes[] = $policyType; + + continue; + } + + if ($record->allows($operation)) { + $allowedTypes[] = $policyType; + + if ($mode === 'limited') { + $limitedTypes[] = $policyType; + } + + continue; + } + + $unsupportedTypes[] = $policyType; + } + + sort($allowedTypes, SORT_STRING); + sort($limitedTypes, SORT_STRING); + sort($unsupportedTypes, SORT_STRING); + sort($invalidSupportTypes, SORT_STRING); + ksort($capabilities); + + return [ + 'allowed_types' => $allowedTypes, + 'limited_types' => $limitedTypes, + 'unsupported_types' => $unsupportedTypes, + 'invalid_support_types' => $invalidSupportTypes, + 'capabilities' => $capabilities, + ]; + } +} diff --git a/app/Support/Baselines/OperatorActionCategory.php b/app/Support/Baselines/OperatorActionCategory.php new file mode 100644 index 00000000..1aceff93 --- /dev/null +++ b/app/Support/Baselines/OperatorActionCategory.php @@ -0,0 +1,16 @@ + $this->resolutionOutcome->value, + 'reason_code' => $this->reasonCode, + 'operator_action_category' => $this->operatorActionCategory->value, + 'structural' => $this->structural, + 'retryable' => $this->retryable, + 'source_model_expected' => $this->sourceModelExpected, + 'source_model_found' => $this->sourceModelFound, + ]; + } +} diff --git a/app/Support/Baselines/ResolutionPath.php b/app/Support/Baselines/ResolutionPath.php new file mode 100644 index 00000000..c746dbf3 --- /dev/null +++ b/app/Support/Baselines/ResolutionPath.php @@ -0,0 +1,14 @@ +sourceModelExpected === 'policy'; + } + + public function expectsInventory(): bool + { + return $this->sourceModelExpected === 'inventory'; + } + + public function toArray(): array + { + return [ + 'policy_type' => $this->policyType, + 'subject_external_id' => $this->subjectExternalId, + 'subject_key' => $this->subjectKey, + 'subject_class' => $this->subjectClass->value, + 'resolution_path' => $this->resolutionPath->value, + 'support_mode' => $this->supportMode, + 'source_model_expected' => $this->sourceModelExpected, + ]; + } +} diff --git a/app/Support/Baselines/SubjectResolver.php b/app/Support/Baselines/SubjectResolver.php new file mode 100644 index 00000000..a314231f --- /dev/null +++ b/app/Support/Baselines/SubjectResolver.php @@ -0,0 +1,201 @@ +describe(operation: 'compare', policyType: $policyType, subjectExternalId: $subjectExternalId, subjectKey: $subjectKey); + } + + public function describeForCapture(string $policyType, ?string $subjectExternalId = null, ?string $subjectKey = null): SubjectDescriptor + { + return $this->describe(operation: 'capture', policyType: $policyType, subjectExternalId: $subjectExternalId, subjectKey: $subjectKey); + } + + public function resolved(SubjectDescriptor $descriptor, ?string $sourceModelFound = null): ResolutionOutcomeRecord + { + $outcome = $descriptor->expectsPolicy() + ? ResolutionOutcome::ResolvedPolicy + : ResolutionOutcome::ResolvedInventory; + + return new ResolutionOutcomeRecord( + resolutionOutcome: $outcome, + reasonCode: $outcome->value, + operatorActionCategory: OperatorActionCategory::None, + structural: false, + retryable: false, + sourceModelExpected: $descriptor->sourceModelExpected, + sourceModelFound: $sourceModelFound ?? $descriptor->sourceModelExpected, + ); + } + + public function missingExpectedRecord(SubjectDescriptor $descriptor): ResolutionOutcomeRecord + { + $expectsPolicy = $descriptor->expectsPolicy(); + + return new ResolutionOutcomeRecord( + resolutionOutcome: $expectsPolicy ? ResolutionOutcome::PolicyRecordMissing : ResolutionOutcome::InventoryRecordMissing, + reasonCode: $expectsPolicy ? 'policy_record_missing' : 'inventory_record_missing', + operatorActionCategory: $expectsPolicy ? OperatorActionCategory::RunPolicySyncOrBackup : OperatorActionCategory::RunInventorySync, + structural: false, + retryable: false, + sourceModelExpected: $descriptor->sourceModelExpected, + ); + } + + public function structuralInventoryOnly(SubjectDescriptor $descriptor): ResolutionOutcomeRecord + { + return new ResolutionOutcomeRecord( + resolutionOutcome: ResolutionOutcome::FoundationInventoryOnly, + reasonCode: 'foundation_not_policy_backed', + operatorActionCategory: OperatorActionCategory::ProductFollowUp, + structural: true, + retryable: false, + sourceModelExpected: $descriptor->sourceModelExpected, + sourceModelFound: 'inventory', + ); + } + + public function invalidSubject(SubjectDescriptor $descriptor): ResolutionOutcomeRecord + { + return new ResolutionOutcomeRecord( + resolutionOutcome: ResolutionOutcome::InvalidSubject, + reasonCode: 'invalid_subject', + operatorActionCategory: OperatorActionCategory::InspectSubjectMapping, + structural: false, + retryable: false, + sourceModelExpected: $descriptor->sourceModelExpected, + ); + } + + public function duplicateSubject(SubjectDescriptor $descriptor): ResolutionOutcomeRecord + { + return new ResolutionOutcomeRecord( + resolutionOutcome: ResolutionOutcome::DuplicateSubject, + reasonCode: 'duplicate_subject', + operatorActionCategory: OperatorActionCategory::InspectSubjectMapping, + structural: false, + retryable: false, + sourceModelExpected: $descriptor->sourceModelExpected, + ); + } + + public function ambiguousMatch(SubjectDescriptor $descriptor): ResolutionOutcomeRecord + { + return new ResolutionOutcomeRecord( + resolutionOutcome: ResolutionOutcome::AmbiguousMatch, + reasonCode: 'ambiguous_match', + operatorActionCategory: OperatorActionCategory::InspectSubjectMapping, + structural: false, + retryable: false, + sourceModelExpected: $descriptor->sourceModelExpected, + ); + } + + public function invalidSupportConfiguration(SupportCapabilityRecord $capability): ResolutionOutcomeRecord + { + return new ResolutionOutcomeRecord( + resolutionOutcome: ResolutionOutcome::InvalidSupportConfig, + reasonCode: 'invalid_support_config', + operatorActionCategory: OperatorActionCategory::ProductFollowUp, + structural: true, + retryable: false, + sourceModelExpected: $capability->sourceModelExpected, + ); + } + + public function throttled(SubjectDescriptor $descriptor): ResolutionOutcomeRecord + { + return new ResolutionOutcomeRecord( + resolutionOutcome: ResolutionOutcome::Throttled, + reasonCode: 'throttled', + operatorActionCategory: OperatorActionCategory::Retry, + structural: false, + retryable: true, + sourceModelExpected: $descriptor->sourceModelExpected, + ); + } + + public function captureFailed(SubjectDescriptor $descriptor, bool $retryable = false): ResolutionOutcomeRecord + { + return new ResolutionOutcomeRecord( + resolutionOutcome: $retryable ? ResolutionOutcome::RetryableCaptureFailure : ResolutionOutcome::CaptureFailed, + reasonCode: $retryable ? 'retryable_capture_failure' : 'capture_failed', + operatorActionCategory: OperatorActionCategory::Retry, + structural: false, + retryable: $retryable, + sourceModelExpected: $descriptor->sourceModelExpected, + ); + } + + public function budgetExhausted(SubjectDescriptor $descriptor): ResolutionOutcomeRecord + { + return new ResolutionOutcomeRecord( + resolutionOutcome: ResolutionOutcome::BudgetExhausted, + reasonCode: 'budget_exhausted', + operatorActionCategory: OperatorActionCategory::Retry, + structural: false, + retryable: true, + sourceModelExpected: $descriptor->sourceModelExpected, + ); + } + + private function describe(string $operation, string $policyType, ?string $subjectExternalId = null, ?string $subjectKey = null): SubjectDescriptor + { + $capability = $this->capability($policyType); + $resolvedSubjectKey = $this->normalizeSubjectKey($policyType, $subjectExternalId, $subjectKey); + + return new SubjectDescriptor( + policyType: $policyType, + subjectExternalId: $subjectExternalId !== null && trim($subjectExternalId) !== '' ? trim($subjectExternalId) : null, + subjectKey: $resolvedSubjectKey, + subjectClass: $capability->subjectClass, + resolutionPath: $capability->resolutionPath, + supportMode: $capability->supportModeFor($operation), + sourceModelExpected: $capability->sourceModelExpected, + ); + } + + private function normalizeSubjectKey(string $policyType, ?string $subjectExternalId, ?string $subjectKey): string + { + $trimmedSubjectKey = is_string($subjectKey) ? trim($subjectKey) : ''; + + if ($trimmedSubjectKey !== '') { + return $trimmedSubjectKey; + } + + $generated = BaselineSubjectKey::forPolicy($policyType, subjectExternalId: $subjectExternalId); + + if (is_string($generated) && $generated !== '') { + return $generated; + } + + $fallbackExternalId = is_string($subjectExternalId) && trim($subjectExternalId) !== '' + ? trim($subjectExternalId) + : 'unknown'; + + return trim($policyType).'|'.$fallbackExternalId; + } +} diff --git a/app/Support/Baselines/SupportCapabilityRecord.php b/app/Support/Baselines/SupportCapabilityRecord.php new file mode 100644 index 00000000..c622f208 --- /dev/null +++ b/app/Support/Baselines/SupportCapabilityRecord.php @@ -0,0 +1,67 @@ + $this->compareCapability, + 'capture' => $this->captureCapability, + default => throw new InvalidArgumentException('Unsupported operation ['.$operation.'].'), + }; + + if ($this->configSupported && ! $this->runtimeValid) { + return 'invalid_support_config'; + } + + return match ($capability) { + 'supported', 'limited' => $capability, + default => 'excluded', + }; + } + + public function allows(string $operation): bool + { + return in_array($this->supportModeFor($operation), ['supported', 'limited'], true); + } + + public function toArray(): array + { + return [ + 'policy_type' => $this->policyType, + 'subject_class' => $this->subjectClass->value, + 'compare_capability' => $this->compareCapability, + 'capture_capability' => $this->captureCapability, + 'resolution_path' => $this->resolutionPath->value, + 'config_supported' => $this->configSupported, + 'runtime_valid' => $this->runtimeValid, + 'source_model_expected' => $this->sourceModelExpected, + ]; + } +} diff --git a/app/Support/Inventory/InventoryPolicyTypeMeta.php b/app/Support/Inventory/InventoryPolicyTypeMeta.php index f6aad429..23edfc84 100644 --- a/app/Support/Inventory/InventoryPolicyTypeMeta.php +++ b/app/Support/Inventory/InventoryPolicyTypeMeta.php @@ -4,6 +4,9 @@ namespace App\Support\Inventory; +use App\Support\Baselines\ResolutionPath; +use App\Support\Baselines\SubjectClass; + class InventoryPolicyTypeMeta { /** @@ -175,4 +178,141 @@ public static function baselineCompareLabel(?string $type): ?string return static::label($type); } + + /** + * @return array{ + * config_supported: bool, + * runtime_valid: bool, + * subject_class: string, + * resolution_path: string, + * compare_capability: string, + * capture_capability: string, + * source_model_expected: 'policy'|'inventory'|'derived'|null + * } + */ + public static function baselineSupportContract(?string $type): array + { + $contract = static::defaultBaselineSupportContract($type); + $resolution = static::baselineCompareMeta($type)['resolution'] ?? null; + + if (is_array($resolution)) { + $contract = array_replace($contract, array_filter([ + 'subject_class' => is_string($resolution['subject_class'] ?? null) ? $resolution['subject_class'] : null, + 'resolution_path' => is_string($resolution['resolution_path'] ?? null) ? $resolution['resolution_path'] : null, + 'compare_capability' => is_string($resolution['compare_capability'] ?? null) ? $resolution['compare_capability'] : null, + 'capture_capability' => is_string($resolution['capture_capability'] ?? null) ? $resolution['capture_capability'] : null, + 'source_model_expected' => is_string($resolution['source_model_expected'] ?? null) ? $resolution['source_model_expected'] : null, + ], static fn (mixed $value): bool => $value !== null)); + } + + $subjectClass = SubjectClass::tryFrom((string) ($contract['subject_class'] ?? '')); + $resolutionPath = ResolutionPath::tryFrom((string) ($contract['resolution_path'] ?? '')); + $compareCapability = in_array($contract['compare_capability'] ?? null, ['supported', 'limited', 'unsupported'], true) + ? (string) $contract['compare_capability'] + : 'unsupported'; + $captureCapability = in_array($contract['capture_capability'] ?? null, ['supported', 'limited', 'unsupported'], true) + ? (string) $contract['capture_capability'] + : 'unsupported'; + $sourceModelExpected = in_array($contract['source_model_expected'] ?? null, ['policy', 'inventory', 'derived'], true) + ? (string) $contract['source_model_expected'] + : null; + + $runtimeValid = $subjectClass instanceof SubjectClass + && $resolutionPath instanceof ResolutionPath + && static::pathMatchesSubjectClass($subjectClass, $resolutionPath) + && static::pathMatchesExpectedSource($resolutionPath, $sourceModelExpected); + + if (! $runtimeValid) { + $compareCapability = 'unsupported'; + $captureCapability = 'unsupported'; + } + + return [ + 'config_supported' => (bool) ($contract['config_supported'] ?? false), + 'runtime_valid' => $runtimeValid, + 'subject_class' => ($subjectClass ?? SubjectClass::Derived)->value, + 'resolution_path' => ($resolutionPath ?? ResolutionPath::Derived)->value, + 'compare_capability' => $compareCapability, + 'capture_capability' => $captureCapability, + 'source_model_expected' => $sourceModelExpected, + ]; + } + + /** + * @return array{ + * config_supported: bool, + * subject_class: string, + * resolution_path: string, + * compare_capability: string, + * capture_capability: string, + * source_model_expected: 'policy'|'inventory'|'derived'|null + * } + */ + private static function defaultBaselineSupportContract(?string $type): array + { + if (filled($type) && ! static::isFoundation($type) && static::metaFor($type) !== []) { + return [ + 'config_supported' => true, + 'subject_class' => SubjectClass::PolicyBacked->value, + 'resolution_path' => ResolutionPath::Policy->value, + 'compare_capability' => 'supported', + 'capture_capability' => 'supported', + 'source_model_expected' => 'policy', + ]; + } + + if (static::isFoundation($type)) { + $supported = (bool) (static::baselineCompareMeta($type)['supported'] ?? false); + $identityStrategy = static::baselineCompareIdentityStrategy($type); + $usesPolicyPath = $identityStrategy === 'external_id'; + + return [ + 'config_supported' => $supported, + 'subject_class' => SubjectClass::FoundationBacked->value, + 'resolution_path' => $usesPolicyPath + ? ResolutionPath::FoundationPolicy->value + : ResolutionPath::FoundationInventory->value, + 'compare_capability' => ! $supported + ? 'unsupported' + : ($usesPolicyPath ? 'supported' : 'limited'), + 'capture_capability' => ! $supported + ? 'unsupported' + : ($usesPolicyPath ? 'supported' : 'limited'), + 'source_model_expected' => $usesPolicyPath ? 'policy' : 'inventory', + ]; + } + + return [ + 'config_supported' => false, + 'subject_class' => SubjectClass::Derived->value, + 'resolution_path' => ResolutionPath::Derived->value, + 'compare_capability' => 'unsupported', + 'capture_capability' => 'unsupported', + 'source_model_expected' => 'derived', + ]; + } + + private static function pathMatchesSubjectClass(SubjectClass $subjectClass, ResolutionPath $resolutionPath): bool + { + return match ($subjectClass) { + SubjectClass::PolicyBacked => $resolutionPath === ResolutionPath::Policy, + SubjectClass::InventoryBacked => $resolutionPath === ResolutionPath::Inventory, + SubjectClass::FoundationBacked => in_array($resolutionPath, [ + ResolutionPath::FoundationInventory, + ResolutionPath::FoundationPolicy, + ], true), + SubjectClass::Derived => $resolutionPath === ResolutionPath::Derived, + }; + } + + private static function pathMatchesExpectedSource(ResolutionPath $resolutionPath, ?string $sourceModelExpected): bool + { + return match ($resolutionPath) { + ResolutionPath::Policy, + ResolutionPath::FoundationPolicy => $sourceModelExpected === 'policy', + ResolutionPath::Inventory, + ResolutionPath::FoundationInventory => $sourceModelExpected === 'inventory', + ResolutionPath::Derived => $sourceModelExpected === 'derived', + }; + } } diff --git a/config/tenantpilot.php b/config/tenantpilot.php index 6e0d342f..abb545fb 100644 --- a/config/tenantpilot.php +++ b/config/tenantpilot.php @@ -412,6 +412,13 @@ 'baseline_compare' => [ 'supported' => true, 'identity_strategy' => 'display_name', + 'resolution' => [ + 'subject_class' => 'foundation_backed', + 'resolution_path' => 'foundation_inventory', + 'compare_capability' => 'limited', + 'capture_capability' => 'limited', + 'source_model_expected' => 'inventory', + ], ], ], [ @@ -426,6 +433,13 @@ 'baseline_compare' => [ 'supported' => true, 'identity_strategy' => 'display_name', + 'resolution' => [ + 'subject_class' => 'foundation_backed', + 'resolution_path' => 'foundation_inventory', + 'compare_capability' => 'limited', + 'capture_capability' => 'limited', + 'source_model_expected' => 'inventory', + ], ], ], [ @@ -440,6 +454,13 @@ 'baseline_compare' => [ 'supported' => true, 'identity_strategy' => 'external_id', + 'resolution' => [ + 'subject_class' => 'foundation_backed', + 'resolution_path' => 'foundation_policy', + 'compare_capability' => 'supported', + 'capture_capability' => 'supported', + 'source_model_expected' => 'policy', + ], ], ], [ @@ -454,6 +475,13 @@ 'baseline_compare' => [ 'supported' => false, 'identity_strategy' => 'external_id', + 'resolution' => [ + 'subject_class' => 'foundation_backed', + 'resolution_path' => 'foundation_policy', + 'compare_capability' => 'unsupported', + 'capture_capability' => 'unsupported', + 'source_model_expected' => 'policy', + ], ], ], [ @@ -468,6 +496,13 @@ 'baseline_compare' => [ 'supported' => true, 'identity_strategy' => 'display_name', + 'resolution' => [ + 'subject_class' => 'foundation_backed', + 'resolution_path' => 'foundation_inventory', + 'compare_capability' => 'limited', + 'capture_capability' => 'limited', + 'source_model_expected' => 'inventory', + ], ], ], ], diff --git a/lang/en/baseline-compare.php b/lang/en/baseline-compare.php index 70d82eec..dba9c8d3 100644 --- a/lang/en/baseline-compare.php +++ b/lang/en/baseline-compare.php @@ -12,8 +12,8 @@ // Duplicate-name warning banner 'duplicate_warning_title' => 'Warning', - 'duplicate_warning_body_plural' => ':count policies in this tenant share the same display name. :app cannot match them to the baseline. Please rename the duplicates in the Microsoft Intune portal.', - 'duplicate_warning_body_singular' => ':count policy in this tenant shares the same display name. :app cannot match it to the baseline. Please rename the duplicate in the Microsoft Intune portal.', + 'duplicate_warning_body_plural' => ':count policies in this tenant share generic display names, resulting in :ambiguous_count ambiguous subjects. :app cannot match them safely to the baseline.', + 'duplicate_warning_body_singular' => ':count policy in this tenant shares a generic display name, resulting in :ambiguous_count ambiguous subject. :app cannot match it safely to the baseline.', // Stats card labels 'stat_assigned_baseline' => 'Assigned Baseline', @@ -30,21 +30,32 @@ 'badge_evidence_gaps' => 'Evidence gaps: :count', 'evidence_gaps_tooltip' => 'Top gaps: :summary', 'evidence_gap_details_heading' => 'Evidence gap details', - 'evidence_gap_details_description' => 'Search recorded gap subjects by reason, policy type, or subject key before falling back to raw diagnostics.', + 'evidence_gap_details_description' => 'Search recorded gap subjects by reason, policy type, subject class, outcome, next action, or subject key before falling back to raw diagnostics.', 'evidence_gap_search_label' => 'Search gap details', - 'evidence_gap_search_placeholder' => 'Search by reason, policy type, or subject key', - 'evidence_gap_search_help' => 'Filter matches across reason, policy type, and subject key.', + 'evidence_gap_search_placeholder' => 'Search by reason, type, class, outcome, action, or subject key', + 'evidence_gap_search_help' => 'Filter matches across reason, policy type, subject class, outcome, next action, and subject key.', 'evidence_gap_bucket_help' => 'Reason summaries stay separate from the detailed row table below.', 'evidence_gap_reason' => 'Reason', 'evidence_gap_reason_affected' => ':count affected', 'evidence_gap_reason_recorded' => ':count recorded', 'evidence_gap_reason_missing_detail' => ':count missing detail', + 'evidence_gap_structural' => 'Structural: :count', + 'evidence_gap_operational' => 'Operational: :count', + 'evidence_gap_transient' => 'Transient: :count', + 'evidence_gap_bucket_structural' => ':count structural', + 'evidence_gap_bucket_operational' => ':count operational', + 'evidence_gap_bucket_transient' => ':count transient', 'evidence_gap_missing_details_title' => 'Detailed rows were not recorded for this run', 'evidence_gap_missing_details_body' => 'Evidence gaps were counted for this compare run, but subject-level detail was not stored. Review the raw diagnostics below or rerun the comparison for fresh detail.', 'evidence_gap_missing_reason_body' => ':count affected subjects were counted for this reason, but detailed rows were not recorded for this run.', + 'evidence_gap_legacy_title' => 'Legacy development gap payload detected', + 'evidence_gap_legacy_body' => 'This run still uses the retired broad reason shape. Regenerate the run or purge stale local development payloads before treating the gap details as operator-safe.', 'evidence_gap_diagnostics_heading' => 'Baseline compare evidence', 'evidence_gap_diagnostics_description' => 'Raw diagnostics remain available for support and deep troubleshooting after the operator summary and searchable detail.', 'evidence_gap_policy_type' => 'Policy type', + 'evidence_gap_subject_class' => 'Subject class', + 'evidence_gap_outcome' => 'Outcome', + 'evidence_gap_next_action' => 'Next action', 'evidence_gap_subject_key' => 'Subject key', 'evidence_gap_table_empty_heading' => 'No recorded gap rows match this view', 'evidence_gap_table_empty_description' => 'Adjust the current search or filters to review other affected subjects.', diff --git a/resources/views/filament/infolists/entries/evidence-gap-subjects.blade.php b/resources/views/filament/infolists/entries/evidence-gap-subjects.blade.php index 100950d6..b575b358 100644 --- a/resources/views/filament/infolists/entries/evidence-gap-subjects.blade.php +++ b/resources/views/filament/infolists/entries/evidence-gap-subjects.blade.php @@ -2,10 +2,24 @@ $summary = is_array($summary ?? null) ? $summary : []; $buckets = is_array($buckets ?? null) ? $buckets : []; $detailState = is_string($summary['detail_state'] ?? null) ? $summary['detail_state'] : 'no_gaps'; + $structuralCount = is_numeric($summary['structural_count'] ?? null) ? (int) $summary['structural_count'] : 0; + $operationalCount = is_numeric($summary['operational_count'] ?? null) ? (int) $summary['operational_count'] : 0; + $transientCount = is_numeric($summary['transient_count'] ?? null) ? (int) $summary['transient_count'] : 0; $tableContext = is_string($searchId ?? null) && $searchId !== '' ? $searchId : 'evidence-gap-search'; @endphp -@if ($detailState === 'details_not_recorded' && $buckets === []) +@if ($detailState === 'legacy_broad_reason') +
+
+
+ {{ __('baseline-compare.evidence_gap_legacy_title') }} +
+

+ {{ __('baseline-compare.evidence_gap_legacy_body') }} +

+
+
+@elseif ($detailState === 'details_not_recorded' && $buckets === [])
@@ -18,6 +32,20 @@
@elseif ($buckets !== [])
+ @if ($detailState === 'structured_details_recorded' && ($structuralCount > 0 || $operationalCount > 0 || $transientCount > 0)) +
+ + {{ __('baseline-compare.evidence_gap_structural', ['count' => $structuralCount]) }} + + + {{ __('baseline-compare.evidence_gap_operational', ['count' => $operationalCount]) }} + + + {{ __('baseline-compare.evidence_gap_transient', ['count' => $transientCount]) }} + +
+ @endif + @if ($detailState === 'details_not_recorded')
@@ -63,6 +91,21 @@ {{ __('baseline-compare.evidence_gap_reason_missing_detail', ['count' => $missingDetailCount]) }} @endif + @if ((int) ($bucket['structural_count'] ?? 0) > 0) + + {{ __('baseline-compare.evidence_gap_bucket_structural', ['count' => (int) $bucket['structural_count']]) }} + + @endif + @if ((int) ($bucket['operational_count'] ?? 0) > 0) + + {{ __('baseline-compare.evidence_gap_bucket_operational', ['count' => (int) $bucket['operational_count']]) }} + + @endif + @if ((int) ($bucket['transient_count'] ?? 0) > 0) + + {{ __('baseline-compare.evidence_gap_bucket_transient', ['count' => (int) $bucket['transient_count']]) }} + + @endif
@if ($missingDetailCount > 0) diff --git a/resources/views/filament/pages/baseline-compare-landing.blade.php b/resources/views/filament/pages/baseline-compare-landing.blade.php index 1186ef8c..3b8b4607 100644 --- a/resources/views/filament/pages/baseline-compare-landing.blade.php +++ b/resources/views/filament/pages/baseline-compare-landing.blade.php @@ -6,6 +6,7 @@ @php $duplicateNamePoliciesCountValue = (int) ($duplicateNamePoliciesCount ?? 0); + $duplicateNameSubjectsCountValue = (int) ($duplicateNameSubjectsCount ?? 0); $explanation = is_array($operatorExplanation ?? null) ? $operatorExplanation : null; $explanationCounts = collect(is_array($explanation['countDescriptors'] ?? null) ? $explanation['countDescriptors'] : []); $evaluationSpec = is_string($explanation['evaluationResult'] ?? null) @@ -27,6 +28,7 @@
{{ __($duplicateNamePoliciesCountValue === 1 ? 'baseline-compare.duplicate_warning_body_singular' : 'baseline-compare.duplicate_warning_body_plural', [ 'count' => $duplicateNamePoliciesCountValue, + 'ambiguous_count' => $duplicateNameSubjectsCountValue, 'app' => config('app.name', 'TenantPilot'), ]) }}
diff --git a/specs/163-baseline-subject-resolution/checklists/requirements.md b/specs/163-baseline-subject-resolution/checklists/requirements.md new file mode 100644 index 00000000..44c26fe1 --- /dev/null +++ b/specs/163-baseline-subject-resolution/checklists/requirements.md @@ -0,0 +1,35 @@ +# Specification Quality Checklist: Baseline Subject Resolution and Evidence Gap Semantics Foundation + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-03-24 +**Feature**: [spec.md](/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/163-baseline-subject-resolution/spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- Validated on first pass. +- The spec intentionally establishes root-cause and runtime-support semantics only; fidelity richness, renderer density, and wording refinement remain deferred follow-on work. \ No newline at end of file diff --git a/specs/163-baseline-subject-resolution/contracts/openapi.yaml b/specs/163-baseline-subject-resolution/contracts/openapi.yaml new file mode 100644 index 00000000..6c360976 --- /dev/null +++ b/specs/163-baseline-subject-resolution/contracts/openapi.yaml @@ -0,0 +1,334 @@ +openapi: 3.1.0 +info: + title: Baseline Subject Resolution Semantics Contract + version: 0.1.0 + description: >- + Read-model and validation contracts for baseline compare and capture subject-resolution + semantics. This contract documents the payloads existing operator surfaces consume after + the foundation upgrade and the capability matrix used to keep baseline support truthful + at runtime. +servers: + - url: https://tenantpilot.local +paths: + /admin/api/operations/{operationRun}/baseline-gap-semantics: + get: + summary: Get structured baseline gap semantics for an operation run + operationId: getBaselineGapSemanticsForRun + parameters: + - name: operationRun + in: path + required: true + schema: + type: integer + responses: + '200': + description: Structured baseline gap semantics for the run + content: + application/json: + schema: + $ref: '#/components/schemas/BaselineGapSemanticsResponse' + '404': + description: Run not found or not visible in current workspace or tenant scope + '403': + description: Caller is a member but lacks permission to inspect the run + + /admin/api/tenants/{tenant}/baseline-compare/latest-gap-semantics: + get: + summary: Get latest baseline compare gap semantics for a tenant and profile + operationId: getLatestTenantBaselineCompareGapSemantics + parameters: + - name: tenant + in: path + required: true + schema: + type: integer + - name: baseline_profile_id + in: query + required: true + schema: + type: integer + responses: + '200': + description: Latest tenant baseline compare semantics projection + content: + application/json: + schema: + $ref: '#/components/schemas/BaselineGapSemanticsResponse' + '404': + description: Tenant or run not found for current entitled scope + '403': + description: Caller lacks compare or review visibility within the established tenant scope + + /admin/api/baseline-support/resolution-capabilities: + get: + summary: Get runtime baseline support and resolution capability matrix + operationId: getBaselineResolutionCapabilities + responses: + '200': + description: Capability matrix used to validate truthful compare and capture support + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + type: array + items: + $ref: '#/components/schemas/SupportCapabilityRecord' + +components: + schemas: + BaselineGapSemanticsResponse: + type: object + required: + - summary + - buckets + properties: + summary: + $ref: '#/components/schemas/BaselineGapSummary' + buckets: + type: array + items: + $ref: '#/components/schemas/BaselineGapBucket' + + BaselineGapSummary: + type: object + required: + - count + - by_reason + - detail_state + - recorded_subjects_total + - missing_detail_count + - structural_count + - operational_count + - transient_count + - legacy_mode + - requires_regeneration + properties: + count: + type: integer + minimum: 0 + by_reason: + type: object + additionalProperties: + type: integer + minimum: 0 + detail_state: + type: string + enum: + - no_gaps + - structured_details_recorded + - details_not_recorded + - legacy_broad_reason + recorded_subjects_total: + type: integer + minimum: 0 + missing_detail_count: + type: integer + minimum: 0 + structural_count: + type: integer + minimum: 0 + operational_count: + type: integer + minimum: 0 + transient_count: + type: integer + minimum: 0 + legacy_mode: + type: boolean + requires_regeneration: + type: boolean + + BaselineGapBucket: + type: object + required: + - reason_code + - reason_label + - count + - recorded_count + - missing_detail_count + - structural_count + - operational_count + - transient_count + - detail_state + - search_text + - rows + properties: + reason_code: + type: string + reason_label: + type: string + count: + type: integer + minimum: 0 + recorded_count: + type: integer + minimum: 0 + missing_detail_count: + type: integer + minimum: 0 + structural_count: + type: integer + minimum: 0 + operational_count: + type: integer + minimum: 0 + transient_count: + type: integer + minimum: 0 + detail_state: + type: string + enum: + - structured_details_recorded + - details_not_recorded + - legacy_broad_reason + search_text: + type: string + rows: + type: array + items: + $ref: '#/components/schemas/EvidenceGapDetailRecord' + + EvidenceGapDetailRecord: + type: object + required: + - policy_type + - subject_key + - subject_class + - resolution_path + - resolution_outcome + - reason_code + - operator_action_category + - structural + - retryable + properties: + policy_type: + type: string + subject_external_id: + type: + - string + - 'null' + subject_key: + type: string + subject_class: + $ref: '#/components/schemas/SubjectClass' + resolution_path: + $ref: '#/components/schemas/ResolutionPath' + resolution_outcome: + $ref: '#/components/schemas/ResolutionOutcome' + reason_code: + type: string + operator_action_category: + $ref: '#/components/schemas/OperatorActionCategory' + structural: + type: boolean + retryable: + type: boolean + source_model_expected: + type: + - string + - 'null' + enum: + - policy + - inventory + - derived + - 'null' + source_model_found: + type: + - string + - 'null' + enum: + - policy + - inventory + - derived + - 'null' + legacy_reason_code: + type: + - string + - 'null' + + SupportCapabilityRecord: + type: object + required: + - policy_type + - subject_class + - compare_capability + - capture_capability + - resolution_path + - config_supported + - runtime_valid + properties: + policy_type: + type: string + subject_class: + $ref: '#/components/schemas/SubjectClass' + compare_capability: + type: string + enum: + - supported + - limited + - unsupported + capture_capability: + type: string + enum: + - supported + - limited + - unsupported + resolution_path: + $ref: '#/components/schemas/ResolutionPath' + config_supported: + type: boolean + runtime_valid: + type: boolean + + SubjectClass: + type: string + enum: + - policy_backed + - inventory_backed + - foundation_backed + - derived + + ResolutionPath: + type: string + enum: + - policy + - inventory + - foundation_policy + - foundation_inventory + - derived + - unsupported + + ResolutionOutcome: + type: string + enum: + - resolved_policy + - resolved_inventory + - policy_record_missing + - inventory_record_missing + - foundation_inventory_only + - resolution_type_mismatch + - unresolvable_subject + - permission_or_scope_blocked + - retryable_capture_failure + - capture_failed + - throttled + - budget_exhausted + - ambiguous_match + - invalid_subject + - duplicate_subject + - invalid_support_config + + OperatorActionCategory: + type: string + enum: + - none + - retry + - run_inventory_sync + - run_policy_sync_or_backup + - review_permissions + - inspect_subject_mapping + - product_follow_up diff --git a/specs/163-baseline-subject-resolution/data-model.md b/specs/163-baseline-subject-resolution/data-model.md new file mode 100644 index 00000000..e20d2a43 --- /dev/null +++ b/specs/163-baseline-subject-resolution/data-model.md @@ -0,0 +1,167 @@ +# Data Model: Baseline Subject Resolution and Evidence Gap Semantics Foundation + +## Overview + +This feature does not require a new primary database table. It introduces a richer logical model for subject classification and resolution, then persists that model inside existing compare and capture run context for new runs. Development-only run payloads using the old broad reason shape may be removed or regenerated instead of preserved through a compatibility contract. + +## Entity: SubjectDescriptor + +Business-level descriptor for a compare or capture target before local resolution. + +### Fields + +| Field | Type | Required | Description | +|---|---|---|---| +| `policy_type` | string | yes | Canonical policy or foundation type from support metadata | +| `subject_external_id` | string | no | Stable local or tenant-local external identifier when available | +| `subject_key` | string | yes | Deterministic comparison key used across capture and compare | +| `subject_class` | enum | yes | One of `policy_backed`, `inventory_backed`, `foundation_backed`, `derived` | +| `resolution_path` | enum | yes | Intended local path, such as `policy`, `inventory`, `foundation_policy`, `foundation_inventory`, or `derived` | +| `support_mode` | enum | yes | `supported`, `limited`, `excluded`, or `invalid_support_config` | +| `source_model_expected` | enum | no | Which local model is expected to satisfy the lookup | + +### Validation Rules + +- `policy_type` must exist in canonical support metadata. +- `subject_class` must be one of the supported subject-class values. +- `resolution_path` must be compatible with `subject_class`. +- `support_mode=invalid_support_config` is only valid when metadata claims support but no truthful runtime path exists. + +## Entity: ResolutionOutcomeRecord + +Deterministic result of attempting to resolve a `SubjectDescriptor` against tenant-local state. + +### Fields + +| Field | Type | Required | Description | +|---|---|---|---| +| `resolution_outcome` | enum | yes | Precise outcome such as `resolved_policy`, `resolved_inventory`, `policy_record_missing`, `inventory_record_missing`, `foundation_inventory_only`, `resolution_type_mismatch`, `unresolvable_subject`, `permission_or_scope_blocked`, `retryable_capture_failure`, `capture_failed`, `throttled`, or `budget_exhausted` | +| `reason_code` | string | yes | Stable operator-facing reason family persisted with the run | +| `operator_action_category` | enum | yes | `none`, `retry`, `run_inventory_sync`, `run_policy_sync_or_backup`, `review_permissions`, `inspect_subject_mapping`, or `product_follow_up` | +| `structural` | boolean | yes | Whether the outcome is structural rather than operational or transient | +| `retryable` | boolean | yes | Whether retry may change the outcome without prerequisite changes | +| `source_model_found` | enum or null | no | Which local model actually satisfied the lookup when resolution succeeded | + +### State Families + +| Family | Outcomes | +|---|---| +| `resolved` | `resolved_policy`, `resolved_inventory` | +| `structural` | `foundation_inventory_only`, `resolution_type_mismatch`, `unresolvable_subject`, `invalid_support_config` | +| `operational` | `policy_record_missing`, `inventory_record_missing`, `permission_or_scope_blocked`, `ambiguous_match`, `invalid_subject`, `duplicate_subject` | +| `transient` | `retryable_capture_failure`, `throttled`, `budget_exhausted`, `capture_failed` | + +### Validation Rules + +- `resolution_outcome` must map to exactly one state family. +- `structural=true` is only valid for structural state-family outcomes. +- `retryable=true` is only valid for transient outcomes or explicitly retryable operational outcomes. + +## Entity: SupportCapabilityRecord + +Runtime truth contract for whether a subject type may enter baseline compare or capture. + +### Fields + +| Field | Type | Required | Description | +|---|---|---|---| +| `policy_type` | string | yes | Canonical type key | +| `subject_class` | enum | yes | Dominant subject class for the type | +| `compare_capability` | enum | yes | `supported`, `limited`, or `unsupported` | +| `capture_capability` | enum | yes | `supported`, `limited`, or `unsupported` | +| `resolution_path` | enum | yes | Truthful runtime resolution path | +| `config_supported` | boolean | yes | Whether metadata claims support | +| `runtime_valid` | boolean | yes | Whether the runtime can honor that support claim | + +### Validation Rules + +- `config_supported=true` and `runtime_valid=false` must be surfaced as `invalid_support_config` rather than silently ignored. +- Types with `compare_capability=unsupported` must not enter compare scope. +- Types with `capture_capability=unsupported` must not enter capture execution. + +## Entity: EvidenceGapDetailRecord + +Structured subject-level record persisted under compare or capture run context for new runs. + +### Fields + +| Field | Type | Required | Description | +|---|---|---|---| +| `policy_type` | string | yes | Canonical type | +| `subject_external_id` | string or null | no | Stable identifier when available | +| `subject_key` | string | yes | Deterministic subject identity | +| `subject_class` | enum | yes | Classified subject class | +| `resolution_path` | enum | yes | Path attempted or declared | +| `resolution_outcome` | enum | yes | Deterministic resolution result | +| `reason_code` | string | yes | Stable reason family | +| `operator_action_category` | enum | yes | Recommended next action family | +| `structural` | boolean | yes | Structural versus non-structural marker | +| `retryable` | boolean | yes | Retryability marker | +| `source_model_expected` | enum or null | no | Expected local evidence model | +| `source_model_found` | enum or null | no | Actual local evidence model when present | + +### Storage Locations + +- `operation_runs.context.baseline_compare.evidence_gaps.subjects[]` +- `operation_runs.context.baseline_capture.gaps.subjects[]` or equivalent capture-context namespace + +### Validation Rules + +- New-run records must store structured objects, not only string subject tokens. +- `subject_key` must be deterministic for identical inputs. +- `reason_code` and `resolution_outcome` must not contradict each other. +- Old development rows that omit the new fields are cleanup candidates and should be regenerated or deleted rather than treated as a first-class runtime shape. + +## Derived Entity: EvidenceGapProjection + +Read-model projection used by canonical run-detail and tenant review surfaces. + +### Fields + +| Field | Type | Description | +|---|---|---| +| `detail_state` | enum | `no_gaps`, `structured_details_recorded`, `details_not_recorded`, `legacy_broad_reason` | +| `count` | integer | Total gap count | +| `by_reason` | map | Aggregate counts by reason | +| `recorded_subjects_total` | integer | Number of structured subject rows available for projection | +| `missing_detail_count` | integer | Gap count that has no structured row attached | +| `structural_count` | integer | Number of recorded structural gap rows | +| `operational_count` | integer | Number of recorded non-structural, non-retryable rows | +| `transient_count` | integer | Number of recorded retryable rows | +| `legacy_mode` | boolean | Indicates the run still stores a broad legacy gap payload | +| `buckets` | list | Grouped records by reason with searchable row payload | +| `requires_regeneration` | boolean | Whether stale local development data should be regenerated rather than interpreted semantically | + +## State Transitions + +### Resolution lifecycle for a subject + +1. `described` + - `SubjectDescriptor` is created from scope, metadata, and capability information. +2. `validated` + - Runtime support guard confirms whether the subject may enter compare or capture. +3. `resolved` + - The system attempts the appropriate local path and emits a `ResolutionOutcomeRecord`. +4. `persisted` + - New runs store the structured `EvidenceGapDetailRecord` or resolved outcome details in `OperationRun.context`. +5. `projected` + - Existing operator surfaces consume the new structured projection. Stale development data is regenerated or removed instead of driving a permanent compatibility path. + +## Example New-Run Compare Gap Record + +```json +{ + "policy_type": "roleScopeTag", + "subject_external_id": "42f4fcb8-5b35-44ec-9240-0a1ad7c31fb1", + "subject_key": "rolescopetag|42f4fcb8-5b35-44ec-9240-0a1ad7c31fb1", + "subject_class": "foundation_backed", + "resolution_path": "foundation_inventory", + "resolution_outcome": "foundation_inventory_only", + "reason_code": "foundation_not_policy_backed", + "operator_action_category": "product_follow_up", + "structural": true, + "retryable": false, + "source_model_expected": "inventory_item", + "source_model_found": "inventory_item" +} +``` diff --git a/specs/163-baseline-subject-resolution/plan.md b/specs/163-baseline-subject-resolution/plan.md new file mode 100644 index 00000000..35175e9d --- /dev/null +++ b/specs/163-baseline-subject-resolution/plan.md @@ -0,0 +1,246 @@ +# Implementation Plan: 163 — Baseline Subject Resolution and Evidence Gap Semantics Foundation + +**Branch**: `163-baseline-subject-resolution` | **Date**: 2026-03-24 | **Spec**: `specs/163-baseline-subject-resolution/spec.md` +**Input**: Feature specification from `specs/163-baseline-subject-resolution/spec.md` + +**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts. + +## Summary + +Introduce an explicit backend subject-resolution contract for baseline compare and baseline capture so the system can classify each subject before resolution, select the correct local model path, and persist precise operator-safe gap semantics instead of collapsing structural, operational, and transient causes into broad `policy_not_found` style states. The implementation will extend existing baseline scope, inventory policy-type metadata, compare and capture jobs, baseline evidence-gap detail parsing, and OperationRun context persistence rather than introducing a parallel execution stack, with a bounded runtime support guard that prevents baseline-supported types from entering compare or capture on a resolver path that cannot truthfully classify them. + +## Technical Context + +**Language/Version**: PHP 8.4 +**Primary Dependencies**: Laravel 12, Filament v5, Livewire v4 +**Storage**: PostgreSQL via existing application tables, especially `operation_runs.context` and baseline snapshot summary JSON +**Testing**: Pest v4 on PHPUnit 12 +**Target Platform**: Dockerized Laravel web application running through Sail locally and Dokploy in deployment +**Project Type**: Web application +**Performance Goals**: Preserve DB-only render behavior for Monitoring and tenant review surfaces, add no render-time Graph calls, and keep evidence-gap interpretation deterministic and lightweight enough for existing run-detail and landing surfaces +**Constraints**: +- No new render-time remote work and no bypass of `GraphClientInterface` +- No change to `OperationRun` lifecycle ownership, notification channels, or summary-count rules +- No new operator screen; existing surfaces must present richer semantics +- Existing development-only run payloads may be deleted or regenerated if that simplifies migration to the new structured contract +- Baseline-supported configuration must not overpromise runtime capability +**Scale/Scope**: Cross-cutting backend semantic work across baseline compare and capture pipelines, support-layer parsers and translators, OperationRun context contracts, tenant and canonical read surfaces, and focused Pest coverage for deterministic classification and development-safe contract cleanup + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +- Inventory-first: PASS — the design keeps inventory as last-observed truth and distinguishes inventory-backed evidence from policy-backed evidence rather than conflating them. +- Read/write separation: PASS — this feature changes classification and persisted run semantics inside existing compare and capture flows; it does not add new write or restore actions. +- Graph contract path: PASS — no new Graph contract or direct endpoint use is introduced; existing capture and sync services remain the only remote paths. +- Deterministic capabilities: PASS — subject-class derivation, resolution outcome mapping, and support-capability guards are explicitly designed to be deterministic and testable. +- RBAC-UX: PASS — existing `/admin` and tenant-context authorization boundaries remain unchanged; only read semantics improve. +- Workspace isolation: PASS — no new workspace leakage is introduced and canonical run-detail remains tenant-safe. +- RBAC confirmations: PASS — no new destructive actions are added. +- Global search: PASS — unaffected. +- Tenant isolation: PASS — all compare, capture, inventory, and run data remain tenant-bound and entitlement-checked. +- Run observability: PASS — compare and capture continue to use existing `OperationRun` types; this slice enriches context semantics only. +- Ops-UX 3-surface feedback: PASS — no new toast, progress, or terminal-notification channels are added. +- Ops-UX lifecycle: PASS — `OperationRun.status` and `OperationRun.outcome` remain service-owned; only context enrichment changes. +- Ops-UX summary counts: PASS — no non-numeric values are moved into `summary_counts`; richer semantics live in context and read models. +- Ops-UX guards: PASS — focused regression tests can protect classification determinism and development cleanup behavior without relaxing existing CI rules. +- Ops-UX system runs: PASS — unchanged. +- Automation: PASS — existing queue, retry, and backoff behavior stays intact; transient outcomes are classified more precisely, not re-executed differently. +- Data minimization: PASS — the new gap detail contract stores classification and stable identifiers, not raw policy payloads or secrets. +- Badge semantics (BADGE-001): PASS — if structural, operational, or transient labels surface as badges, they must route through centralized badge or presentation helpers rather than ad hoc maps. +- UI naming (UI-NAMING-001): PASS — the feature exists to replace implementation-first broad error prose with domain-first operator meaning. +- Operator surfaces (OPSURF-001): PASS — existing run detail and tenant review surfaces remain operator-first and diagnostics-secondary. +- Filament UI Action Surface Contract: PASS — action topology stays unchanged; this is a read-surface semantics upgrade. +- Filament UI UX-001 (Layout & IA): PASS — existing layouts remain, but sections become more semantically truthful. No exemption required. + +## Project Structure + +### Documentation (this feature) + +```text +specs/163-baseline-subject-resolution/ +├── plan.md +├── research.md +├── data-model.md +├── quickstart.md +├── contracts/ +│ └── openapi.yaml +├── checklists/ +│ └── requirements.md +└── tasks.md +``` + +### Source Code (repository root) + +```text +app/ +├── Filament/ +│ ├── Pages/ +│ │ └── BaselineCompareLanding.php +│ ├── Resources/ +│ │ ├── OperationRunResource.php +│ │ └── BaselineSnapshotResource.php +│ └── Widgets/ +├── Jobs/ +│ ├── CompareBaselineToTenantJob.php +│ └── CaptureBaselineSnapshotJob.php +├── Services/ +│ ├── Baselines/ +│ │ ├── BaselineCompareService.php +│ │ ├── BaselineCaptureService.php +│ │ ├── BaselineContentCapturePhase.php +│ │ └── Evidence/ +│ ├── Intune/ +│ │ └── PolicySyncService.php +│ └── Inventory/ +│ └── InventorySyncService.php +├── Support/ +│ ├── Baselines/ +│ ├── Inventory/ +│ ├── OpsUx/ +│ └── Ui/ +├── Livewire/ +└── Models/ +config/ +├── tenantpilot.php +└── graph_contracts.php +tests/ +├── Feature/ +│ ├── Baselines/ +│ ├── Filament/ +│ └── Monitoring/ +└── Unit/ + └── Support/ +``` + +**Structure Decision**: Web application. The work stays inside existing baseline jobs and services, support-layer value objects and presenters, current Filament surfaces, and focused Pest coverage. No new top-level architecture area is required. + +## Complexity Tracking + +No constitution violations are required for this feature. + +## Phase 0 — Outline & Research (DONE) + +Outputs: +- `specs/163-baseline-subject-resolution/research.md` + +Key decisions captured: +- Introduce a first-class subject-resolution contract in the backend instead of solving the problem with UI-only relabeling. +- Persist both `subject_class` and `resolution_outcome` because they answer different operator questions. +- Keep foundation-backed subjects eligible only when the runtime can truthfully classify them through an inventory-backed or limited-capability path. +- Add a runtime consistency guard during scope or resolver preparation so `baseline_compare.supported` cannot silently overpromise structural capability. +- Preserve transient reasons such as throttling and capture failure as precise operational outcomes rather than absorbing them into structural taxonomy. +- Treat broad legacy gap shapes as development-only cleanup candidates rather than a compatibility requirement for the new runtime contract. + +## Phase 1 — Design & Contracts (DONE) + +Outputs: +- `specs/163-baseline-subject-resolution/data-model.md` +- `specs/163-baseline-subject-resolution/contracts/openapi.yaml` +- `specs/163-baseline-subject-resolution/quickstart.md` + +Design highlights: +- The core semantic unit is a `SubjectDescriptor` that is classified before resolution and yields a deterministic `ResolutionOutcomeRecord`. +- `OperationRun.context` remains the canonical persisted contract for compare and capture evidence-gap semantics, but new runs store richer subject-level objects instead of reason plus raw string only. +- The runtime support guard sits before compare and capture execution so unsupported structural mismatches are blocked or reclassified before misleading `policy_not_found`-style outcomes are emitted. +- Existing detail and landing surfaces are updated for the new structured gap contract, and development fixtures or stale local run data are regenerated instead of driving a permanent compatibility layer. +- Compare and capture share the same root-cause vocabulary, but retain operation-specific outcome families where needed. + +## Phase 1 — Agent Context Update (REQUIRED) + +Run: +- `.specify/scripts/bash/update-agent-context.sh copilot` + +## Constitution Check — Post-Design Re-evaluation + +- PASS — the design remains inside existing compare and capture operations and does not add new remote-call paths or lifecycle mutations. +- PASS — inventory-first semantics are strengthened because inventory-backed subjects are no longer mislabeled as missing policy records. +- PASS — operator surfaces stay on existing pages and remain DB-only at render time. +- PASS — development cleanup is explicit and bounded; the new contract remains the only forward-looking runtime shape. +- PASS — no Action Surface or UX-001 exemptions are needed because action topology and layouts remain intact. + +## Phase 2 — Implementation Plan + +### Step 1 — Subject classification and runtime capability foundation + +Goal: implement FR-001 through FR-003, FR-008, FR-015, and FR-016 by creating a deterministic subject-resolution foundation shared by compare and capture. + +Changes: +- Introduce a dedicated subject-resolution support layer under `app/Support/Baselines/` that defines: + - subject classes + - resolution paths + - resolution outcomes + - operator action categories + - structural versus operational versus transient classification +- Extend `InventoryPolicyTypeMeta` and related metadata accessors so baseline support can express whether a type is policy-backed, inventory-backed, foundation-backed, or limited. +- Add a runtime capability guard used by `BaselineScope`, `BaselineCompareService`, and `BaselineCaptureService` so types only enter compare or capture on a truthful path. +- Keep the guard deterministic and explicit in logs or run context when support is limited or excluded. + +Tests: +- Add unit tests for subject-class derivation, resolution-path derivation, and runtime-capability guard behavior. +- Add golden-style tests covering supported, limited, and structurally invalid foundation types. + +### Step 2 — Capture-path resolution and gap taxonomy upgrade + +Goal: implement FR-004 through FR-010 on the capture side so structural resolver mismatches are no longer emitted as generic missing-policy cases. + +Changes: +- Refactor `BaselineContentCapturePhase` so it resolves subjects through the new subject contract rather than assuming a policy lookup for all subjects. +- Replace broad `policy_not_found` capture gaps with precise structured outcomes such as: + - policy record missing + - inventory record missing + - foundation-backed via inventory path + - resolution type mismatch + - unresolvable subject +- Preserve existing transient outcomes like `throttled`, `capture_failed`, and `budget_exhausted` unchanged except for richer structured metadata. +- Persist new structured gap-subject objects for new runs and remove any requirement to keep broad legacy reason shapes alive for future writes. + +Tests: +- Add feature and unit coverage for capture-path classification across policy-backed, inventory-backed, foundation-backed, duplicate, invalid, and transient cases. +- Add deterministic replay coverage proving unchanged capture inputs produce unchanged outcomes. +- Add regressions proving structural foundation subjects no longer produce new generic `policy_not_found` gaps. + +### Step 3 — Compare-path resolution and evidence-gap detail contract + +Goal: implement FR-004 through FR-014 on the compare side by aligning current-evidence resolution, evidence-gap reasoning, and persisted run context with the new contract. + +Changes: +- Refactor `CompareBaselineToTenantJob` so baseline item interpretation and current-state resolution produce explicit `resolution_outcome` records rather than only count buckets and raw subject keys. +- Add structured evidence-gap subject records under `baseline_compare.evidence_gaps.subjects` for new runs, including subject class, resolution path, resolution outcome, reason code, operator action category, and retryability or structural flags. +- Preserve already precise compare reasons such as `missing_current`, `ambiguous_match`, and role-definition-specific gap families while separating them from structural non-policy-backed outcomes. +- Ensure baseline compare reason translation remains aligned with the new detailed reason taxonomy instead of flattening distinct root causes. + +Tests: +- Add feature tests for mixed compare runs containing structural, operational, transient, and successful subjects. +- Add deterministic compare tests proving identical inputs yield identical resolution outcomes. +- Add regressions for evidence-gap persistence shape and compare-surface rendering against the new structured contract. + +### Step 4 — Development cleanup and operator-surface adoption + +Goal: implement FR-011 through FR-014 and the User Story 3 acceptance scenarios by moving existing read surfaces to the new gap contract and treating stale development data as disposable. + +Changes: +- Extend `BaselineCompareEvidenceGapDetails`, `BaselineCompareStats`, `OperationRunResource`, `BaselineCompareLanding`, and any related Livewire gap tables so they read the new structured gap subject records consistently. +- Add an explicit development cleanup mechanism for stale local run payloads, preferably a dedicated development-only Artisan command plus fixture regeneration steps, so old broad string-only gap subjects can be purged instead of preserved. +- Introduce operator-facing labels that answer root cause before action advice while keeping diagnostics secondary. +- Keep existing pages and sections, but expose structural versus operational versus transient semantics consistently across dense and detailed surfaces. +- Update snapshot and compare summary surfaces where old broad reason aggregations would otherwise misread the new taxonomy. + +Tests: +- Add or update Filament feature tests for canonical run detail and tenant baseline compare landing against the new structured run shape. +- Add cleanup-oriented tests proving the development cleanup mechanism removes or invalidates stale broad-reason run payloads without extending production semantics. + +### Step 5 — Focused validation pack and rollout safety + +Goal: protect the foundation from semantic regressions and make follow-on fidelity work safe. + +Changes: +- Add a focused regression pack spanning compare, capture, capability guard, and development-safe contract cleanup. +- Review every touched reason-label and badge usage to ensure structural, operational, and transient meanings remain centralized. +- Document the new backend contract shape in code-level PHPDoc and tests so follow-on specs can build on stable semantics. +- Keep rollout bounded to baseline compare and capture semantics without adding renderer-richness work from Spec 164. + +Tests: +- Run the focused Pest pack in `quickstart.md`. +- Add one regression proving no render-time Graph calls occur on affected run-detail or landing surfaces. + diff --git a/specs/163-baseline-subject-resolution/quickstart.md b/specs/163-baseline-subject-resolution/quickstart.md new file mode 100644 index 00000000..346e8969 --- /dev/null +++ b/specs/163-baseline-subject-resolution/quickstart.md @@ -0,0 +1,87 @@ +# Quickstart: Baseline Subject Resolution and Evidence Gap Semantics Foundation + +## Prerequisites + +1. Start the local stack. + +```bash +vendor/bin/sail up -d +``` + +2. Clear stale cached state if you have been switching branches or configs. + +```bash +vendor/bin/sail artisan optimize:clear +``` + +## Focused Verification Pack + +Run the minimum targeted regression pack for this foundation: + +```bash +vendor/bin/sail artisan test --compact tests/Unit/Support/Baselines tests/Feature/Baselines tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php +``` + +If the implementation introduces dedicated new files, narrow the pack further to the new subject-resolution, compare, capture, and development-cleanup tests. + +Format touched files before final review: + +```bash +vendor/bin/sail bin pint --dirty --format agent +``` + +## Manual Verification Flow + +1. Ensure a tenant has fresh inventory data for at least one policy-backed type and one baseline-supported foundation type. +2. Trigger or locate a baseline capture run and a baseline compare run for that tenant and profile. +3. Open the canonical run detail at `/admin/operations/{run}`. +4. Confirm the page distinguishes: + - structural cases + - operational or missing-local-data cases + - transient retryable cases +5. Confirm inventory-only foundation subjects no longer surface as a new generic `policy_not_found` gap. +6. Confirm policy-backed missing-local-record cases still surface as an operational missing-record outcome. + +## Development Cleanup Verification + +1. Remove or invalidate old local compare or capture runs that still contain broad legacy gap reasons. + Dry-run: + +```bash +vendor/bin/sail artisan tenantpilot:baselines:purge-legacy-gap-runs +``` + + Apply deletion: + +```bash +vendor/bin/sail artisan tenantpilot:baselines:purge-legacy-gap-runs --force +``` + +2. Regenerate fresh runs under the new structured contract. +3. Confirm the product and targeted tests no longer depend on the broad legacy shape being preserved in runtime code. + +## Runtime Capability Guard Verification + +1. Configure or seed one baseline-supported type whose runtime resolver path is valid. +2. Configure or seed one type whose support claim would be structurally invalid without the new guard. +3. Start compare or capture preparation. +4. Confirm the valid type enters execution with a truthful path. +5. Confirm the invalid type is limited, excluded, or explicitly classified as invalid support configuration before misleading gap output is produced. + +## Determinism Verification + +1. Run the same compare scenario twice against unchanged tenant-local data. +2. Confirm both runs persist the same `subject_class`, `resolution_outcome`, and `operator_action_category` values for the same subject. + +## Render-Safety Verification + +1. Bind the fail-hard Graph client in affected UI tests. +2. Verify canonical run detail and tenant baseline compare landing render without triggering Graph calls. +3. Verify the richer semantics are derived solely from persisted run context and local metadata. + +## Deployment Notes + +- No new panel provider is required; Laravel 12 continues to register providers in `bootstrap/providers.php`. +- Filament remains on Livewire v4-compatible patterns and does not require view publishing. +- No new shared or panel assets are required, so this slice adds no new `filament:assets` deployment step beyond the existing deployment baseline. +- Existing compare and capture operations remain on current `OperationRun` types and notification behavior. diff --git a/specs/163-baseline-subject-resolution/research.md b/specs/163-baseline-subject-resolution/research.md new file mode 100644 index 00000000..cf591ee8 --- /dev/null +++ b/specs/163-baseline-subject-resolution/research.md @@ -0,0 +1,65 @@ +# Research: Baseline Subject Resolution and Evidence Gap Semantics Foundation + +## Decision 1: Introduce a backend subject-resolution contract instead of UI-only relabeling + +- Decision: Add an explicit backend resolution layer that classifies each compare or capture subject before lookup and returns a structured resolution outcome. +- Rationale: The current failure is rooted in the resolver path itself. `BaselineContentCapturePhase` still assumes every subject can be resolved via `Policy`, while compare scope already admits foundation types through inventory metadata. Renaming `policy_not_found` in the UI would preserve the wrong source-of-truth contract. +- Alternatives considered: + - UI-only copy fixes: rejected because they would leave incorrect persisted semantics and non-deterministic operator meaning. + - Per-surface one-off translation rules: rejected because compare, capture, and run detail would drift semantically. + +## Decision 2: Persist both `subject_class` and `resolution_outcome` + +- Decision: Store both the business class of the subject and the result of resolving it. +- Rationale: `subject_class` answers what kind of object the system is dealing with, while `resolution_outcome` answers what actually happened. A single field would either blur object identity or overload root-cause meaning. +- Alternatives considered: + - Only store `resolution_outcome`: rejected because operators and future renderer work still need to know whether the target was policy-backed, inventory-backed, foundation-backed, or derived. + - Only store `subject_class`: rejected because class alone cannot distinguish resolved, missing-local-record, throttled, or structurally unsupported states. + +## Decision 3: Keep inventory-only foundation subjects in scope only when the runtime can truthfully classify them + +- Decision: Inventory-only foundation subjects may remain compare or capture eligible only when the runtime explicitly supports an inventory-backed or limited-capability path for them. +- Rationale: The product already includes supported foundations in baseline scope via `InventoryPolicyTypeMeta::baselineSupportedFoundations()` and `BaselineScope::allTypes()`. Removing them wholesale would hide legitimate support cases. Allowing them in scope without a truthful path produces predictable false alarms. +- Alternatives considered: + - Remove all inventory-only foundations from compare and capture: rejected because it would throw away potentially valid baseline support. + - Keep all supported foundations in scope and tolerate broad `policy_not_found`: rejected because it preserves the current trust problem. + +## Decision 4: Add a runtime consistency guard before compare and capture execution + +- Decision: Add a deterministic support-capability guard in scope or service preparation that validates each supported type against an actual resolution path before compare or capture runs. +- Rationale: The spec’s core “config must not overpromise” requirement is best enforced before job execution. This prevents structurally invalid types from silently entering a run and only failing later as misleading gaps. +- Alternatives considered: + - Validate only after gap generation: rejected because it still emits misleading runtime states. + - Validate only in configuration review or documentation: rejected because runtime truth must not depend on manual discipline. + +## Decision 5: Preserve transient and already-precise operational reasons as distinct outcomes + +- Decision: Keep `throttled`, `capture_failed`, `budget_exhausted`, `ambiguous_match`, and related precise reasons intact, while adding new structural and missing-local-record outcomes beside them. +- Rationale: These reasons already carry actionable meaning and should not be re-modeled into a coarse structural taxonomy. The new foundation is about separating root-cause families, not flattening them. +- Alternatives considered: + - Replace the entire existing reason vocabulary: rejected because it would cause unnecessary churn and regress already-useful operator semantics. + - Collapse transient reasons into one retryable bucket: rejected because rate limiting, capture errors, and budget exhaustion still imply different remediation paths. + +## Decision 6: Prefer development cleanup over legacy compatibility + +- Decision: Newly created runs write the richer structured shape immediately, and obsolete development-only run payloads may be deleted or regenerated instead of preserved through a compatibility parser. +- Rationale: The repository is still in development, so preserving broad historical reason codes would keep ambiguous semantics alive in the runtime model for no real product benefit. Rebuilding local data and fixtures is cheaper and cleaner than carrying a long-term compatibility path. +- Alternatives considered: + - Keep a compatibility parser for old run shapes: rejected because it would preserve the old semantic contract in code paths that should move to the new model immediately. + - Backfill old runs with inferred outcomes: rejected because the original resolver context is incomplete and inference would still be unreliable. + +## Decision 7: Reuse `OperationRun.context` as the canonical persistence boundary + +- Decision: Store the richer gap semantics inside existing compare and capture run context rather than creating a new relational evidence-gap table. +- Rationale: Compare and capture results are already run-scoped, immutable operational artifacts. Monitoring and tenant review surfaces must stay DB-only at render time. Extending the existing run context keeps the persistence boundary aligned with execution truth. +- Alternatives considered: + - New evidence-gap relational tables: rejected because they add mutable join complexity for a run-bounded artifact. + - On-demand recomputation from current inventory and policy state: rejected because current state can drift away from the run’s original truth. + +## Decision 8: Upgrade existing surfaces instead of adding a new operator page + +- Decision: Surface the richer semantics on existing canonical run-detail, tenant baseline compare landing, and related evidence-gap detail surfaces. +- Rationale: The feature is about truthfulness of semantics, not information architecture expansion. Existing surfaces already have the right operator entry points. +- Alternatives considered: + - Add a dedicated resolver diagnostics page: rejected because it would make core trust semantics secondary and harder to discover. + - Keep structured semantics backend-only: rejected because the operator value comes from clearer action guidance on current pages. diff --git a/specs/163-baseline-subject-resolution/spec.md b/specs/163-baseline-subject-resolution/spec.md new file mode 100644 index 00000000..390f3812 --- /dev/null +++ b/specs/163-baseline-subject-resolution/spec.md @@ -0,0 +1,174 @@ +# Feature Specification: Baseline Subject Resolution and Evidence Gap Semantics Foundation + +**Feature Branch**: `163-baseline-subject-resolution` +**Created**: 2026-03-24 +**Status**: Draft +**Input**: User description: "Spec 163 — Baseline Subject Resolution & Evidence Gap Semantics Foundation" + +## Spec Scope Fields *(mandatory)* + +- **Scope**: tenant, canonical-view +- **Primary Routes**: `/admin/operations/{run}`, `/admin/t/{tenant}/baseline-compare-landing`, and existing baseline compare and baseline capture entry points that surface evidence-gap meaning +- **Data Ownership**: Tenant-owned local evidence records, captured baseline comparison results, and operation-run context remain the operational source of truth for resolution outcomes. Workspace-owned baseline support metadata remains the source of support promises and subject-class expectations. +- **RBAC**: Existing workspace membership, tenant entitlement, and baseline compare or monitoring view permissions remain authoritative. This feature does not introduce new roles or broaden visibility. + +For canonical-view specs, the spec MUST define: + +- **Default filter behavior when tenant-context is active**: Canonical monitoring surfaces must continue to respect active tenant context in navigation and related links, while direct run-detail access remains explicit to the run's tenant and must not silently widen visibility. +- **Explicit entitlement checks preventing cross-tenant leakage**: Canonical run-detail and tenant-scoped compare review surfaces must continue to enforce workspace entitlement first and tenant entitlement second, with deny-as-not-found behavior for non-members and no cross-tenant hinting through resolution outcomes or evidence-gap details. + +## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)* + +| Surface | Primary Persona | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions | +|---|---|---|---|---|---|---|---|---|---| +| Monitoring → baseline compare or capture run detail | Workspace manager or entitled tenant operator | Canonical detail | Is this gap structural, operational, or transient, and what action should I take next? | Resolution meaning, subject class, operator-safe next step, whether retry or sync is relevant, whether the issue is a product limitation or tenant-local data issue | Raw context payloads and low-level diagnostic fragments | execution outcome, evidence completeness, root-cause class, actionability | Simulation only for compare interpretation, TenantPilot only for rendering and classification persistence | View run, inspect gap meaning, navigate to related tenant review surfaces | None | +| Tenant baseline compare landing and related review surfaces | Tenant operator | Tenant-scoped review surface | Can I trust this compare result, and what exactly is missing or mismatched locally? | Structural versus operational meaning, subject class, local evidence expectation, next-step guidance, compare support limitations | Raw stored context and secondary technical diagnostics | compare trust, data completeness, root-cause class, actionability | Simulation only | Compare now, inspect latest run, review evidence-gap meaning | None | + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Distinguish structural from missing-local-data gaps (Priority: P1) + +An operator reviewing a baseline compare or capture result needs the product to tell them whether the gap is structurally expected for that subject class or whether a policy or inventory record is actually missing locally. + +**Why this priority**: This is the core trust problem. If structural limits and missing-local-data cases are collapsed into one generic reason, operators take the wrong follow-up action and lose confidence in the product. + +**Independent Test**: Run compare and capture flows that include both policy-backed and foundation-backed subjects, then verify that the resulting gaps clearly separate structural resolver limits from missing local records without relying on raw diagnostics. + +**Acceptance Scenarios**: + +1. **Given** a baseline-supported subject that is inventory-backed but not policy-backed, **When** a new compare or capture run evaluates it, **Then** the run records a structural foundation or inventory outcome instead of a generic policy-not-found meaning. +2. **Given** a policy-backed subject with no local policy record, **When** the same flow evaluates it, **Then** the run records an operational missing-local-data outcome that is distinct from structural subject-class limits. + +--- + +### User Story 2 - Keep support promises truthful at runtime (Priority: P2) + +A product owner or operator needs baseline-supported subject types to enter compare or capture only when the runtime can classify and resolve them truthfully, so support configuration does not overpromise capabilities the resolver cannot deliver. + +**Why this priority**: False support promises create predictable false alarms and make baseline support metadata untrustworthy. + +**Independent Test**: Evaluate supported subject types against current resolver capability and verify that each type either enters the run with a valid resolution path and meaningful outcome set or is explicitly limited or excluded before misleading gaps are produced. + +**Acceptance Scenarios**: + +1. **Given** a subject type marked as baseline-supported, **When** the runtime has no truthful resolution path for that subject class, **Then** the type is either explicitly limited, explicitly excluded, or classified through a non-policy path instead of silently producing a generic missing-policy signal. +2. **Given** a subject type with a valid resolution path, **When** the run evaluates it, **Then** the stored outcome reflects the correct subject class and local evidence model. + +--- + +### User Story 3 - Replace dev-era broad reasons with the new contract cleanly (Priority: P3) + +A developer or operator needs the repository to move to the new structured gap contract without carrying obsolete development-only run payloads forward just for compatibility. + +**Why this priority**: Staying in a mixed old-and-new state during development would preserve ambiguity in exactly the area this feature is trying to fix. + +**Independent Test**: Remove or regenerate old development runs, create a new run under the updated contract, and verify that the existing surfaces expose subject class, resolution meaning, and action category without fallback to the old broad reason contract. + +**Acceptance Scenarios**: + +1. **Given** development-only runs that use the old broad reason shape, **When** the team chooses to clean or regenerate them, **Then** the product does not require runtime preservation of the obsolete shape to proceed. +2. **Given** a new run created after this foundation is implemented, **When** an operator opens the existing detail surfaces, **Then** the run exposes subject class, resolution meaning, and action category without requiring a new screen. + +### Edge Cases + +- A run contains a mix of policy-backed, foundation-backed, inventory-backed, and derived subjects. Each subject must keep its own resolution meaning instead of being normalized into one broad reason bucket. +- A subject is supported in configuration but currently lacks a truthful runtime resolution path. The system must not silently enter the subject into compare or capture as if the path were valid. +- A transient upstream or budget-related failure occurs for one subject while another subject in the same run is structurally not policy-backed. The surface must keep transient and structural meaning distinct. +- Development data may still contain obsolete broad-reason payloads during rollout. The team may remove or regenerate those runs instead of extending the runtime contract to support them indefinitely. +- Two identical subjects evaluated against the same tenant-local state at different points in the same release must produce the same resolution outcome and operator meaning. + +## Requirements *(mandatory)* + +**Constitution alignment (required):** This feature does not add new external provider calls or a new long-running operation type. It establishes a stricter semantic contract for how existing baseline compare and capture workflows classify subjects, persist evidence-gap meaning, and describe operator action truth. Existing tenant isolation, preview, and audit expectations remain in force. + +**Constitution alignment (OPS-UX):** Existing compare and capture runs continue to use the established three-surface feedback contract. Run status and outcome remain service-owned. Summary counts remain numeric and lifecycle-safe. This feature extends the semantic detail stored in run context so evidence-gap meaning is deterministic, reproducible, and available on progress and terminal surfaces without redefining run lifecycle ownership. + +**Constitution alignment (RBAC-UX):** This feature changes what existing entitled users can understand on run-detail and tenant review surfaces, not who may access those surfaces. Non-members remain deny-as-not-found. Members who lack the relevant capability remain forbidden only after entitlement is established. No cross-tenant visibility or capability broadening is introduced. + +**Constitution alignment (OPS-EX-AUTH-001):** Not applicable beyond reaffirming that monitoring and operations surfaces continue to avoid synchronous auth-handshake behavior. + +**Constitution alignment (BADGE-001):** If any status-like labels are refined for evidence-gap meaning, the semantic mapping must remain centralized and shared across dense and detailed surfaces. This feature must not create ad hoc surface-specific meanings for structural, operational, or transient states. + +**Constitution alignment (UI-NAMING-001):** Operator-facing wording must describe the object class and root cause before advice. Labels should use domain language such as “Policy record missing locally”, “Inventory-backed foundation subject”, or “Retry may help”, and avoid implementation-first phrasing. + +**Constitution alignment (OPSURF-001):** Existing run detail and tenant review surfaces remain operator-first. Default-visible content must answer whether the issue is structural, operational, or transient before exposing raw diagnostics. Mutation scope messaging for existing compare actions remains unchanged. + +**Constitution alignment (Filament Action Surfaces):** The affected Filament pages remain compliant with the Action Surface Contract. No new destructive actions are introduced. Existing compare or review actions remain read or inspect oriented, and this feature changes interpretation rather than mutation behavior. + +**Constitution alignment (UX-001 — Layout & Information Architecture):** This feature reuses existing layouts and surfaces. The required change is semantic clarity, not a new layout pattern. Existing sections and detail affordances must present the new meaning without introducing naked diagnostics or a parallel screen. + +### Functional Requirements + +- **FR-001**: The system MUST determine the subject class for every compare or capture subject before attempting local resolution. +- **FR-002**: The system MUST support, at minimum, policy-backed, inventory-backed, foundation-backed, and derived subject classes for new runs. +- **FR-003**: The system MUST choose the local resolution strategy from the subject class and supported capability contract, rather than implicitly treating every in-scope subject as policy-backed. +- **FR-004**: The system MUST distinguish policy-backed missing-local-record cases from structural foundation or inventory-only cases in new run outputs. +- **FR-005**: The system MUST support a precise evidence-gap reason taxonomy for new runs that can separately represent missing policy records, missing inventory records, structural non-policy-backed subjects, resolution mismatches, invalid or duplicate subjects, transient capture failures, ambiguity, and budget or throttling limits. +- **FR-006**: The system MUST persist structured gap metadata for new runs that includes subject class, resolution meaning, and operator action category, rather than relying only on a broad reason code and a raw subject key. +- **FR-007**: The system MUST provide an explicit resolution outcome for each evaluated subject, including successful resolution path, structural limitation, missing local artifact, or transient failure as applicable. +- **FR-008**: The system MUST prevent baseline support metadata from overpromising compare or capture capability when no truthful runtime resolution path exists for that subject class. +- **FR-009**: The system MUST classify new gaps so operators can tell whether retry, backup or sync, or product follow-up is the correct next action. +- **FR-010**: The system MUST NOT persist the historical broad policy-not-found reason as the sole reason for newly created structural cases that have a more precise semantic classification. +- **FR-011**: During development, the system MAY invalidate or discard previously stored run payloads that only contain the broad legacy reason if that simplifies migration to the new structured contract. +- **FR-012**: The system MUST preserve already precise reason families, including transient and ambiguity-related cases, without collapsing them into the new structural taxonomy. +- **FR-013**: The system MUST keep the semantic meaning aligned across dense landing surfaces and richer detail surfaces so the same run does not communicate different root causes on different pages. +- **FR-014**: The system MUST derive resolution meaning on the backend so run context, auditability, and diagnostic replay do not depend on UI-only interpretation. +- **FR-015**: The system MUST produce the same resolution outcome and operator-facing meaning for the same subject and tenant-local state whenever the input conditions are unchanged. +- **FR-016**: The system MUST allow inventory-backed or foundation-backed supported subjects to remain in scope only when their compare or capture behavior can be described truthfully through the resolution contract. + +### Assumptions + +- Foundation-backed subjects remain eligible for compare or capture only when the product can truthfully classify them through an inventory-backed or limited non-policy resolution path. Otherwise they are treated as explicitly unsupported for that operation rather than as generic missing-policy cases. +- Subject class and resolution outcome are both required because they answer different operator questions: what kind of object is this, and what happened when the system tried to resolve it. +- The repository is still in active development, so breaking cleanup of previously stored development run payloads is acceptable when it removes obsolete broad-reason semantics instead of preserving them. +- Newly created runs are expected to use the new structured contract immediately; there is no requirement to keep the old broad reason shape alive for future writes. +- This foundation spec establishes root-cause truth and runtime support truth. Fidelity richness, renderer density, and deeper wording refinements are handled in follow-on work. + +### Deferred Scope + +- New renderer families, fidelity badges, or snapshot richness redesign are not included in this feature. +- This feature does not redefine content diff algorithms, reporting exports, or large historical data backfills. +- This feature does not require a new operator screen. It upgrades semantic truth on existing surfaces. +- This feature does not preserve historical development run payloads only for compatibility's sake. +- This feature does not create dual-read or dual-write architecture for old and new gap semantics unless a concrete development need emerges later. +- New downstream domain behavior, including new findings, alerts, or follow-on automation, must be designed around the new structured contract rather than the old broad reason. + +## Development Migration Policy + +- **Breaking Cleanup Is Acceptable**: Existing development-only compare and capture runs MAY be deleted, regenerated, or rendered invalid if that removes obsolete broad-reason semantics and keeps the runtime model cleaner. +- **Single Contract Going Forward**: Newly created runs MUST write the new structured resolution and gap contract only. +- **No Parallel Semantic Core**: The old broad reason MAY be recognized temporarily in one-off development utilities or cleanup scripts, but it MUST NOT remain a first-class domain contract for ongoing feature work. +- **Regenerate Over Preserve**: When tests, fixtures, or local demo data depend on the old shape, the preferred path is to rebuild them against the new contract instead of extending production code to preserve the obsolete structure. + +## UI Action Matrix *(mandatory when Filament is changed)* + +| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions | +|---|---|---|---|---|---|---|---|---|---|---| +| Baseline compare or capture run detail | Existing canonical run-detail surface | Existing navigation and refresh actions remain | Existing detail and diagnostic sections remain the inspect affordance | None added | None | No new CTA. Empty states explain whether the run has no gaps or whether development data must be regenerated under the new contract. | Existing run-detail header actions remain | Not applicable | Existing run-backed audit semantics remain | Read-only semantic upgrade; no new mutation surface | +| Tenant baseline compare landing and related review surfaces | Existing tenant-scoped review surfaces | Existing compare and navigation actions remain | Existing summary and detail sections remain the inspect affordance | None added | None | Existing compare CTA remains; no new dangerous action is introduced | Existing page-level actions remain | Not applicable | Existing run-backed audit semantics remain | Read-only semantic upgrade; same actions, clearer meaning | + +### Key Entities *(include if feature involves data)* + +- **Subject**: A compare or capture target that the product must classify and resolve against tenant-local evidence before it can judge trust or completeness. +- **Subject Class**: The business-level class that describes whether a subject is policy-backed, inventory-backed, foundation-backed, or derived. +- **Resolution Outcome**: The deterministic result of attempting to resolve a subject locally, including both successful resolution paths and precise failure or limitation meanings. +- **Evidence Gap Detail**: The structured record attached to a run that captures which subject was affected, how it was classified, what local evidence expectation applied, and which operator action category follows. +- **Support Capability Contract**: The support promise that states whether a subject type may enter compare or capture and through which truthful resolution path. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: In validation runs containing both structural and missing-local-data cases, operators can distinguish the two classes from the default-visible surface without opening raw diagnostics in 100% of sampled review sessions. +- **SC-002**: For every supported subject type included in a release validation pack, the runtime either produces a truthful resolution path or classifies the type as explicitly limited or unsupported before misleading broad-cause gaps are emitted. +- **SC-003**: Local development data, tests, and fixtures can be regenerated against the new structured contract without requiring production code to preserve the obsolete broad-reason payload shape. +- **SC-004**: New runs expose enough structured gap metadata that operators can determine whether retry, backup or sync, or product follow-up is the next action in a single page visit. +- **SC-005**: Replaying the same subject against the same tenant-local state yields the same stored resolution outcome and operator action category across repeated validation runs. + +## Definition of Done + +- Newly created compare and capture runs persist the new structured resolution contract and do not rely on the broad legacy reason as their primary semantic output. +- Development fixtures, local data, and tests that depended on the old broad reason shape are either regenerated or intentionally removed instead of forcing the runtime to preserve obsolete semantics. +- New domain logic introduced for this feature uses subject class, resolution outcome, and structured gap metadata as the source of truth instead of branching on the legacy broad reason. +- Structural, operational, and transient cases are distinguishable in backend persistence and in operator-facing interpretation. +- Baseline-supported subject types do not enter the runtime path with a silent structural resolver mismatch. diff --git a/specs/163-baseline-subject-resolution/tasks.md b/specs/163-baseline-subject-resolution/tasks.md new file mode 100644 index 00000000..c65925f3 --- /dev/null +++ b/specs/163-baseline-subject-resolution/tasks.md @@ -0,0 +1,194 @@ +# Tasks: Baseline Subject Resolution and Evidence Gap Semantics Foundation + +**Input**: Design documents from `/specs/163-baseline-subject-resolution/` +**Prerequisites**: `plan.md`, `spec.md`, `research.md`, `data-model.md`, `contracts/openapi.yaml`, `quickstart.md` + +**Tests**: Tests are REQUIRED for this feature because it changes runtime behavior in compare, capture, and operator surfaces. +**Organization**: Tasks are grouped by user story so each story can be implemented and verified independently where practical. + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Create shared test support and optional cleanup-command scaffolding needed by later story work. + +- [X] T001 Scaffold the development-only cleanup command entry point in `app/Console/Commands/PurgeLegacyBaselineGapRuns.php` +- [X] T002 [P] Create shared compare and capture fixture builders for subject-resolution scenarios in `tests/Feature/Baselines/Support/BaselineSubjectResolutionFixtures.php` +- [X] T003 [P] Create shared assertion helpers for structured gap payloads in `tests/Feature/Baselines/Support/AssertsStructuredBaselineGaps.php` + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Build the shared subject-resolution contract and runtime capability foundation before user story work begins. + +**⚠️ CRITICAL**: No user story work should begin until this phase is complete. + +- [X] T004 [P] Create subject-resolution enums in `app/Support/Baselines/SubjectClass.php`, `app/Support/Baselines/ResolutionPath.php`, `app/Support/Baselines/ResolutionOutcome.php`, and `app/Support/Baselines/OperatorActionCategory.php` +- [X] T005 [P] Create subject-resolution value objects in `app/Support/Baselines/SubjectDescriptor.php`, `app/Support/Baselines/ResolutionOutcomeRecord.php`, and `app/Support/Baselines/SupportCapabilityRecord.php` +- [X] T006 Implement the shared resolver and capability services in `app/Support/Baselines/SubjectResolver.php` and `app/Support/Baselines/BaselineSupportCapabilityGuard.php` +- [X] T007 Update metadata derivation for subject classes and support capability in `app/Support/Inventory/InventoryPolicyTypeMeta.php` and `config/tenantpilot.php` +- [X] T008 Update shared scope and service wiring for the new resolver contract in `app/Support/Baselines/BaselineScope.php`, `app/Services/Baselines/BaselineCompareService.php`, and `app/Services/Baselines/BaselineCaptureService.php` +- [X] T009 [P] Add foundational unit coverage for enums, metadata rules, and resolver behavior in `tests/Unit/Support/Baselines/SubjectResolverTest.php` and `tests/Unit/Support/Inventory/InventoryPolicyTypeMetaResolutionContractTest.php` + +**Checkpoint**: Subject-resolution foundation and runtime capability guard are ready. + +--- + +## Phase 3: User Story 1 - Distinguish structural from missing-local-data gaps (Priority: P1) 🎯 MVP + +**Goal**: Ensure compare and capture can tell structural inventory or foundation limitations apart from missing local policy or inventory records. + +**Independent Test**: Run compare and capture flows that include both policy-backed and foundation-backed subjects and verify the resulting gaps separate structural resolver limits from missing local records without relying on raw diagnostics. + +### Tests for User Story 1 + +- [X] T010 [P] [US1] Add compare gap classification and ambiguity-preservation coverage for structural versus missing-local-data cases in `tests/Feature/Baselines/BaselineCompareGapClassificationTest.php` +- [X] T011 [P] [US1] Add capture gap classification coverage for policy-backed and foundation-backed subjects in `tests/Feature/Baselines/BaselineCaptureGapClassificationTest.php` +- [X] T012 [P] [US1] Add deterministic replay coverage for unchanged compare and capture inputs in `tests/Feature/Baselines/BaselineResolutionDeterminismTest.php` + +### Implementation for User Story 1 + +- [X] T013 [US1] Refactor subject lookup and outcome emission in `app/Services/Baselines/BaselineContentCapturePhase.php` to use `SubjectResolver` instead of raw policy-only lookup +- [X] T014 [US1] Update compare-side subject persistence and deterministic subject keys in `app/Jobs/CompareBaselineToTenantJob.php` and `app/Support/Baselines/BaselineSubjectKey.php` +- [X] T015 [US1] Replace broad gap taxonomy handling with structured structural versus operational semantics while preserving ambiguity-related reason families in `app/Support/Baselines/BaselineCompareReasonCode.php` and `app/Support/Baselines/BaselineCompareEvidenceGapDetails.php` +- [X] T016 [US1] Update compare summary aggregation for structural, operational, and transient counts in `app/Support/Baselines/BaselineCompareStats.php` + +**Checkpoint**: User Story 1 is functional and testable on its own. + +--- + +## Phase 4: User Story 2 - Keep support promises truthful at runtime (Priority: P2) + +**Goal**: Prevent baseline-supported types from entering compare or capture on a resolver path that cannot classify them truthfully. + +**Independent Test**: Evaluate supported subject types against runtime resolver capability and verify each type either enters execution with a valid path and meaningful outcome set or is limited or excluded before misleading gaps are produced. + +### Tests for User Story 2 + +- [X] T017 [P] [US2] Add feature coverage for runtime support-capability guard decisions in `tests/Feature/Baselines/BaselineSupportCapabilityGuardTest.php` +- [X] T018 [P] [US2] Add unit coverage for subject-class and support-mode derivation in `tests/Unit/Support/Inventory/InventoryPolicyTypeMetaBaselineSupportTest.php` + +### Implementation for User Story 2 + +- [X] T019 [US2] Extend baseline support metadata with subject-class and capability truth in `config/tenantpilot.php` and `app/Support/Inventory/InventoryPolicyTypeMeta.php` +- [X] T020 [US2] Enforce capability guard decisions before compare execution in `app/Support/Baselines/BaselineScope.php` and `app/Services/Baselines/BaselineCompareService.php` +- [X] T021 [US2] Enforce the same capability guard before capture execution in `app/Services/Baselines/BaselineCaptureService.php` and `app/Jobs/CaptureBaselineSnapshotJob.php` +- [X] T022 [US2] Persist operator-safe capability and support outcomes in `app/Jobs/CompareBaselineToTenantJob.php` and `app/Services/Baselines/BaselineContentCapturePhase.php` + +**Checkpoint**: User Story 2 is functional and testable on its own. + +--- + +## Phase 5: User Story 3 - Replace dev-era broad reasons with the new contract cleanly (Priority: P3) + +**Goal**: Move existing operator surfaces, tests, and development fixtures to the new structured gap contract without preserving the old broad reason shape in runtime code. + +**Independent Test**: Remove or regenerate old development runs, create a new run under the updated contract, and verify the existing surfaces expose subject class, resolution meaning, and action category without fallback to the old broad reason contract. + +### Tests for User Story 3 + +- [X] T023 [P] [US3] Add canonical run-detail regression coverage for structured gap semantics in `tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php` +- [X] T024 [P] [US3] Add tenant landing regression coverage for structured gap semantics in `tests/Feature/Filament/BaselineCompareLandingAdminTenantParityTest.php` +- [X] T025 [P] [US3] Add DB-only render regression coverage for gap surfaces in `tests/Feature/Filament/BaselineGapSurfacesDbOnlyRenderTest.php` +- [X] T026 [P] [US3] Add development cleanup and regeneration coverage for stale run payloads in `tests/Feature/Baselines/BaselineGapContractCleanupTest.php` + +### Implementation for User Story 3 + +- [X] T027 [US3] Update run-detail semantics for structured gap records in `app/Filament/Resources/OperationRunResource.php` and `resources/views/filament/infolists/entries/evidence-gap-subjects.blade.php` +- [X] T028 [US3] Update tenant landing semantics for structured gap records in `app/Filament/Pages/BaselineCompareLanding.php` and `resources/views/filament/pages/baseline-compare-landing.blade.php` +- [X] T029 [US3] Implement the cleanup command logic and run-selection criteria in `app/Console/Commands/PurgeLegacyBaselineGapRuns.php` and `app/Models/OperationRun.php` +- [X] T030 [US3] Remove broad-reason dev fixture usage and regenerate structured payload fixtures in `tests/Feature/Baselines/Support/BaselineSubjectResolutionFixtures.php`, `tests/Feature/Baselines`, and `tests/Feature/Filament` +- [X] T031 [US3] Finalize projection states and empty-state semantics for development cleanup in `app/Support/Baselines/BaselineCompareEvidenceGapDetails.php` and `app/Support/Baselines/BaselineCompareStats.php` + +**Checkpoint**: User Story 3 is functional and testable on its own. + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +**Purpose**: Final consistency, documentation alignment, and focused validation across all stories. + +- [X] T032 [P] Document the implemented cleanup command and final contract examples in `specs/163-baseline-subject-resolution/contracts/openapi.yaml`, `specs/163-baseline-subject-resolution/data-model.md`, and `specs/163-baseline-subject-resolution/quickstart.md` +- [X] T033 Run the focused validation pack and the cleanup command flow documented in `specs/163-baseline-subject-resolution/quickstart.md` + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: T002 and T003 start immediately; T001 is optional early scaffolding for US3 and does not block semantic work. +- **Foundational (Phase 2)**: Depends on shared support where needed for tests, but is not blocked by T001 cleanup-command scaffolding; blocks all user stories once started. +- **User Story 1 (Phase 3)**: Depends on Foundational completion; delivers the MVP semantic contract. +- **User Story 2 (Phase 4)**: Depends on Foundational completion; can proceed after Phase 2 and integrate with US1 outputs. +- **User Story 3 (Phase 5)**: Depends on US1 structured gap contract and benefits from US2 capability guard outputs. +- **Polish (Phase 6)**: Depends on all desired user stories being complete. + +### User Story Dependencies + +- **US1 (P1)**: Starts after Phase 2; no dependency on later stories. +- **US2 (P2)**: Starts after Phase 2; shares foundational components but remains independently testable. +- **US3 (P3)**: Starts after US1 because surface adoption depends on the new structured gap payload; it consumes T001 if the cleanup command scaffold was created early. + +### Within Each User Story + +- Tests must be written and fail before the implementation tasks they cover. +- Resolver or metadata changes must land before surface or projection updates that consume them. +- Story-level verification must pass before moving to the next dependent story. + +### Parallel Opportunities + +- T002 and T003 can run in parallel. +- T004 and T005 can run in parallel. +- T009 can run in parallel with the end of T006 through T008 once the foundational interfaces stabilize. +- T010, T011, and T012 can run in parallel. +- T017 and T018 can run in parallel. +- T023, T024, T025, and T026 can run in parallel. +- T032 can run in parallel with final validation prep once implementation stabilizes. + +--- + +## Parallel Example: User Story 1 + +```bash +# Launch the independent US1 tests together: +Task: "Add compare gap classification coverage in tests/Feature/Baselines/BaselineCompareGapClassificationTest.php" +Task: "Add capture gap classification coverage in tests/Feature/Baselines/BaselineCaptureGapClassificationTest.php" +``` + +## Parallel Example: User Story 2 + +```bash +# Launch the independent US2 tests together: +Task: "Add feature coverage for runtime support-capability guard decisions in tests/Feature/Baselines/BaselineSupportCapabilityGuardTest.php" +Task: "Add unit coverage for subject-class and support-mode derivation in tests/Unit/Support/Inventory/InventoryPolicyTypeMetaBaselineSupportTest.php" +``` + +## Parallel Example: User Story 3 + +```bash +# Launch the independent US3 regression tests together: +Task: "Add canonical run-detail regression coverage in tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php" +Task: "Add tenant landing regression coverage in tests/Feature/Filament/BaselineCompareLandingAdminTenantParityTest.php" +Task: "Add DB-only render regression coverage in tests/Feature/Filament/BaselineGapSurfacesDbOnlyRenderTest.php" +Task: "Add development cleanup and regeneration coverage in tests/Feature/Baselines/BaselineGapContractCleanupTest.php" +``` + +--- + +## Implementation Strategy + +### MVP First + +Deliver Phase 3 (US1) first after the foundational phase. That provides the core semantic win: structural versus missing-local-data gaps become distinguishable in persisted run context. + +### Incremental Delivery + +1. Finish Setup and Foundational phases. +2. Deliver US1 to establish the new structured resolution and gap contract. +3. Deliver US2 to stop support metadata from overpromising runtime capability. +4. Deliver US3 to move existing surfaces and development fixtures fully onto the new contract. +5. Finish with Polish to align the design docs and validation steps with the implemented behavior. + +### Suggested MVP Scope + +US1 only is the smallest valuable slice. It fixes the primary trust problem and creates the contract that US2 and US3 build on. diff --git a/tests/Feature/Baselines/BaselineCaptureGapClassificationTest.php b/tests/Feature/Baselines/BaselineCaptureGapClassificationTest.php new file mode 100644 index 00000000..0d4403d2 --- /dev/null +++ b/tests/Feature/Baselines/BaselineCaptureGapClassificationTest.php @@ -0,0 +1,110 @@ +set('tenantpilot.baselines.full_content_capture.enabled', true); + config()->set('tenantpilot.baselines.full_content_capture.max_items_per_run', 50); + config()->set('tenantpilot.baselines.full_content_capture.max_concurrency', 1); + config()->set('tenantpilot.baselines.full_content_capture.max_retries', 0); + + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $profile = BaselineProfile::factory()->active()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'capture_mode' => BaselineCaptureMode::FullContent->value, + 'scope_jsonb' => [ + 'policy_types' => ['deviceConfiguration'], + 'foundation_types' => ['roleScopeTag'], + ], + ]); + + $inventorySyncRun = createInventorySyncOperationRunWithCoverage( + tenant: $tenant, + statusByType: [ + 'deviceConfiguration' => 'succeeded', + 'roleScopeTag' => 'succeeded', + ], + foundationTypes: ['roleScopeTag'], + ); + + InventoryItem::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'external_id' => 'policy-missing-1', + 'policy_type' => 'deviceConfiguration', + 'display_name' => 'Missing Policy Subject', + 'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'etag-policy-missing'], + 'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(), + 'last_seen_at' => now(), + ]); + + InventoryItem::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'external_id' => 'scope-tag-1', + 'policy_type' => 'roleScopeTag', + 'display_name' => 'Structural Foundation Subject', + 'meta_jsonb' => ['odata_type' => '#microsoft.graph.roleScopeTag', 'etag' => 'etag-scope-tag'], + 'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(), + 'last_seen_at' => now(), + ]); + + $run = app(OperationRunService::class)->ensureRunWithIdentity( + tenant: $tenant, + type: OperationRunType::BaselineCapture->value, + identityInputs: ['baseline_profile_id' => (int) $profile->getKey()], + context: [ + 'baseline_profile_id' => (int) $profile->getKey(), + 'source_tenant_id' => (int) $tenant->getKey(), + 'effective_scope' => [ + 'policy_types' => ['deviceConfiguration'], + 'foundation_types' => ['roleScopeTag'], + 'truthful_types' => ['deviceConfiguration', 'roleScopeTag'], + ], + 'capture_mode' => BaselineCaptureMode::FullContent->value, + ], + initiator: $user, + ); + + (new CaptureBaselineSnapshotJob($run))->handle( + app(BaselineSnapshotIdentity::class), + app(InventoryMetaContract::class), + app(AuditLogger::class), + app(OperationRunService::class), + ); + + $run->refresh(); + + expect(data_get($run->context, 'baseline_capture.gaps.by_reason.policy_not_found'))->toBeNull() + ->and(data_get($run->context, 'baseline_capture.gaps.by_reason.policy_record_missing'))->toBe(1) + ->and(data_get($run->context, 'baseline_capture.gaps.by_reason.foundation_not_policy_backed'))->toBe(1); + + $subjects = data_get($run->context, 'baseline_capture.gaps.subjects'); + + expect($subjects)->toBeArray(); + AssertsStructuredBaselineGaps::assertStructuredSubjects($subjects); + + $subjectsByType = collect($subjects)->keyBy('policy_type'); + + expect(data_get($subjectsByType['deviceConfiguration'], 'subject_class'))->toBe('policy_backed') + ->and(data_get($subjectsByType['deviceConfiguration'], 'resolution_outcome'))->toBe('policy_record_missing') + ->and(data_get($subjectsByType['deviceConfiguration'], 'reason_code'))->toBe('policy_record_missing') + ->and(data_get($subjectsByType['deviceConfiguration'], 'structural'))->toBeFalse(); + + expect(data_get($subjectsByType['roleScopeTag'], 'subject_class'))->toBe('foundation_backed') + ->and(data_get($subjectsByType['roleScopeTag'], 'resolution_outcome'))->toBe('foundation_inventory_only') + ->and(data_get($subjectsByType['roleScopeTag'], 'reason_code'))->toBe('foundation_not_policy_backed') + ->and(data_get($subjectsByType['roleScopeTag'], 'structural'))->toBeTrue(); +}); diff --git a/tests/Feature/Baselines/BaselineCompareAmbiguousMatchGapTest.php b/tests/Feature/Baselines/BaselineCompareAmbiguousMatchGapTest.php index 850d5f61..f33baffd 100644 --- a/tests/Feature/Baselines/BaselineCompareAmbiguousMatchGapTest.php +++ b/tests/Feature/Baselines/BaselineCompareAmbiguousMatchGapTest.php @@ -12,6 +12,7 @@ use App\Support\Baselines\BaselineSubjectKey; use App\Support\OperationRunOutcome; use App\Support\OperationRunType; +use Tests\Feature\Baselines\Support\AssertsStructuredBaselineGaps; it('treats duplicate subject_key matches as an evidence gap and suppresses findings', function () { [$user, $tenant] = createUserWithTenant(role: 'owner'); @@ -116,9 +117,15 @@ expect(data_get($context, 'baseline_compare.evidence_gaps.by_reason.ambiguous_match'))->toBe(1); $gapSubjects = data_get($context, 'baseline_compare.evidence_gaps.subjects'); - expect($gapSubjects)->toBeArray() - ->and($gapSubjects)->toHaveKey('ambiguous_match') - ->and($gapSubjects['ambiguous_match'])->toBeArray() - ->and($gapSubjects['ambiguous_match'])->toHaveCount(1) - ->and($gapSubjects['ambiguous_match'][0])->toContain('deviceConfiguration|'); + expect($gapSubjects)->toBeArray(); + AssertsStructuredBaselineGaps::assertStructuredSubjects($gapSubjects); + + $ambiguousSubject = collect($gapSubjects)->firstWhere('reason_code', 'ambiguous_match'); + + expect($ambiguousSubject)->toBeArray() + ->and(data_get($ambiguousSubject, 'policy_type'))->toBe('deviceConfiguration') + ->and(data_get($ambiguousSubject, 'subject_class'))->toBe('policy_backed') + ->and(data_get($ambiguousSubject, 'resolution_outcome'))->toBe('ambiguous_match') + ->and(data_get($ambiguousSubject, 'operator_action_category'))->toBe('inspect_subject_mapping') + ->and(data_get($ambiguousSubject, 'subject_key'))->toContain('duplicate policy'); }); diff --git a/tests/Feature/Baselines/BaselineCompareGapClassificationTest.php b/tests/Feature/Baselines/BaselineCompareGapClassificationTest.php new file mode 100644 index 00000000..5d86f05d --- /dev/null +++ b/tests/Feature/Baselines/BaselineCompareGapClassificationTest.php @@ -0,0 +1,143 @@ +set('tenantpilot.baselines.full_content_capture.enabled', true); + config()->set('tenantpilot.baselines.full_content_capture.max_items_per_run', 50); + config()->set('tenantpilot.baselines.full_content_capture.max_concurrency', 1); + config()->set('tenantpilot.baselines.full_content_capture.max_retries', 0); + + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $profile = BaselineProfile::factory()->active()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'capture_mode' => BaselineCaptureMode::FullContent->value, + 'scope_jsonb' => [ + 'policy_types' => ['deviceConfiguration'], + 'foundation_types' => ['roleScopeTag'], + ], + ]); + + $snapshot = BaselineSnapshot::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'baseline_profile_id' => (int) $profile->getKey(), + 'captured_at' => now()->subMinute(), + ]); + + $profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]); + + $policySubjectKey = BaselineSubjectKey::fromDisplayName('Missing Compare Policy'); + $foundationSubjectKey = BaselineSubjectKey::fromDisplayName('Structural Compare Foundation'); + + expect($policySubjectKey)->not->toBeNull() + ->and($foundationSubjectKey)->not->toBeNull(); + + BaselineSnapshotItem::factory()->create([ + 'baseline_snapshot_id' => (int) $snapshot->getKey(), + 'subject_type' => 'policy', + 'subject_external_id' => BaselineSubjectKey::workspaceSafeSubjectExternalId('deviceConfiguration', (string) $policySubjectKey), + 'subject_key' => (string) $policySubjectKey, + 'policy_type' => 'deviceConfiguration', + 'baseline_hash' => hash('sha256', 'baseline-policy'), + 'meta_jsonb' => ['display_name' => 'Missing Compare Policy'], + ]); + + BaselineSnapshotItem::factory()->create([ + 'baseline_snapshot_id' => (int) $snapshot->getKey(), + 'subject_type' => 'policy', + 'subject_external_id' => BaselineSubjectKey::workspaceSafeSubjectExternalId('roleScopeTag', (string) $foundationSubjectKey), + 'subject_key' => (string) $foundationSubjectKey, + 'policy_type' => 'roleScopeTag', + 'baseline_hash' => hash('sha256', 'baseline-foundation'), + 'meta_jsonb' => ['display_name' => 'Structural Compare Foundation'], + ]); + + $inventorySyncRun = createInventorySyncOperationRunWithCoverage( + tenant: $tenant, + statusByType: [ + 'deviceConfiguration' => 'succeeded', + 'roleScopeTag' => 'succeeded', + ], + foundationTypes: ['roleScopeTag'], + ); + + InventoryItem::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'external_id' => 'compare-missing-policy', + 'policy_type' => 'deviceConfiguration', + 'display_name' => 'Missing Compare Policy', + 'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'etag-compare-policy'], + 'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(), + 'last_seen_at' => now(), + ]); + + InventoryItem::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'external_id' => 'compare-scope-tag', + 'policy_type' => 'roleScopeTag', + 'display_name' => 'Structural Compare Foundation', + 'meta_jsonb' => ['odata_type' => '#microsoft.graph.roleScopeTag', 'etag' => 'etag-compare-foundation'], + 'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(), + 'last_seen_at' => now(), + ]); + + $run = app(OperationRunService::class)->ensureRunWithIdentity( + tenant: $tenant, + type: OperationRunType::BaselineCompare->value, + identityInputs: ['baseline_profile_id' => (int) $profile->getKey()], + context: [ + 'baseline_profile_id' => (int) $profile->getKey(), + 'baseline_snapshot_id' => (int) $snapshot->getKey(), + 'effective_scope' => [ + 'policy_types' => ['deviceConfiguration'], + 'foundation_types' => ['roleScopeTag'], + 'truthful_types' => ['deviceConfiguration', 'roleScopeTag'], + ], + 'capture_mode' => BaselineCaptureMode::FullContent->value, + ], + initiator: $user, + ); + + (new CompareBaselineToTenantJob($run))->handle( + app(BaselineSnapshotIdentity::class), + app(AuditLogger::class), + app(OperationRunService::class), + ); + + $run->refresh(); + + expect(data_get($run->context, 'baseline_compare.evidence_gaps.by_reason.policy_not_found'))->toBeNull() + ->and(data_get($run->context, 'baseline_compare.evidence_gaps.by_reason.policy_record_missing'))->toBe(1) + ->and(data_get($run->context, 'baseline_compare.evidence_gaps.by_reason.foundation_not_policy_backed'))->toBe(1); + + $subjects = data_get($run->context, 'baseline_compare.evidence_gaps.subjects'); + + expect($subjects)->toBeArray(); + AssertsStructuredBaselineGaps::assertStructuredSubjects($subjects); + + $subjectsByType = collect($subjects)->keyBy('policy_type'); + + expect(data_get($subjectsByType['deviceConfiguration'], 'resolution_outcome'))->toBe('policy_record_missing') + ->and(data_get($subjectsByType['deviceConfiguration'], 'reason_code'))->toBe('policy_record_missing') + ->and(data_get($subjectsByType['deviceConfiguration'], 'operator_action_category'))->toBe('run_policy_sync_or_backup'); + + expect(data_get($subjectsByType['roleScopeTag'], 'resolution_outcome'))->toBe('foundation_inventory_only') + ->and(data_get($subjectsByType['roleScopeTag'], 'reason_code'))->toBe('foundation_not_policy_backed') + ->and(data_get($subjectsByType['roleScopeTag'], 'operator_action_category'))->toBe('product_follow_up'); +}); diff --git a/tests/Feature/Baselines/BaselineCompareResumeTokenTest.php b/tests/Feature/Baselines/BaselineCompareResumeTokenTest.php index 629dea45..1edb136d 100644 --- a/tests/Feature/Baselines/BaselineCompareResumeTokenTest.php +++ b/tests/Feature/Baselines/BaselineCompareResumeTokenTest.php @@ -17,6 +17,7 @@ use App\Support\Baselines\PolicyVersionCapturePurpose; use App\Support\OperationRunOutcome; use App\Support\OperationRunType; +use Tests\Feature\Baselines\Support\AssertsStructuredBaselineGaps; it('records a resume token when full-content compare cannot capture all subjects within budget', function (): void { config()->set('tenantpilot.baselines.full_content_capture.enabled', true); @@ -171,7 +172,7 @@ public function capture( expect($state['offset'])->toBe(1); }); -it('stores capture-phase gap subjects for policy_not_found evidence gaps', function (): void { +it('stores capture-phase gap subjects for policy-record-missing evidence gaps', function (): void { config()->set('tenantpilot.baselines.full_content_capture.enabled', true); config()->set('tenantpilot.baselines.full_content_capture.max_items_per_run', 10); config()->set('tenantpilot.baselines.full_content_capture.max_concurrency', 1); @@ -272,8 +273,19 @@ public function capture( $run->refresh(); - expect(data_get($run->context, 'baseline_compare.evidence_gaps.by_reason.policy_not_found'))->toBe(1) - ->and(data_get($run->context, 'baseline_compare.evidence_gaps.subjects.policy_not_found'))->toBe([ - 'deviceConfiguration|missing-capture-policy', - ]); + expect(data_get($run->context, 'baseline_compare.evidence_gaps.by_reason.policy_record_missing'))->toBe(1); + + $subjects = data_get($run->context, 'baseline_compare.evidence_gaps.subjects'); + + expect($subjects)->toBeArray(); + AssertsStructuredBaselineGaps::assertStructuredSubjects($subjects); + + $missingSubject = collect($subjects)->firstWhere('reason_code', 'policy_record_missing'); + + expect($missingSubject)->toBeArray() + ->and(data_get($missingSubject, 'policy_type'))->toBe('deviceConfiguration') + ->and(data_get($missingSubject, 'subject_key'))->toBe('missing capture policy') + ->and(data_get($missingSubject, 'subject_external_id'))->toBe('missing-capture-policy') + ->and(data_get($missingSubject, 'resolution_outcome'))->toBe('policy_record_missing') + ->and(data_get($missingSubject, 'operator_action_category'))->toBe('run_policy_sync_or_backup'); }); diff --git a/tests/Feature/Baselines/BaselineGapContractCleanupTest.php b/tests/Feature/Baselines/BaselineGapContractCleanupTest.php new file mode 100644 index 00000000..5003ead0 --- /dev/null +++ b/tests/Feature/Baselines/BaselineGapContractCleanupTest.php @@ -0,0 +1,137 @@ +create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'type' => OperationRunType::BaselineCompare->value, + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::PartiallySucceeded->value, + 'context' => [ + 'baseline_compare' => [ + 'evidence_gaps' => [ + 'count' => 1, + 'by_reason' => ['policy_not_found' => 1], + 'subjects' => [ + 'policy_not_found' => [ + 'deviceConfiguration|legacy-policy-gap', + ], + ], + ], + ], + ], + ]); + + $legacyCapture = OperationRun::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'type' => OperationRunType::BaselineCapture->value, + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::PartiallySucceeded->value, + 'context' => [ + 'baseline_capture' => [ + 'gaps' => [ + 'count' => 1, + 'by_reason' => ['policy_not_found' => 1], + 'subjects' => [ + 'policy_not_found' => [ + 'deviceConfiguration|legacy-capture-gap', + ], + ], + ], + ], + ], + ]); + + $structuredCompare = OperationRun::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'type' => OperationRunType::BaselineCompare->value, + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::PartiallySucceeded->value, + 'context' => BaselineSubjectResolutionFixtures::compareContext([ + BaselineSubjectResolutionFixtures::structuredGap(), + ]), + ]); + + expect($legacyCompare->hasLegacyBaselineGapPayload())->toBeTrue() + ->and($legacyCapture->hasLegacyBaselineGapPayload())->toBeTrue() + ->and($structuredCompare->hasStructuredBaselineGapPayload())->toBeTrue() + ->and($structuredCompare->hasLegacyBaselineGapPayload())->toBeFalse(); + + $this->artisan('tenantpilot:baselines:purge-legacy-gap-runs') + ->expectsOutputToContain('Dry run: matched 2 legacy baseline run(s). Re-run with --force to delete them.') + ->assertSuccessful(); + + expect(OperationRun::query()->whereKey($legacyCompare->getKey())->exists())->toBeTrue() + ->and(OperationRun::query()->whereKey($legacyCapture->getKey())->exists())->toBeTrue() + ->and(OperationRun::query()->whereKey($structuredCompare->getKey())->exists())->toBeTrue(); +}); + +it('deletes only legacy baseline gap runs when forced', function (): void { + [, $tenant] = createUserWithTenant(role: 'owner'); + + $legacyCompare = OperationRun::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'type' => OperationRunType::BaselineCompare->value, + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::PartiallySucceeded->value, + 'context' => [ + 'baseline_compare' => [ + 'evidence_gaps' => [ + 'count' => 1, + 'by_reason' => ['policy_not_found' => 1], + 'subjects' => [ + 'policy_not_found' => [ + 'deviceConfiguration|legacy-policy-gap', + ], + ], + ], + ], + ], + ]); + + $structuredCompare = OperationRun::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'type' => OperationRunType::BaselineCompare->value, + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::PartiallySucceeded->value, + 'context' => BaselineSubjectResolutionFixtures::compareContext([ + BaselineSubjectResolutionFixtures::structuredGap(), + ]), + ]); + + $structuredCapture = OperationRun::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'type' => OperationRunType::BaselineCapture->value, + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::PartiallySucceeded->value, + 'context' => BaselineSubjectResolutionFixtures::captureContext([ + BaselineSubjectResolutionFixtures::structuredGap(), + ]), + ]); + + $this->artisan('tenantpilot:baselines:purge-legacy-gap-runs', ['--force' => true]) + ->expectsOutputToContain('Deleted 1 legacy baseline run(s).') + ->assertSuccessful(); + + expect(OperationRun::query()->whereKey($legacyCompare->getKey())->exists())->toBeFalse() + ->and(OperationRun::query()->whereKey($structuredCompare->getKey())->exists())->toBeTrue() + ->and(OperationRun::query()->whereKey($structuredCapture->getKey())->exists())->toBeTrue(); +}); diff --git a/tests/Feature/Baselines/BaselineResolutionDeterminismTest.php b/tests/Feature/Baselines/BaselineResolutionDeterminismTest.php new file mode 100644 index 00000000..e6db3bae --- /dev/null +++ b/tests/Feature/Baselines/BaselineResolutionDeterminismTest.php @@ -0,0 +1,192 @@ +set('tenantpilot.baselines.full_content_capture.enabled', true); + config()->set('tenantpilot.baselines.full_content_capture.max_items_per_run', 50); + config()->set('tenantpilot.baselines.full_content_capture.max_concurrency', 1); + config()->set('tenantpilot.baselines.full_content_capture.max_retries', 0); + + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $profile = BaselineProfile::factory()->active()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'capture_mode' => BaselineCaptureMode::FullContent->value, + 'scope_jsonb' => [ + 'policy_types' => ['deviceConfiguration'], + 'foundation_types' => ['roleScopeTag'], + ], + ]); + + $snapshot = BaselineSnapshot::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'baseline_profile_id' => (int) $profile->getKey(), + 'captured_at' => now()->subMinute(), + ]); + + $profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]); + + $policySubjectKey = BaselineSubjectKey::fromDisplayName('Deterministic Missing Policy'); + $foundationSubjectKey = BaselineSubjectKey::fromDisplayName('Deterministic Foundation'); + + BaselineSnapshotItem::factory()->create([ + 'baseline_snapshot_id' => (int) $snapshot->getKey(), + 'subject_type' => 'policy', + 'subject_external_id' => BaselineSubjectKey::workspaceSafeSubjectExternalId('deviceConfiguration', (string) $policySubjectKey), + 'subject_key' => (string) $policySubjectKey, + 'policy_type' => 'deviceConfiguration', + 'baseline_hash' => hash('sha256', 'deterministic-policy'), + 'meta_jsonb' => ['display_name' => 'Deterministic Missing Policy'], + ]); + + BaselineSnapshotItem::factory()->create([ + 'baseline_snapshot_id' => (int) $snapshot->getKey(), + 'subject_type' => 'policy', + 'subject_external_id' => BaselineSubjectKey::workspaceSafeSubjectExternalId('roleScopeTag', (string) $foundationSubjectKey), + 'subject_key' => (string) $foundationSubjectKey, + 'policy_type' => 'roleScopeTag', + 'baseline_hash' => hash('sha256', 'deterministic-foundation'), + 'meta_jsonb' => ['display_name' => 'Deterministic Foundation'], + ]); + + $inventorySyncRun = createInventorySyncOperationRunWithCoverage( + tenant: $tenant, + statusByType: [ + 'deviceConfiguration' => 'succeeded', + 'roleScopeTag' => 'succeeded', + ], + foundationTypes: ['roleScopeTag'], + ); + + InventoryItem::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'external_id' => 'deterministic-policy', + 'policy_type' => 'deviceConfiguration', + 'display_name' => 'Deterministic Missing Policy', + 'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'etag-deterministic-policy'], + 'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(), + 'last_seen_at' => now(), + ]); + + InventoryItem::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'external_id' => 'deterministic-foundation', + 'policy_type' => 'roleScopeTag', + 'display_name' => 'Deterministic Foundation', + 'meta_jsonb' => ['odata_type' => '#microsoft.graph.roleScopeTag', 'etag' => 'etag-deterministic-foundation'], + 'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(), + 'last_seen_at' => now(), + ]); + + $captureRunA = OperationRun::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'type' => OperationRunType::BaselineCapture->value, + 'status' => OperationRunStatus::Queued->value, + 'outcome' => OperationRunOutcome::Pending->value, + 'context' => [ + 'baseline_profile_id' => (int) $profile->getKey(), + 'source_tenant_id' => (int) $tenant->getKey(), + 'effective_scope' => [ + 'policy_types' => ['deviceConfiguration'], + 'foundation_types' => ['roleScopeTag'], + 'truthful_types' => ['deviceConfiguration', 'roleScopeTag'], + ], + 'capture_mode' => BaselineCaptureMode::FullContent->value, + ], + 'user_id' => (int) $user->getKey(), + ]); + + $captureRunB = OperationRun::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'type' => OperationRunType::BaselineCapture->value, + 'status' => OperationRunStatus::Queued->value, + 'outcome' => OperationRunOutcome::Pending->value, + 'context' => $captureRunA->context, + 'user_id' => (int) $user->getKey(), + ]); + + (new CaptureBaselineSnapshotJob($captureRunA))->handle( + app(BaselineSnapshotIdentity::class), + app(InventoryMetaContract::class), + app(AuditLogger::class), + app(OperationRunService::class), + ); + + (new CaptureBaselineSnapshotJob($captureRunB))->handle( + app(BaselineSnapshotIdentity::class), + app(InventoryMetaContract::class), + app(AuditLogger::class), + app(OperationRunService::class), + ); + + $compareRunA = OperationRun::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'type' => OperationRunType::BaselineCompare->value, + 'status' => OperationRunStatus::Queued->value, + 'outcome' => OperationRunOutcome::Pending->value, + 'context' => [ + 'baseline_profile_id' => (int) $profile->getKey(), + 'baseline_snapshot_id' => (int) $snapshot->getKey(), + 'effective_scope' => [ + 'policy_types' => ['deviceConfiguration'], + 'foundation_types' => ['roleScopeTag'], + 'truthful_types' => ['deviceConfiguration', 'roleScopeTag'], + ], + 'capture_mode' => BaselineCaptureMode::FullContent->value, + ], + 'user_id' => (int) $user->getKey(), + ]); + + $compareRunB = OperationRun::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'type' => OperationRunType::BaselineCompare->value, + 'status' => OperationRunStatus::Queued->value, + 'outcome' => OperationRunOutcome::Pending->value, + 'context' => $compareRunA->context, + 'user_id' => (int) $user->getKey(), + ]); + + (new CompareBaselineToTenantJob($compareRunA))->handle( + app(BaselineSnapshotIdentity::class), + app(AuditLogger::class), + app(OperationRunService::class), + ); + + (new CompareBaselineToTenantJob($compareRunB))->handle( + app(BaselineSnapshotIdentity::class), + app(AuditLogger::class), + app(OperationRunService::class), + ); + + $captureSubjectsA = collect(data_get($captureRunA->fresh()->context, 'baseline_capture.gaps.subjects', []))->sortBy('policy_type')->values()->all(); + $captureSubjectsB = collect(data_get($captureRunB->fresh()->context, 'baseline_capture.gaps.subjects', []))->sortBy('policy_type')->values()->all(); + $compareSubjectsA = collect(data_get($compareRunA->fresh()->context, 'baseline_compare.evidence_gaps.subjects', []))->sortBy('policy_type')->values()->all(); + $compareSubjectsB = collect(data_get($compareRunB->fresh()->context, 'baseline_compare.evidence_gaps.subjects', []))->sortBy('policy_type')->values()->all(); + + expect($captureSubjectsA)->toBe($captureSubjectsB) + ->and($compareSubjectsA)->toBe($compareSubjectsB); +}); diff --git a/tests/Feature/Baselines/BaselineSupportCapabilityGuardTest.php b/tests/Feature/Baselines/BaselineSupportCapabilityGuardTest.php new file mode 100644 index 00000000..b3985431 --- /dev/null +++ b/tests/Feature/Baselines/BaselineSupportCapabilityGuardTest.php @@ -0,0 +1,121 @@ + 'brokenFoundation', + 'label' => 'Broken Foundation', + 'baseline_compare' => [ + 'supported' => true, + 'identity_strategy' => 'external_id', + 'resolution' => [ + 'subject_class' => 'foundation_backed', + 'resolution_path' => 'foundation_policy', + 'compare_capability' => 'supported', + 'capture_capability' => 'supported', + 'source_model_expected' => 'inventory', + ], + ], + ]; + + config()->set('tenantpilot.foundation_types', $foundationTypes); +} + +it('persists truthful compare scope capability decisions before dispatching compare work', function (): void { + Bus::fake(); + appendBrokenFoundationSupportConfig(); + + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $profile = BaselineProfile::factory()->active()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'capture_mode' => BaselineCaptureMode::Opportunistic->value, + 'scope_jsonb' => [ + 'policy_types' => ['deviceConfiguration'], + 'foundation_types' => ['roleScopeTag', 'brokenFoundation', 'unknownFoundation'], + ], + ]); + + $snapshot = BaselineSnapshot::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'baseline_profile_id' => (int) $profile->getKey(), + ]); + + $profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]); + + BaselineTenantAssignment::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'baseline_profile_id' => (int) $profile->getKey(), + ]); + + $result = app(BaselineCompareService::class)->startCompare($tenant, $user); + + expect($result['ok'])->toBeTrue(); + + $run = $result['run']; + $scope = data_get($run->context, 'effective_scope'); + + expect(data_get($scope, 'truthful_types'))->toBe(['deviceConfiguration', 'roleScopeTag']) + ->and(data_get($scope, 'limited_types'))->toBe(['roleScopeTag']) + ->and(data_get($scope, 'all_types'))->toBe(['brokenFoundation', 'deviceConfiguration', 'roleScopeTag']) + ->and(data_get($scope, 'unsupported_types'))->toBe(['brokenFoundation']) + ->and(data_get($scope, 'invalid_support_types'))->toBe(['brokenFoundation']) + ->and(data_get($scope, 'capabilities.deviceConfiguration.support_mode'))->toBe('supported') + ->and(data_get($scope, 'capabilities.roleScopeTag.support_mode'))->toBe('limited') + ->and(data_get($scope, 'capabilities.brokenFoundation.support_mode'))->toBe('invalid_support_config') + ->and(data_get($scope, 'capabilities.unknownFoundation.support_mode'))->toBeNull(); + + Bus::assertDispatched(CompareBaselineToTenantJob::class); +}); + +it('persists the same truthful scope capability decisions before dispatching capture work', function (): void { + Bus::fake(); + appendBrokenFoundationSupportConfig(); + + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $profile = BaselineProfile::factory()->active()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'capture_mode' => BaselineCaptureMode::Opportunistic->value, + 'scope_jsonb' => [ + 'policy_types' => ['deviceConfiguration'], + 'foundation_types' => ['roleScopeTag', 'brokenFoundation', 'unknownFoundation'], + ], + ]); + + $result = app(BaselineCaptureService::class)->startCapture($profile, $tenant, $user); + + expect($result['ok'])->toBeTrue(); + + $run = $result['run']; + $scope = data_get($run->context, 'effective_scope'); + + expect(data_get($scope, 'truthful_types'))->toBe(['deviceConfiguration', 'roleScopeTag']) + ->and(data_get($scope, 'limited_types'))->toBe(['roleScopeTag']) + ->and(data_get($scope, 'all_types'))->toBe(['brokenFoundation', 'deviceConfiguration', 'roleScopeTag']) + ->and(data_get($scope, 'unsupported_types'))->toBe(['brokenFoundation']) + ->and(data_get($scope, 'invalid_support_types'))->toBe(['brokenFoundation']) + ->and(data_get($scope, 'capabilities.deviceConfiguration.support_mode'))->toBe('supported') + ->and(data_get($scope, 'capabilities.roleScopeTag.support_mode'))->toBe('limited') + ->and(data_get($scope, 'capabilities.brokenFoundation.support_mode'))->toBe('invalid_support_config') + ->and(data_get($scope, 'capabilities.unknownFoundation.support_mode'))->toBeNull(); + + Bus::assertDispatched(CaptureBaselineSnapshotJob::class); +}); diff --git a/tests/Feature/Baselines/Support/AssertsStructuredBaselineGaps.php b/tests/Feature/Baselines/Support/AssertsStructuredBaselineGaps.php new file mode 100644 index 00000000..cebfb7d3 --- /dev/null +++ b/tests/Feature/Baselines/Support/AssertsStructuredBaselineGaps.php @@ -0,0 +1,43 @@ + $subject + */ + public static function assertStructuredSubject(array $subject): void + { + foreach ([ + 'policy_type', + 'subject_key', + 'subject_class', + 'resolution_path', + 'resolution_outcome', + 'reason_code', + 'operator_action_category', + 'structural', + 'retryable', + ] as $key) { + Assert::assertArrayHasKey($key, $subject); + } + } + + /** + * @param list> $subjects + */ + public static function assertStructuredSubjects(array $subjects): void + { + Assert::assertNotEmpty($subjects); + + foreach ($subjects as $subject) { + Assert::assertIsArray($subject); + self::assertStructuredSubject($subject); + } + } +} diff --git a/tests/Feature/Baselines/Support/BaselineSubjectResolutionFixtures.php b/tests/Feature/Baselines/Support/BaselineSubjectResolutionFixtures.php new file mode 100644 index 00000000..05744e7c --- /dev/null +++ b/tests/Feature/Baselines/Support/BaselineSubjectResolutionFixtures.php @@ -0,0 +1,85 @@ + $overrides + * @return array + */ + public static function structuredGap(array $overrides = []): array + { + return array_replace([ + 'policy_type' => 'deviceConfiguration', + 'subject_external_id' => 'subject-1', + 'subject_key' => 'deviceconfiguration|subject-1', + 'subject_class' => SubjectClass::PolicyBacked->value, + 'resolution_path' => ResolutionPath::Policy->value, + 'resolution_outcome' => ResolutionOutcome::PolicyRecordMissing->value, + 'reason_code' => 'policy_record_missing', + 'operator_action_category' => OperatorActionCategory::RunPolicySyncOrBackup->value, + 'structural' => false, + 'retryable' => false, + 'source_model_expected' => 'policy', + 'source_model_found' => null, + ], $overrides); + } + + /** + * @param list> $subjects + * @param array $overrides + * @return array + */ + public static function compareContext(array $subjects, array $overrides = []): array + { + $byReason = []; + + foreach ($subjects as $subject) { + $reasonCode = is_string($subject['reason_code'] ?? null) ? $subject['reason_code'] : 'unknown'; + $byReason[$reasonCode] = ($byReason[$reasonCode] ?? 0) + 1; + } + + return array_replace_recursive([ + 'baseline_compare' => [ + 'evidence_gaps' => [ + 'count' => count($subjects), + 'by_reason' => $byReason, + 'subjects' => $subjects, + ], + ], + ], $overrides); + } + + /** + * @param list> $subjects + * @param array $overrides + * @return array + */ + public static function captureContext(array $subjects, array $overrides = []): array + { + $byReason = []; + + foreach ($subjects as $subject) { + $reasonCode = is_string($subject['reason_code'] ?? null) ? $subject['reason_code'] : 'unknown'; + $byReason[$reasonCode] = ($byReason[$reasonCode] ?? 0) + 1; + } + + return array_replace_recursive([ + 'baseline_capture' => [ + 'gaps' => [ + 'count' => count($subjects), + 'by_reason' => $byReason, + 'subjects' => $subjects, + ], + ], + ], $overrides); + } +} diff --git a/tests/Feature/Filament/BaselineCompareEvidenceGapTableTest.php b/tests/Feature/Filament/BaselineCompareEvidenceGapTableTest.php index 60dbb05e..fd1fd25e 100644 --- a/tests/Feature/Filament/BaselineCompareEvidenceGapTableTest.php +++ b/tests/Feature/Filament/BaselineCompareEvidenceGapTableTest.php @@ -10,6 +10,7 @@ use Illuminate\Foundation\Testing\RefreshDatabase; use Livewire\Features\SupportTesting\Testable; use Livewire\Livewire; +use Tests\Feature\Baselines\Support\BaselineSubjectResolutionFixtures; uses(RefreshDatabase::class); @@ -23,28 +24,49 @@ function baselineCompareEvidenceGapTable(Testable $component): Table */ function baselineCompareEvidenceGapBuckets(): array { - return BaselineCompareEvidenceGapDetails::fromContext([ - 'baseline_compare' => [ - 'evidence_gaps' => [ - 'count' => 5, - 'by_reason' => [ - 'ambiguous_match' => 3, - 'policy_not_found' => 2, - ], - 'subjects' => [ - 'ambiguous_match' => [ - 'deviceConfiguration|WiFi-Corp-Profile', - 'deviceConfiguration|VPN-Always-On', - 'deviceCompliancePolicy|Windows-Encryption-Required', - ], - 'policy_not_found' => [ - 'deviceConfiguration|Deleted-Policy-ABC', - 'deviceCompliancePolicy|Retired-Compliance-Policy', - ], - ], - ], - ], - ])['buckets']; + return BaselineCompareEvidenceGapDetails::fromContext(BaselineSubjectResolutionFixtures::compareContext([ + BaselineSubjectResolutionFixtures::structuredGap([ + 'policy_type' => 'deviceConfiguration', + 'subject_key' => 'WiFi-Corp-Profile', + 'subject_class' => 'policy_backed', + 'resolution_path' => 'policy', + 'resolution_outcome' => 'ambiguous_match', + 'reason_code' => 'ambiguous_match', + 'operator_action_category' => 'inspect_subject_mapping', + ]), + BaselineSubjectResolutionFixtures::structuredGap([ + 'policy_type' => 'deviceConfiguration', + 'subject_key' => 'VPN-Always-On', + 'subject_class' => 'policy_backed', + 'resolution_path' => 'policy', + 'resolution_outcome' => 'ambiguous_match', + 'reason_code' => 'ambiguous_match', + 'operator_action_category' => 'inspect_subject_mapping', + ]), + BaselineSubjectResolutionFixtures::structuredGap([ + 'policy_type' => 'deviceCompliancePolicy', + 'subject_key' => 'Windows-Encryption-Required', + 'subject_class' => 'policy_backed', + 'resolution_path' => 'policy', + 'resolution_outcome' => 'ambiguous_match', + 'reason_code' => 'ambiguous_match', + 'operator_action_category' => 'inspect_subject_mapping', + ]), + BaselineSubjectResolutionFixtures::structuredGap([ + 'policy_type' => 'deviceConfiguration', + 'subject_key' => 'Deleted-Policy-ABC', + 'resolution_outcome' => 'policy_record_missing', + 'reason_code' => 'policy_record_missing', + 'operator_action_category' => 'run_policy_sync_or_backup', + ]), + BaselineSubjectResolutionFixtures::structuredGap([ + 'policy_type' => 'deviceCompliancePolicy', + 'subject_key' => 'Retired-Compliance-Policy', + 'resolution_outcome' => 'policy_record_missing', + 'reason_code' => 'policy_record_missing', + 'operator_action_category' => 'run_policy_sync_or_backup', + ]), + ]))['buckets']; } it('uses a Filament table for evidence-gap rows with searchable visible columns', function (): void { @@ -59,6 +81,9 @@ function baselineCompareEvidenceGapBuckets(): array expect($table->getDefaultSortColumn())->toBe('reason_label'); expect($table->getColumn('reason_label')?->isSearchable())->toBeTrue(); expect($table->getColumn('policy_type')?->isSearchable())->toBeTrue(); + expect($table->getColumn('subject_class_label')?->isSearchable())->toBeTrue(); + expect($table->getColumn('resolution_outcome_label')?->isSearchable())->toBeTrue(); + expect($table->getColumn('operator_action_category_label')?->isSearchable())->toBeTrue(); expect($table->getColumn('subject_key')?->isSearchable())->toBeTrue(); $component @@ -66,7 +91,13 @@ function baselineCompareEvidenceGapBuckets(): array ->assertSee('Deleted-Policy-ABC') ->assertSee('Reason') ->assertSee('Policy type') + ->assertSee('Subject class') + ->assertSee('Outcome') + ->assertSee('Next action') ->assertSee('Subject key') + ->assertSee('Policy-backed') + ->assertSee('Policy record missing') + ->assertSee('Run policy sync or backup') ->assertSee(TagBadgeCatalog::spec(TagBadgeDomain::PolicyType, 'deviceConfiguration')->label) ->assertSee(TagBadgeCatalog::spec(TagBadgeDomain::PolicyType, 'deviceCompliancePolicy')->label); }); @@ -84,12 +115,16 @@ function baselineCompareEvidenceGapBuckets(): array 'buckets' => baselineCompareEvidenceGapBuckets(), 'context' => 'tenant-landing-filters', ]) - ->filterTable('reason_code', 'policy_not_found') + ->filterTable('reason_code', 'policy_record_missing') ->assertSee('Retired-Compliance-Policy') ->assertDontSee('VPN-Always-On') ->filterTable('policy_type', 'deviceCompliancePolicy') ->assertSee('Retired-Compliance-Policy') - ->assertDontSee('Deleted-Policy-ABC'); + ->assertDontSee('Deleted-Policy-ABC') + ->filterTable('operator_action_category', 'run_policy_sync_or_backup') + ->assertSee('Run policy sync or backup') + ->filterTable('subject_class', 'policy_backed') + ->assertSee('Policy-backed'); }); it('shows an explicit empty state when only missing-detail buckets exist', function (): void { @@ -98,7 +133,7 @@ function baselineCompareEvidenceGapBuckets(): array 'evidence_gaps' => [ 'count' => 2, 'by_reason' => [ - 'policy_not_found' => 2, + 'policy_record_missing' => 2, ], 'subjects' => [], ], diff --git a/tests/Feature/Filament/BaselineCompareLandingAdminTenantParityTest.php b/tests/Feature/Filament/BaselineCompareLandingAdminTenantParityTest.php index 2025bb95..b44bec07 100644 --- a/tests/Feature/Filament/BaselineCompareLandingAdminTenantParityTest.php +++ b/tests/Feature/Filament/BaselineCompareLandingAdminTenantParityTest.php @@ -14,6 +14,7 @@ use Filament\Facades\Filament; use Illuminate\Foundation\Testing\RefreshDatabase; use Livewire\Livewire; +use Tests\Feature\Baselines\Support\BaselineSubjectResolutionFixtures; uses(RefreshDatabase::class); @@ -22,7 +23,29 @@ */ function baselineCompareLandingGapContext(): array { - return [ + return array_replace_recursive(BaselineSubjectResolutionFixtures::compareContext([ + BaselineSubjectResolutionFixtures::structuredGap([ + 'policy_type' => 'deviceConfiguration', + 'subject_key' => 'WiFi-Corp-Profile', + 'resolution_outcome' => 'ambiguous_match', + 'reason_code' => 'ambiguous_match', + 'operator_action_category' => 'inspect_subject_mapping', + ]), + BaselineSubjectResolutionFixtures::structuredGap([ + 'policy_type' => 'deviceConfiguration', + 'subject_key' => 'VPN-Always-On', + 'resolution_outcome' => 'ambiguous_match', + 'reason_code' => 'ambiguous_match', + 'operator_action_category' => 'inspect_subject_mapping', + ]), + BaselineSubjectResolutionFixtures::structuredGap([ + 'policy_type' => 'deviceConfiguration', + 'subject_key' => 'Deleted-Policy-ABC', + 'resolution_outcome' => 'policy_record_missing', + 'reason_code' => 'policy_record_missing', + 'operator_action_category' => 'run_policy_sync_or_backup', + ]), + ]), [ 'baseline_compare' => [ 'subjects_total' => 50, 'reason_code' => 'evidence_capture_incomplete', @@ -40,26 +63,8 @@ function baselineCompareLandingGapContext(): array 'failed' => 3, 'throttled' => 0, ], - 'evidence_gaps' => [ - 'count' => 5, - 'by_reason' => [ - 'ambiguous_match' => 3, - 'policy_not_found' => 2, - ], - 'ambiguous_match' => 3, - 'policy_not_found' => 2, - 'subjects' => [ - 'ambiguous_match' => [ - 'deviceConfiguration|WiFi-Corp-Profile', - 'deviceConfiguration|VPN-Always-On', - ], - 'policy_not_found' => [ - 'deviceConfiguration|Deleted-Policy-ABC', - ], - ], - ], ], - ]; + ]); } function seedBaselineCompareLandingGapRun(\App\Models\Tenant $tenant): OperationRun @@ -120,11 +125,15 @@ function seedBaselineCompareLandingGapRun(\App\Models\Tenant $tenant): Operation Livewire::test(BaselineCompareLanding::class) ->assertSee('Evidence gap details') ->assertSee('Search gap details') - ->assertSee('Search by reason, policy type, or subject key') + ->assertSee('Search by reason, type, class, outcome, action, or subject key') ->assertSee('Reason') ->assertSee('Ambiguous inventory match') - ->assertSee('Policy not found') + ->assertSee('Policy record missing') + ->assertSee('Subject class') + ->assertSee('Outcome') + ->assertSee('Next action') ->assertSee('WiFi-Corp-Profile') + ->assertSee('Inspect subject mapping') ->assertSee('Baseline compare evidence'); }); diff --git a/tests/Feature/Filament/BaselineCompareLandingDuplicateNamesBannerTest.php b/tests/Feature/Filament/BaselineCompareLandingDuplicateNamesBannerTest.php index 5a982c32..a51f7240 100644 --- a/tests/Feature/Filament/BaselineCompareLandingDuplicateNamesBannerTest.php +++ b/tests/Feature/Filament/BaselineCompareLandingDuplicateNamesBannerTest.php @@ -61,8 +61,9 @@ Livewire::test(BaselineCompareLanding::class) ->assertSee(__('baseline-compare.duplicate_warning_title')) - ->assertSee('share the same display name') - ->assertSee('cannot match them to the baseline'); + ->assertSee('share generic display names') + ->assertSee('resulting in 1 ambiguous subject') + ->assertSee('cannot match them safely to the baseline'); }); it('does not show the duplicate-name warning for stale rows outside the latest inventory sync', function (): void { @@ -140,6 +141,6 @@ Livewire::test(BaselineCompareLanding::class) ->assertDontSee(__('baseline-compare.duplicate_warning_title')) - ->assertDontSee('share the same display name') - ->assertDontSee('cannot match them to the baseline'); + ->assertDontSee('share generic display names') + ->assertDontSee('cannot match them safely to the baseline'); }); diff --git a/tests/Feature/Filament/BaselineCompareLandingWhyNoFindingsTest.php b/tests/Feature/Filament/BaselineCompareLandingWhyNoFindingsTest.php index 691a3c5a..6a5cf8c5 100644 --- a/tests/Feature/Filament/BaselineCompareLandingWhyNoFindingsTest.php +++ b/tests/Feature/Filament/BaselineCompareLandingWhyNoFindingsTest.php @@ -109,7 +109,7 @@ 'evidence_gaps' => [ 'count' => 2, 'by_reason' => [ - 'policy_not_found' => 2, + 'policy_record_missing' => 2, ], ], ], diff --git a/tests/Feature/Filament/BaselineGapSurfacesDbOnlyRenderTest.php b/tests/Feature/Filament/BaselineGapSurfacesDbOnlyRenderTest.php new file mode 100644 index 00000000..41506e0a --- /dev/null +++ b/tests/Feature/Filament/BaselineGapSurfacesDbOnlyRenderTest.php @@ -0,0 +1,135 @@ + + */ +function structuredGapSurfaceContext(): array +{ + return array_replace_recursive(BaselineSubjectResolutionFixtures::compareContext([ + BaselineSubjectResolutionFixtures::structuredGap([ + 'policy_type' => 'deviceConfiguration', + 'subject_key' => 'WiFi-Corp-Profile', + 'subject_class' => 'policy_backed', + 'resolution_path' => 'policy', + 'resolution_outcome' => 'policy_record_missing', + 'reason_code' => 'policy_record_missing', + 'operator_action_category' => 'run_policy_sync_or_backup', + ]), + BaselineSubjectResolutionFixtures::structuredGap([ + 'policy_type' => 'roleScopeTag', + 'subject_key' => 'scope-tag-finance', + 'subject_class' => 'foundation_backed', + 'resolution_path' => 'foundation_inventory', + 'resolution_outcome' => 'foundation_inventory_only', + 'reason_code' => 'foundation_not_policy_backed', + 'operator_action_category' => 'product_follow_up', + 'structural' => true, + ]), + ]), [ + 'baseline_compare' => [ + 'reason_code' => 'evidence_capture_incomplete', + 'coverage' => [ + 'proof' => true, + 'covered_types' => ['deviceConfiguration', 'roleScopeTag'], + 'uncovered_types' => [], + 'effective_types' => ['deviceConfiguration', 'roleScopeTag'], + ], + 'fidelity' => 'meta', + ], + ]); +} + +it('renders canonical run detail gap semantics from persisted db context only', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + bindFailHardGraphClient(); + Filament::setTenant(null, true); + + $run = OperationRun::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'type' => OperationRunType::BaselineCompare->value, + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::PartiallySucceeded->value, + 'context' => structuredGapSurfaceContext(), + 'completed_at' => now(), + ]); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) + ->get(route('admin.operations.view', ['run' => (int) $run->getKey()])) + ->assertOk() + ->assertSee('Evidence gap details') + ->assertSee('Policy-backed') + ->assertSee('Foundation-backed') + ->assertSee('Policy record missing') + ->assertSee('Foundation not policy-backed') + ->assertSee('Run policy sync or backup') + ->assertSee('Product follow-up') + ->assertSee('WiFi-Corp-Profile') + ->assertSee('scope-tag-finance'); +}); + +it('renders tenant landing gap semantics from persisted db context only', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + $this->actingAs($user); + + bindFailHardGraphClient(); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $profile = BaselineProfile::factory()->active()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + ]); + + $snapshot = BaselineSnapshot::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'baseline_profile_id' => (int) $profile->getKey(), + ]); + + $profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]); + + BaselineTenantAssignment::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'baseline_profile_id' => (int) $profile->getKey(), + ]); + + OperationRun::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'type' => OperationRunType::BaselineCompare->value, + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::PartiallySucceeded->value, + 'context' => structuredGapSurfaceContext(), + 'completed_at' => now(), + ]); + + Livewire::test(BaselineCompareLanding::class) + ->assertSee('Evidence gap details') + ->assertSee('Subject class') + ->assertSee('Outcome') + ->assertSee('Next action') + ->assertSee('Foundation-backed') + ->assertSee('Foundation not policy-backed') + ->assertSee('Product follow-up') + ->assertSee('scope-tag-finance'); +}); diff --git a/tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php b/tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php index c8b95e2c..33b2b653 100644 --- a/tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php +++ b/tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php @@ -13,6 +13,7 @@ use App\Support\Workspaces\WorkspaceContext; use Filament\Facades\Filament; use Illuminate\Testing\TestResponse; +use Tests\Feature\Baselines\Support\BaselineSubjectResolutionFixtures; function visiblePageText(TestResponse $response): string { @@ -31,7 +32,43 @@ function visiblePageText(TestResponse $response): string */ function baselineCompareGapContext(array $overrides = []): array { - return array_replace_recursive([ + return array_replace_recursive(BaselineSubjectResolutionFixtures::compareContext([ + BaselineSubjectResolutionFixtures::structuredGap([ + 'policy_type' => 'deviceConfiguration', + 'subject_key' => 'WiFi-Corp-Profile', + 'resolution_outcome' => 'ambiguous_match', + 'reason_code' => 'ambiguous_match', + 'operator_action_category' => 'inspect_subject_mapping', + ]), + BaselineSubjectResolutionFixtures::structuredGap([ + 'policy_type' => 'deviceConfiguration', + 'subject_key' => 'VPN-Always-On', + 'resolution_outcome' => 'ambiguous_match', + 'reason_code' => 'ambiguous_match', + 'operator_action_category' => 'inspect_subject_mapping', + ]), + BaselineSubjectResolutionFixtures::structuredGap([ + 'policy_type' => 'deviceConfiguration', + 'subject_key' => 'Email-Exchange-Config', + 'resolution_outcome' => 'ambiguous_match', + 'reason_code' => 'ambiguous_match', + 'operator_action_category' => 'inspect_subject_mapping', + ]), + BaselineSubjectResolutionFixtures::structuredGap([ + 'policy_type' => 'deviceConfiguration', + 'subject_key' => 'Deleted-Policy-ABC', + 'resolution_outcome' => 'policy_record_missing', + 'reason_code' => 'policy_record_missing', + 'operator_action_category' => 'run_policy_sync_or_backup', + ]), + BaselineSubjectResolutionFixtures::structuredGap([ + 'policy_type' => 'deviceConfiguration', + 'subject_key' => 'Removed-Config-XYZ', + 'resolution_outcome' => 'policy_record_missing', + 'reason_code' => 'policy_record_missing', + 'operator_action_category' => 'run_policy_sync_or_backup', + ]), + ]), [ 'baseline_compare' => [ 'subjects_total' => 50, 'reason_code' => 'evidence_capture_incomplete', @@ -49,26 +86,6 @@ function baselineCompareGapContext(array $overrides = []): array 'failed' => 3, 'throttled' => 0, ], - 'evidence_gaps' => [ - 'count' => 5, - 'by_reason' => [ - 'ambiguous_match' => 3, - 'policy_not_found' => 2, - ], - 'ambiguous_match' => 3, - 'policy_not_found' => 2, - 'subjects' => [ - 'ambiguous_match' => [ - 'deviceConfiguration|WiFi-Corp-Profile', - 'deviceConfiguration|VPN-Always-On', - 'deviceConfiguration|Email-Exchange-Config', - ], - 'policy_not_found' => [ - 'deviceConfiguration|Deleted-Policy-ABC', - 'deviceConfiguration|Removed-Config-XYZ', - ], - ], - ], ], ], $overrides); } @@ -295,15 +312,18 @@ function baselineCompareGapContext(array $overrides = []): array ->assertOk() ->assertSee('Evidence gap details') ->assertSee('Search gap details') - ->assertSee('Search by reason, policy type, or subject key') + ->assertSee('Search by reason, type, class, outcome, action, or subject key') ->assertSee('Reason') ->assertSee('Ambiguous inventory match') - ->assertSee('Policy not found') + ->assertSee('Policy record missing') ->assertSee('3 affected') ->assertSee('2 affected') ->assertSee('WiFi-Corp-Profile') ->assertSee('Deleted-Policy-ABC') ->assertSee('Policy type') + ->assertSee('Subject class') + ->assertSee('Outcome') + ->assertSee('Next action') ->assertSee('Subject key'); }); diff --git a/tests/Unit/Support/Baselines/SubjectResolverTest.php b/tests/Unit/Support/Baselines/SubjectResolverTest.php new file mode 100644 index 00000000..725cd496 --- /dev/null +++ b/tests/Unit/Support/Baselines/SubjectResolverTest.php @@ -0,0 +1,95 @@ +describeForCompare('deviceConfiguration', 'policy-1', 'deviceconfiguration|policy-1'); + $foundationDescriptor = $resolver->describeForCapture('roleScopeTag', 'scope-tag-1', 'rolescopetag|baseline'); + $rbacDescriptor = $resolver->describeForCompare('intuneRoleDefinition', 'role-def-1', 'rbac-role'); + + expect($policyDescriptor->subjectClass)->toBe(SubjectClass::PolicyBacked) + ->and($policyDescriptor->resolutionPath)->toBe(ResolutionPath::Policy) + ->and($policyDescriptor->supportMode)->toBe('supported') + ->and($policyDescriptor->sourceModelExpected)->toBe('policy'); + + expect($foundationDescriptor->subjectClass)->toBe(SubjectClass::FoundationBacked) + ->and($foundationDescriptor->resolutionPath)->toBe(ResolutionPath::FoundationInventory) + ->and($foundationDescriptor->supportMode)->toBe('limited') + ->and($foundationDescriptor->sourceModelExpected)->toBe('inventory'); + + expect($rbacDescriptor->subjectClass)->toBe(SubjectClass::FoundationBacked) + ->and($rbacDescriptor->resolutionPath)->toBe(ResolutionPath::FoundationPolicy) + ->and($rbacDescriptor->supportMode)->toBe('supported') + ->and($rbacDescriptor->sourceModelExpected)->toBe('policy'); +}); + +it('maps structural and operational outcomes without flattening them into policy_not_found', function (): void { + $resolver = app(SubjectResolver::class); + + $foundationDescriptor = $resolver->describeForCapture('notificationMessageTemplate', 'template-1', 'template-subject'); + $policyDescriptor = $resolver->describeForCompare('deviceConfiguration', 'policy-1', 'policy-subject'); + + $structuralOutcome = $resolver->structuralInventoryOnly($foundationDescriptor); + $missingPolicyOutcome = $resolver->missingExpectedRecord($policyDescriptor); + $throttledOutcome = $resolver->throttled($policyDescriptor); + + expect($structuralOutcome->resolutionOutcome)->toBe(ResolutionOutcome::FoundationInventoryOnly) + ->and($structuralOutcome->reasonCode)->toBe('foundation_not_policy_backed') + ->and($structuralOutcome->operatorActionCategory)->toBe(OperatorActionCategory::ProductFollowUp) + ->and($structuralOutcome->structural)->toBeTrue(); + + expect($missingPolicyOutcome->resolutionOutcome)->toBe(ResolutionOutcome::PolicyRecordMissing) + ->and($missingPolicyOutcome->reasonCode)->toBe('policy_record_missing') + ->and($missingPolicyOutcome->operatorActionCategory)->toBe(OperatorActionCategory::RunPolicySyncOrBackup) + ->and($missingPolicyOutcome->structural)->toBeFalse(); + + expect($throttledOutcome->resolutionOutcome)->toBe(ResolutionOutcome::Throttled) + ->and($throttledOutcome->retryable)->toBeTrue() + ->and($throttledOutcome->operatorActionCategory)->toBe(OperatorActionCategory::Retry); +}); + +it('guards unsupported or invalid support declarations before runtime work starts', function (): void { + $guard = app(BaselineSupportCapabilityGuard::class); + + config()->set('tenantpilot.foundation_types', [ + [ + 'type' => 'intuneRoleAssignment', + 'label' => 'Intune RBAC Role Assignment', + 'baseline_compare' => [ + 'supported' => false, + 'identity_strategy' => 'external_id', + ], + ], + [ + 'type' => 'brokenFoundation', + 'label' => 'Broken Foundation', + 'baseline_compare' => [ + 'supported' => true, + 'resolution' => [ + 'subject_class' => SubjectClass::FoundationBacked->value, + 'resolution_path' => 'broken', + 'compare_capability' => 'supported', + 'capture_capability' => 'supported', + 'source_model_expected' => 'inventory', + ], + ], + ], + ]); + + $result = $guard->guardTypes(['intuneRoleAssignment', 'brokenFoundation'], 'compare'); + + expect($result['allowed_types'])->toBe([]) + ->and($result['unsupported_types'])->toBe(['brokenFoundation', 'intuneRoleAssignment']) + ->and($result['invalid_support_types'])->toBe(['brokenFoundation']) + ->and(data_get($result, 'capabilities.brokenFoundation.support_mode'))->toBe('invalid_support_config') + ->and(data_get($result, 'capabilities.intuneRoleAssignment.support_mode'))->toBe('excluded'); +}); diff --git a/tests/Unit/Support/Inventory/InventoryPolicyTypeMetaBaselineSupportTest.php b/tests/Unit/Support/Inventory/InventoryPolicyTypeMetaBaselineSupportTest.php new file mode 100644 index 00000000..4ed562ff --- /dev/null +++ b/tests/Unit/Support/Inventory/InventoryPolicyTypeMetaBaselineSupportTest.php @@ -0,0 +1,60 @@ +toBeTrue() + ->and($contract['runtime_valid'])->toBeTrue() + ->and($contract['subject_class'])->toBe('foundation_backed') + ->and($contract['resolution_path'])->toBe('foundation_inventory') + ->and($contract['compare_capability'])->toBe('limited') + ->and($contract['capture_capability'])->toBe('limited') + ->and($contract['source_model_expected'])->toBe('inventory'); +}); + +it('treats unknown baseline types as derived and excluded from support promises', function (): void { + $contract = InventoryPolicyTypeMeta::baselineSupportContract('unknownFoundation'); + + expect($contract['config_supported'])->toBeFalse() + ->and($contract['runtime_valid'])->toBeTrue() + ->and($contract['subject_class'])->toBe('derived') + ->and($contract['resolution_path'])->toBe('derived') + ->and($contract['compare_capability'])->toBe('unsupported') + ->and($contract['capture_capability'])->toBe('unsupported') + ->and($contract['source_model_expected'])->toBe('derived'); +}); + +it('downgrades malformed baseline support declarations before they can overpromise runtime capability', function (): void { + $foundationTypes = is_array(config('tenantpilot.foundation_types')) ? config('tenantpilot.foundation_types') : []; + $foundationTypes[] = [ + 'type' => 'brokenFoundation', + 'label' => 'Broken Foundation', + 'baseline_compare' => [ + 'supported' => true, + 'identity_strategy' => 'external_id', + 'resolution' => [ + 'subject_class' => 'foundation_backed', + 'resolution_path' => 'foundation_policy', + 'compare_capability' => 'supported', + 'capture_capability' => 'supported', + 'source_model_expected' => 'inventory', + ], + ], + ]; + + config()->set('tenantpilot.foundation_types', $foundationTypes); + + $contract = InventoryPolicyTypeMeta::baselineSupportContract('brokenFoundation'); + + expect($contract['config_supported'])->toBeTrue() + ->and($contract['runtime_valid'])->toBeFalse() + ->and($contract['subject_class'])->toBe('foundation_backed') + ->and($contract['resolution_path'])->toBe('foundation_policy') + ->and($contract['compare_capability'])->toBe('unsupported') + ->and($contract['capture_capability'])->toBe('unsupported') + ->and($contract['source_model_expected'])->toBe('inventory'); +}); diff --git a/tests/Unit/Support/Inventory/InventoryPolicyTypeMetaResolutionContractTest.php b/tests/Unit/Support/Inventory/InventoryPolicyTypeMetaResolutionContractTest.php new file mode 100644 index 00000000..55a657e2 --- /dev/null +++ b/tests/Unit/Support/Inventory/InventoryPolicyTypeMetaResolutionContractTest.php @@ -0,0 +1,78 @@ +toBeTrue() + ->and($contract['runtime_valid'])->toBeTrue() + ->and($contract['subject_class'])->toBe(SubjectClass::PolicyBacked->value) + ->and($contract['resolution_path'])->toBe(ResolutionPath::Policy->value) + ->and($contract['compare_capability'])->toBe('supported') + ->and($contract['capture_capability'])->toBe('supported') + ->and($contract['source_model_expected'])->toBe('policy'); +}); + +it('derives limited inventory-backed foundation support from canonical metadata', function (): void { + $contract = InventoryPolicyTypeMeta::baselineSupportContract('roleScopeTag'); + + expect($contract['config_supported'])->toBeTrue() + ->and($contract['runtime_valid'])->toBeTrue() + ->and($contract['subject_class'])->toBe(SubjectClass::FoundationBacked->value) + ->and($contract['resolution_path'])->toBe(ResolutionPath::FoundationInventory->value) + ->and($contract['compare_capability'])->toBe('limited') + ->and($contract['capture_capability'])->toBe('limited') + ->and($contract['source_model_expected'])->toBe('inventory'); +}); + +it('derives supported foundation policy resolution for intune role definitions', function (): void { + $contract = InventoryPolicyTypeMeta::baselineSupportContract('intuneRoleDefinition'); + + expect($contract['config_supported'])->toBeTrue() + ->and($contract['runtime_valid'])->toBeTrue() + ->and($contract['subject_class'])->toBe(SubjectClass::FoundationBacked->value) + ->and($contract['resolution_path'])->toBe(ResolutionPath::FoundationPolicy->value) + ->and($contract['compare_capability'])->toBe('supported') + ->and($contract['capture_capability'])->toBe('supported') + ->and($contract['source_model_expected'])->toBe('policy'); +}); + +it('marks unsupported and malformed contracts deterministically', function (): void { + config()->set('tenantpilot.foundation_types', [ + [ + 'type' => 'intuneRoleAssignment', + 'label' => 'Intune RBAC Role Assignment', + 'baseline_compare' => [ + 'supported' => false, + 'identity_strategy' => 'external_id', + ], + ], + [ + 'type' => 'brokenFoundation', + 'label' => 'Broken Foundation', + 'baseline_compare' => [ + 'supported' => true, + 'resolution' => [ + 'resolution_path' => 'broken', + ], + ], + ], + ]); + + $unsupported = InventoryPolicyTypeMeta::baselineSupportContract('intuneRoleAssignment'); + $invalid = InventoryPolicyTypeMeta::baselineSupportContract('brokenFoundation'); + + expect($unsupported['config_supported'])->toBeFalse() + ->and($unsupported['compare_capability'])->toBe('unsupported') + ->and($unsupported['capture_capability'])->toBe('unsupported'); + + expect($invalid['config_supported'])->toBeTrue() + ->and($invalid['runtime_valid'])->toBeFalse() + ->and($invalid['compare_capability'])->toBe('unsupported') + ->and($invalid['capture_capability'])->toBe('unsupported'); +});